From 45666d33fc7cca1618449c646c97b3caac3f29ac Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 13:11:10 +0200 Subject: [PATCH 01/11] Plan independent BFF and adapt selected Sendico foundations (#34) --- Makefile | 12 + README.md | 24 +- backend/AGENTS.md | 15 ++ backend/PROVENANCE.md | 38 +++ backend/README.md | 42 ++- backend/cmd/bff/main.go | 46 ++++ backend/go.mod | 3 + backend/internal/browser/policy.go | 118 +++++++++ backend/internal/browser/policy_test.go | 79 ++++++ backend/internal/credential/token.go | 29 +++ backend/internal/credential/token_test.go | 24 ++ backend/internal/evidence/key.go | 43 ++++ backend/internal/evidence/key_test.go | 18 ++ backend/internal/httpapi/router.go | 28 ++ backend/internal/httpapi/router_test.go | 17 ++ docs/architecture/bff.md | 300 ++++++++++++++++++++++ docs/implementation.md | 33 +++ 17 files changed, 854 insertions(+), 15 deletions(-) create mode 100644 backend/AGENTS.md create mode 100644 backend/PROVENANCE.md create mode 100644 backend/cmd/bff/main.go create mode 100644 backend/go.mod create mode 100644 backend/internal/browser/policy.go create mode 100644 backend/internal/browser/policy_test.go create mode 100644 backend/internal/credential/token.go create mode 100644 backend/internal/credential/token_test.go create mode 100644 backend/internal/evidence/key.go create mode 100644 backend/internal/evidence/key_test.go create mode 100644 backend/internal/httpapi/router.go create mode 100644 backend/internal/httpapi/router_test.go create mode 100644 docs/architecture/bff.md diff --git a/Makefile b/Makefile index 0652fcd..edc5a03 100644 --- a/Makefile +++ b/Makefile @@ -8,3 +8,15 @@ test-frontend: cd frontend/packages/sxchnge_theme && flutter test cd frontend/packages/sxchnge_theme/example && flutter test cd frontend/app && flutter test + +.PHONY: test-backend lint-backend run-backend + +test-backend: + cd backend && go test -race ./... + +lint-backend: + cd backend && test -z "$$(gofmt -l .)" + cd backend && go vet ./... + +run-backend: + cd backend && go run ./cmd/bff diff --git a/README.md b/README.md index fc81f11..c511b64 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ with `go_router`. Its interface uses the shared theme's colors and typography. **Authentication, email verification and analytics use dummy services.** No real account or session is created, no email is delivered, and no analytics data is -transmitted. The backend is documentation only. Session/KYC access guards and -Sendico integration are not implemented. +transmitted. The backend has an independent Go operational shell; product services +are not connected. Session/KYC access guards and Sendico integration are not +implemented. ## Repository structure @@ -23,7 +24,7 @@ Sendico integration are not implemented. | [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font | | [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared auth contracts, providers, two-factor page and dummy services | | [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics | -| [`backend/`](backend/README.md) | Planned BFF and Sendico integration boundaries | +| [`backend/`](backend/README.md) | Independent Go BFF starter, adapted foundations and provider integration plan | | [`.agents/skills/`](.agents/skills) | Repository agent skills | | [`docs/`](docs/implementation.md) | Implementation records and UX measurement contracts | @@ -152,9 +153,9 @@ The intended integration is: **consumer app → sxchnge BFF → supported Sendic - The sxchnge organization, backend integration principal and individual customer references are distinct identities. Consumers must not share the integration account's customer identity. -- The BFF should use a supported API contract rather than copying Sendico's BFF - or depending on its local internal modules. Integration credentials belong in - the backend. +- The BFF uses a supported provider API. Selected Sendico components may be + adapted into SXCHNGE-owned code; local Sendico module dependencies are not used. + Integration credentials belong in the backend. Before backend integration, agree on the customer, KYC, eligibility/limits and customer-scoped payment contracts for one complete transaction flow. These are @@ -224,3 +225,14 @@ instructions also support authenticator challenges; actual TOTP enrollment, validation and recovery remain backend integration work. See the [two-factor UX contract](docs/ux/two-factor.md) and [shared auth package](frontend/packages/sxchnge_auth/README.md). + +## Backend integration plan + +The [BFF architecture and Sendico service audit](docs/architecture/bff.md) defines +consumer identity, financial-provider boundaries, reusable components, contract +gaps and delivery phases. Selected Sendico code has been adapted with recorded +[provenance](backend/PROVENANCE.md); the projects share no runtime modules. + +Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. +The starter exposes health and unconfigured readiness only. Authentication, +persistence and live financial operations remain the next implementation phases. diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..1a271dd --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,15 @@ +# BFF guidelines + +Follow root AGENTS.md. This is an independent Go module; Sendico is an external +provider, not a local module dependency. Never add sibling-path replacements, +shared database access or internal Sendico discovery/messaging dependencies. + +Keep HTTP DTOs/mappers, application models/use cases and provider adapters distinct. +Resolve ownership from the authenticated SXCHNGE principal and server-side mappings. +Only adapters may expose provider transport types internally. Adapted code belongs +to SXCHNGE; record source revision and changes in PROVENANCE.md. + +Review the complete backend contribution before delivery. Run `make test-backend` +and `make lint-backend` at the repository root. Add behavioral tests for changed +boundaries and failure paths. Errors returned publicly must be stable typed codes; +never expose raw provider errors, secrets, document contents or credentials. diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md new file mode 100644 index 0000000..6a7f17c --- /dev/null +++ b/backend/PROVENANCE.md @@ -0,0 +1,38 @@ +# Sendico reuse provenance + +Source repository: `S_I/sendico`; revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. +Inspected 2026-09-12. These are owned forks inside SXCHNGE, not runtime imports. +Source files below match the committed revision; local dependency edits were not used. +No generated Sendico SDK, shared database models, deployment configuration or credentials were copied. + +## `internal/browser/policy.go` + +- Source: [api/edge/shared/auth/browser/browser.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go) +- Source Git blob: `3af7b54aea277d91e0d8c3eba9ee84c155f4b624` +- Adaptation: Adapted cookie creation/clearing, origin validation and CSRF checks. Replaced Sendico names/errors/env path resolution with explicit SXCHNGE policy; checks do not depend on a client-controlled transport header. Host-only Secure/HttpOnly cookie, Lax same-site navigation, duplicate-cookie rejection and explicit expiry validation. +- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. + +## `internal/credential/token.go` + +- Source: [api/edge/shared/storage/verification/hash.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go) +- Source Git blob: `36fadcf404bfc77608b28cca1e669a4a62f31774` +- Adaptation: Adapted generateMagic/tokenHash only: 32-byte crypto/rand, URL-safe encoding, SHA-256 digest. Added constant-time matching. Excluded deterministic token generation, Sendico models and all numeric OTP hashing. +- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. + +## `internal/evidence/key.go` + +- Source: [api/edge/shared/storage/s3key/key.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go) +- Source Git blob: `e3cca16304d2c4cb9f256f510d5a1a30a21e22a6` +- Adaptation: Adapted relative object-key confinement. Replaced merrors with a local sentinel and rejected encoded sequences, backslashes and embedded control characters. Not a filesystem or customer-authorization utility. +- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. + +## Maintenance + +The command, operational HTTP shell, module configuration and tests are new SXCHNGE code. +There is no automatic synchronization or promise of source compatibility with Sendico. +For an upstream fix, review the pinned source difference, port only the relevant behavior, +record a new provenance entry and run backend tests/vet. Provider contract changes belong +in the future Sendico adapter and its transport fixtures, not application models or widgets. + +These primitives do not create sessions, deliver email, validate TOTP or upload documents. +Use them inside the durable flows described in the [BFF plan](../docs/architecture/bff.md). diff --git a/backend/README.md b/backend/README.md index 2ea8c76..5f44a9c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,14 +1,38 @@ -# SXCHNGE backend +# SXCHNGE BFF -Reserved for a small backend for frontend (BFF) using a supported Sendico API. No service, runtime, or dependencies have been initialized yet. +Independent Go module with an operational HTTP shell and selected, adapted +Sendico foundations. Sendico remains an external financial-services provider; +there are no Sendico module imports, shared databases or sibling-path dependencies. +See the [architecture and service plan](../docs/architecture/bff.md) and +[reuse provenance](PROVENANCE.md). -Planned responsibilities: +## Run and validate -- Consumer authentication and sessions, with a stable mapping to organization-scoped Sendico customer references. -- Consumer-authorized access to onboarding, quotes, orders, and transaction history. -- Product-specific orchestration and presentation of Sendico KYC, eligibility, and remaining-limit responses. -- Server-side integration credentials and request correlation with Sendico. +Requires Go 1.26 or later. From the repository root: -Sendico owns payment execution, authoritative KYC evidence and verification records, policy evaluation, and limit reservations/enforcement. Do not copy its existing BFF or import local Sendico implementation modules. +```sh +make test-backend +make lint-backend +make run-backend +``` -Before implementation, agree on customer registration and identity mapping, customer KYC operations, eligibility/limits reads, customer-scoped payment access, and retry/idempotency behavior. Validate consumer isolation even when multiple customers use the same sxchnge organization and integration principal. +The default address is `127.0.0.1:8082`; override with `SXCHNGE_HTTP_ADDR`. +`GET /healthz` reports process health. `GET /readyz` deliberately returns **503** +with `not_configured`: persistence, authentication and Sendico are not connected. +No product routes return dummy success. The process handles SIGINT/SIGTERM with +bounded graceful shutdown. Production transport must terminate HTTPS. + +## Present components + +- `cmd/bff`, `internal/httpapi`: HTTP process, timeouts and operational endpoints. +- `internal/browser`: explicit HTTPS origin/CSRF policy and Secure HttpOnly + host-only session cookie. Apply this policy to every future browser mutation; + it is not yet connected to product routes because those do not exist. +- `internal/credential`: random opaque credentials and storage digests. Never use + this helper for passwords or short OTP codes. +- `internal/evidence`: confined server-generated object keys; not a substitute + for ownership, upload validation or private storage controls. + +Only the standard library is required. Larger adapted flows, persistence, +provider DTOs/mappers, email and live integration follow the plan in separate +contributions. Source-derived components evolve independently inside this repo. diff --git a/backend/cmd/bff/main.go b/backend/cmd/bff/main.go new file mode 100644 index 0000000..da44535 --- /dev/null +++ b/backend/cmd/bff/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.sendico.io/tech/sxchnge/bff/internal/httpapi" +) + +func main() { + if err := run(); err != nil { + slog.Error("BFF stopped", "error", err) + os.Exit(1) + } +} + +func run() error { + addr := os.Getenv("SXCHNGE_HTTP_ADDR") + if addr == "" { + addr = "127.0.0.1:8082" + } + server := &http.Server{Addr: addr, Handler: httpapi.New(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + failures := make(chan error, 1) + go func() { failures <- server.ListenAndServe() }() + slog.Info("BFF operational shell starting", "address", addr, "provider_configured", false) + select { + case err := <-failures: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + slog.Info("BFF shutting down") + return server.Shutdown(shutdown) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..e43372c --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module git.sendico.io/tech/sxchnge/bff + +go 1.26 diff --git a/backend/internal/browser/policy.go b/backend/internal/browser/policy.go new file mode 100644 index 0000000..2e029a9 --- /dev/null +++ b/backend/internal/browser/policy.go @@ -0,0 +1,118 @@ +// Adapted from Sendico edge/shared/auth/browser; see backend/PROVENANCE.md. +package browser + +import ( + "errors" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + SessionCookie = "__Host-sxchnge_session" + CSRFHeader = "X-CSRF-Protection" + CSRFValue = "1" +) + +var ( + ErrOrigin = errors.New("browser origin is not allowed") + ErrCSRF = errors.New("browser CSRF protection is required") + ErrSession = errors.New("browser session is missing or invalid") +) + +type Policy struct{ origins map[string]struct{} } + +// New accepts explicit HTTPS origins. Browser session transport is chosen by the +// server route; a client-controlled transport header cannot turn protection off. +func New(allowedOrigins []string) (*Policy, error) { + p := &Policy{origins: make(map[string]struct{}, len(allowedOrigins))} + for _, value := range allowedOrigins { + origin, ok := canonicalOrigin(value) + if !ok { + return nil, ErrOrigin + } + p.origins[origin] = struct{}{} + } + if len(p.origins) == 0 { + return nil, ErrOrigin + } + return p, nil +} + +func canonicalOrigin(value string) (string, bool) { + u, err := url.Parse(value) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" { + return "", false + } + return "https://" + strings.ToLower(u.Host), true +} + +// Validate must guard every browser-session mutation, including login/logout. +// It assumes CORS never grants credentialed access to untrusted origins. +func (p *Policy) Validate(r *http.Request) error { + if r == nil { + return ErrCSRF + } + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return nil + } + if r.Header.Get(CSRFHeader) != CSRFValue { + return ErrCSRF + } + origin, ok := canonicalOrigin(r.Header.Get("Origin")) + if !ok || p == nil { + return ErrOrigin + } + if _, allowed := p.origins[origin]; !allowed { + return ErrOrigin + } + return nil +} + +func Credential(r *http.Request) (string, error) { + if r == nil { + return "", ErrSession + } + var value string + for _, c := range r.Cookies() { + if c.Name != SessionCookie { + continue + } + if value != "" || c.Value == "" { + return "", ErrSession + } + value = c.Value + } + if value == "" { + return "", ErrSession + } + return value, nil +} + +func SetSession(w http.ResponseWriter, token string, now, expires time.Time) error { + age := int(expires.Sub(now).Seconds()) + if token == "" || age < 1 { + return ErrSession + } + c := cookie(token) + c.Expires = expires.UTC() + c.MaxAge = age + if c.Valid() != nil { + return ErrSession + } + http.SetCookie(w, c) + return nil +} + +func ClearSession(w http.ResponseWriter) { + c := cookie("") + c.Expires = time.Unix(1, 0).UTC() + c.MaxAge = -1 + http.SetCookie(w, c) +} + +func cookie(value string) *http.Cookie { + return &http.Cookie{Name: SessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode} +} diff --git a/backend/internal/browser/policy_test.go b/backend/internal/browser/policy_test.go new file mode 100644 index 0000000..ba01a3c --- /dev/null +++ b/backend/internal/browser/policy_test.go @@ -0,0 +1,79 @@ +package browser + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestMutationRequiresOriginAndHeader(t *testing.T) { + p, err := New([]string{"https://app.example.com"}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + origin, header, transport string + valid bool + }{ + {"https://app.example.com", "1", "", true}, + {"https://APP.example.com", "1", "", true}, + {"https://evil.example.com", "1", "", false}, + {"https://app.example.com.evil.test", "1", "", false}, + {"https://app.example.com/path", "1", "", false}, + {"https://app.example.com?query", "1", "", false}, + {"https://app.example.com#fragment", "1", "", false}, + {"https://app.example.com", "", "bearer", false}, + {"", "1", "bearer", false}, + {"null", "1", "cookie", false}, + } { + r := httptest.NewRequest(http.MethodPost, "https://app.example.com/api/session", nil) + r.Header.Set("Origin", tc.origin) + r.Header.Set(CSRFHeader, tc.header) + r.Header.Set("X-Auth-Transport", tc.transport) + if got := p.Validate(r); (got == nil) != tc.valid { + t.Errorf("%+v: %v", tc, got) + } + } + if p.Validate(httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)) != nil { + t.Fatal("read rejected") + } + for _, origin := range []string{"*", "http://app.example.com", "https://user@app.example.com", "https://app.example.com/"} { + if _, err := New([]string{origin}); err == nil { + t.Errorf("accepted configuration %q", origin) + } + } + if _, err := New(nil); err == nil { + t.Fatal("empty origin policy accepted") + } +} + +func TestSessionCookie(t *testing.T) { + now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC) + w := httptest.NewRecorder() + if err := SetSession(w, "opaque-token", now, now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + c := w.Result().Cookies()[0] + if c.Name != SessionCookie || !c.Secure || !c.HttpOnly || c.Path != "/" || c.Domain != "" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 3600 { + t.Fatalf("bad cookie: %+v", c) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil) + r.AddCookie(c) + if value, err := Credential(r); err != nil || value != "opaque-token" { + t.Fatal("cookie not read") + } + r.AddCookie(c) + if _, err := Credential(r); err == nil { + t.Fatal("ambiguous credential accepted") + } + if err := SetSession(httptest.NewRecorder(), "token", now, now); err == nil { + t.Fatal("expired cookie accepted") + } + w = httptest.NewRecorder() + ClearSession(w) + c = w.Result().Cookies()[0] + if c.MaxAge != -1 || c.Value != "" || c.Path != "/" || !c.Secure { + t.Fatal("cookie not cleared safely") + } +} diff --git a/backend/internal/credential/token.go b/backend/internal/credential/token.go new file mode 100644 index 0000000..e61c2ab --- /dev/null +++ b/backend/internal/credential/token.go @@ -0,0 +1,29 @@ +// Adapted from Sendico verification/hash.go; see backend/PROVENANCE.md. +package credential + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" +) + +// New returns an opaque credential and its storage digest. Store only the digest. +// This is for high-entropy tokens, never passwords or short numeric OTP codes. +func New() (raw, digest string, err error) { + bytes := make([]byte, 32) + if _, err = rand.Read(bytes); err != nil { + return "", "", err + } + raw = base64.RawURLEncoding.EncodeToString(bytes) + return raw, Digest(raw), nil +} + +func Digest(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func Matches(raw, digest string) bool { + return raw != "" && subtle.ConstantTimeCompare([]byte(Digest(raw)), []byte(digest)) == 1 +} diff --git a/backend/internal/credential/token_test.go b/backend/internal/credential/token_test.go new file mode 100644 index 0000000..1c5ef23 --- /dev/null +++ b/backend/internal/credential/token_test.go @@ -0,0 +1,24 @@ +package credential + +import ( + "encoding/base64" + "testing" +) + +func TestOpaqueCredential(t *testing.T) { + raw, digest, err := New() + if err != nil { + t.Fatal(err) + } + decoded, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || len(decoded) != 32 { + t.Fatal("credential must contain 256 random bits") + } + if raw == digest || !Matches(raw, digest) || Matches("other", digest) || Matches("", Digest("")) { + t.Fatal("invalid digest matching behavior") + } + other, _, err := New() + if err != nil || other == raw { + t.Fatal("credentials must be independently generated") + } +} diff --git a/backend/internal/evidence/key.go b/backend/internal/evidence/key.go new file mode 100644 index 0000000..287e669 --- /dev/null +++ b/backend/internal/evidence/key.go @@ -0,0 +1,43 @@ +// Adapted from Sendico edge/shared/storage/s3key; see backend/PROVENANCE.md. +package evidence + +import ( + "errors" + "path" + "strings" + "unicode" +) + +var ErrInvalidKey = errors.New("invalid evidence key") + +// JoinKey confines a server-generated object reference to a relative prefix. +// It is not an ownership check or a local-filesystem path sanitizer. Inputs must +// not be URL-decoded or interpreted as filesystem paths after validation. +func JoinKey(prefix, reference string) (string, error) { + a, err := relative(prefix) + if err != nil { + return "", err + } + b, err := relative(reference) + if err != nil { + return "", err + } + return path.Join(a, b), nil +} + +func relative(value string) (string, error) { + value = strings.TrimSpace(value) + if strings.ContainsAny(value, "\\%") || strings.ContainsFunc(value, unicode.IsControl) { + return "", ErrInvalidKey + } + for _, segment := range strings.Split(value, "/") { + if segment == ".." { + return "", ErrInvalidKey + } + } + clean := path.Clean(value) + if value == "" || clean == "." || path.IsAbs(clean) { + return "", ErrInvalidKey + } + return clean, nil +} diff --git a/backend/internal/evidence/key_test.go b/backend/internal/evidence/key_test.go new file mode 100644 index 0000000..d9a7c85 --- /dev/null +++ b/backend/internal/evidence/key_test.go @@ -0,0 +1,18 @@ +package evidence + +import "testing" + +func TestKeyConfinement(t *testing.T) { + for _, ref := range []string{"", ".", "..", "../other", "/other", "a/../../other", "a\\..\\other", "%2e%2e/other", "bad\x00key"} { + if _, err := JoinKey("customer/documents", ref); err == nil { + t.Errorf("accepted %q", ref) + } + if _, err := JoinKey(ref, "document"); err == nil { + t.Errorf("accepted prefix %q", ref) + } + } + got, err := JoinKey("customer/documents/", "passport/front") + if err != nil || got != "customer/documents/passport/front" { + t.Fatalf("got %q, %v", got, err) + } +} diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go new file mode 100644 index 0000000..50a5eba --- /dev/null +++ b/backend/internal/httpapi/router.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "encoding/json" + "net/http" +) + +// New is the operational shell. Product routes will be added only with their +// persistence, authentication and provider adapters, never simulated success. +func New() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + write(w, http.StatusOK, "ok") + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { + write(w, http.StatusServiceUnavailable, "not_configured") + }) + return mux +} + +func write(w http.ResponseWriter, status int, value string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(struct { + Status string `json:"status"` + }{value}) +} diff --git a/backend/internal/httpapi/router_test.go b/backend/internal/httpapi/router_test.go new file mode 100644 index 0000000..3bc4efe --- /dev/null +++ b/backend/internal/httpapi/router_test.go @@ -0,0 +1,17 @@ +package httpapi + +import ( + "net/http/httptest" + "testing" +) + +func TestOperationalShellDoesNotClaimReadiness(t *testing.T) { + handler := New() + for path, want := range map[string]int{"/healthz": 200, "/readyz": 503, "/api/v1/session": 404} { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != want { + t.Errorf("%s: %d, want %d", path, w.Code, want) + } + } +} diff --git a/docs/architecture/bff.md b/docs/architecture/bff.md new file mode 100644 index 0000000..989402a --- /dev/null +++ b/docs/architecture/bff.md @@ -0,0 +1,300 @@ +# Independent SXCHNGE BFF — task #34 + +## Decision and delivery scope + +Build a small, independently deployed Go BFF in `backend/`. Adapt selected Sendico +code into SXCHNGE ownership; treat Sendico as an external financial provider through +a versioned partner contract. Use one BFF process initially, with a worker entrypoint +from the same module when durable asynchronous work is introduced. Do not reproduce +Sendico's service topology inside SXCHNGE. + +This contribution delivers the architecture/reuse audit and a tested operational +starter. It does **not** implement account persistence, real authentication, email, +KYC uploads, money operations or a live Sendico adapter. The starter has health +and explicitly unconfigured readiness endpoints. Selected browser-session, +opaque-credential and evidence-key components are adapted now; the larger flows +below are planned extractions, not claims of completed integration. + +Audit basis: local `S_I/sendico` checkout, branch `compliance-ingestor-2167`, commit +`8bb20dcd622dd8eb92d289b6b34411e7eccab083`, inspected 2026-09-12. The checkout has +unrelated dependency-manifest edits, which were not copied or modified. Findings +establish source capabilities at that revision, not deployment availability, +external API support, or production readiness. See [source references](#source-references) +and [reuse provenance](../../backend/PROVENANCE.md). + +## Ownership and separation + +```mermaid +flowchart LR + UI[Flutter web / mobile] --> BFF[SXCHNGE BFF\nconsumer API and authorization] + BFF --> DB[(SXCHNGE database\nsessions, mappings, commands)] + BFF --> MAIL[Email delivery adapter] + BFF --> ADAPTER[SXCHNGE Sendico adapter\nDTOs and mapping] + ADAPTER --> EDGE[Sendico partner API\nauthenticated customer scope] + EDGE --> FIN[Sendico ledger, quotes, payments\nlimits, compliance, inbound] + EDGE --> EVENTS[Signed callback delivery] + EVENTS --> BFF +``` + +| Owner | Responsibilities | +| --- | --- | +| SXCHNGE | Consumer accounts/passwords, email confirmation, sessions, language, MFA policy/enrollment, local customer mapping, user authorization, operation intents, idempotency/outbox/inbox, product DTOs and safe status explanations | +| Sendico | Canonical financial accounts and posted money, spendability rules, currency/rail catalog, quotes/rates/fees, execution/settlement, authoritative limits and reservations, KYC evidence/results, compliance decisions and review workflows | +| Provider boundary | Authenticated partner principal, organization and customer isolation, stable identifiers, versioned transport, error/status semantics, replay guarantees, signed events and recovery reads | + +Sendico integration credentials stay exclusively in the BFF's secret configuration. +SXCHNGE users do not receive Sendico credentials, internal gRPC addresses, database +access, or membership/admin privileges on its integration organization. The BFF +never repairs balances by posting arbitrary ledger entries and never independently +reserves limits already owned by Sendico's payment execution. + +### Three identities must stay distinct + +Persist a server-owned mapping from `sxchnge_user_id` to a Sendico provider scope: +`organization_ref`, `customer_ref`, `compliance_subject_ref`, and, where Sendico's +ownership contracts require it, `ledger_owner_account_ref`. These fields are not +assumed equal. The partner integration principal is a fifth, separate identity. +Use unique indexes for local user/provider and provider organization/customer. +Provisioning is idempotent using a stable external customer reference. + +Do not accept organization, customer, initiator or owner refs from browser payloads. +Resolve local account/quote/operation IDs under the authenticated user first; send +only mapped refs. Sendico must enforce the same scope independently. List filtering +is mandatory before results are returned; direct reads must return the same safe +not-found outcome for missing and inaccessible objects. + +**Blocking contract gap:** the inspected protocols have customer-related fields, +but no customer provisioning RPC was found. Current ledger/payment ownership is +expressed with Sendico account refs; current personal-compliance HTTP routes resolve +organizations owned by the logged-in account. Neither is automatically a delegated +customer API for many SXCHNGE users under one organization. Establish a supported +mapping/provisioning facade on Sendico before connecting customer money or KYC. +Do not create a new Sendico organization per consumer to work around this. + +## Reuse decisions + +| Sendico starting point | Decision | SXCHNGE adaptation | +| --- | --- | --- | +| `edge/shared/auth/browser/browser.go` | Adapted now | Own `browser` package; fixed `__Host-sxchnge_session`, explicit HTTPS origins, server-selected cookie routes, no client-header CSRF bypass, same-site Lax cookie for return navigation; no Sendico error/config imports | +| `edge/shared/storage/verification/hash.go` | Selected functions adapted now | 32-byte random opaque credentials and SHA-256 storage digests; constant-time matching; no deterministic magic links or short-OTP hashing copied | +| `edge/shared/storage/s3key/key.go` | Adapted now | Own evidence object-key confinement and errors, additional encoded/backslash/control-character rejection; not an ownership check or local-filesystem sanitizer | +| `edge/shared/storage/verification/{create,consume,consume_update,db}.go` | Adapt in authentication phase | Account + purpose + operation binding, atomic consumption, expiry checked during reads, idempotent issuance, cooldown/attempt indexes. Replace model/repository/logging/notification dependencies with SXCHNGE contracts | +| `edge/shared/storage/refreshtokens/{filters,crud,revoke}.go` | Adapt model and persistence patterns in session phase | Session families/versioning, account binding, rotation and revocation; own database and credential format. Do not copy account-bound API-client login wholesale | +| `edge/shared/.../router/public/{login,refresh,logout}.go` | Adapt flow and tests | Pending login before session issuance; uniform credential failures; confirmed logout and refresh recovery. Preserve password input verbatim, unlike the inspected decoder's trim behavior | +| `edge/product/internal/server/paymentapiimp/{personal_source,visibility,quote,payquote}.go` | Adapt orchestration/authorization rules | Mandatory customer ownership even when partner has organization-wide rights; request-scoped deadlines, quote-bound execution, safe not-found projection. No BSON/permissions/discovery types in SXCHNGE models | +| `edge/product/internal/compliance/{read_service,upload_service,submit_service}.go` | Adapt orchestration pattern | Separate status/limits slices, bounded evidence ingestion, ownership, submission idempotency. Replace “account owns organization” assumption with provider customer mapping | +| `edge/callbacks/internal/signing/service.go` | Implement matching receiver after contract confirmation | Use approved wire algorithm and exact raw-body verification, bounded clock skew, event-ID inbox deduplication, reconciliation reads. Do not copy Vault-backed outbound sender into receiver | +| Ledger, limits, FX, AML/policy, gateways, treasury, KYC engines | External services | Call the partner facade; no cloned financial/compliance state machines | +| Sendico `pkg`, generic Mongo repositories, NATS/discovery bootstrapping, BFF router/DI graph | Do not import wholesale | Small local packages and explicit construction; no sibling Go replacements, shared database, submodule, automatic upstream sync or Sendico runtime dependencies | + +Reuse includes meaningful tests and behavioral invariants, not just filenames. +Each extraction records its source commit and intentional deviations. Future +Sendico fixes are assessed and ported selectively through SXCHNGE PRs. Contract +fixtures/version checks protect the adapter as the products diverge. + +## BFF structure + +Use a single Go module and explicit constructor injection. The following is the +**target layout**; only the shell and three selected foundations exist today. +Create additional directories when their first use case is implemented. + +```text +backend/ + cmd/bff/ HTTP process and graceful shutdown + cmd/worker/ future outbox / provider reconciliation worker + internal/httpapi/ routes, HTTP DTOs, mappers, stable errors + internal/identity/ users, registration, email confirmation + internal/session/ authenticated principal, restore/rotate/revoke + internal/verification/ challenges, factors, operation approvals + internal/profile/ account preferences + internal/wallet/ accounts and spendable-balance projection + internal/operations/ quotes, commands, status, history + internal/compliance/ KYC and visible limits use cases + internal/provider/ narrow customer/financial provider ports + internal/sendico/ external DTOs, HTTP client, mappers, contract tests + internal/notification/ email delivery port and adapter + internal/storage/ SXCHNGE repositories and migrations/indexes + internal/jobs/ durable outbox, event inbox, reconciliation + internal/browser/ browser cookie and CSRF policy (exists) + internal/credential/ opaque credentials and digests (exists) + internal/evidence/ object key confinement (exists) +``` + +A handler decodes an HTTP DTO, validates it, maps to a use-case input, and supplies +the authenticated principal. Use cases own application models and repository/provider +ports. The Sendico adapter alone converts its external DTOs into these models. +The Flutter client retains DTO → mapper → service → model → provider → UI; +provider DTOs do not leak through either boundary. Avoid one interface per function +or an all-purpose “Sendico service” spanning unrelated capabilities. + +Proposed persistence: a separate SXCHNGE MongoDB database/credentials, using native +driver repositories and replica-set transactions for critical local transitions. +This preserves useful Sendico persistence experience without importing its generic +repository module. Collections: users, sessions, challenges/factors, provider +mappings, quotes, operation commands, outbox and callback inbox. Unique indexes +scope email, token digest, issuance/idempotency keys and provider events. TTL indexes +clean up expired data; application checks enforce expiry immediately. Financial +balances and KYC decisions are not authoritative local records. + +Start with the existing deployment's secret facility where available. Do not make +NATS, Redis, Sendico discovery or direct Vault imports prerequisites for the BFF. +Email has an independent adapter and durable delivery jobs; adopting an existing +mail provider does not require adopting Sendico notification/event envelopes. + +## Required Sendico services and partner capabilities + +The BFF should call a versioned partner HTTP API. The names below are **existing +internal services**, useful to implement that facade; they are not instructions to +expose unauthenticated gRPC or for the browser to call them. If a supported partner +API is already deployed, validate it against this checklist instead of creating a +second one. No deployed endpoint has been verified in this task. + +| Capability needed by SXCHNGE | Existing source capability | Required partner behavior / gap | +| --- | --- | --- | +| Partner authentication and customer provisioning | Product account/API-client login and organization provisioning; customer fields in payment/KYC contracts | Confirm server-to-server grant/rotation/scopes. Existing `/login/api` is password + client-secret based, not evidence of OAuth client credentials. Add/confirm idempotent customer registration and canonical owner/subject mapping | +| Customer currency accounts | `LedgerService.ListAccounts`, `GetBalance`; treasury currency catalog | Enforce mapped customer scope; provision accounts through approved workflow. Return server-defined **available to spend**, currency precision, timestamp/version. `BalanceResponse.balance` alone does not define holds, pending debits or spendability | +| Rates, fees and quotes | `QuotationService.QuotePayment`, `QuotePayments`, `ListFXDirections` | Bind initiator and required source owner; return immutable fee/debit/credit/expiry quote snapshot, supported directions and exact money representation. An advisory quote is not execution authorization | +| Exchange and payout | `PaymentOrchestratorService.ExecutePayment`, `GetPayment`, `ListPayments` | Mandatory mapped organization/initiator/source-owner constraints; execute by accepted quote ref; scoped idempotency conflict/replay and lookup by client operation ref. Confirm FX ledger-to-ledger and payout rail availability | +| Top-up | `inbound.routes.RouteService`, payment methods and gateway capabilities; `inbound.crypto_deposits` for crypto | Return customer-bound funding instructions or hosted payment action. Confirm bank/card rails and incoming attribution. A top-up cannot immediately credit funds merely because the browser submitted a form | +| Limits and upgrade benefits | `LimitsService.ListVisibleLimits`, `CheckLimit`; reservation lifecycle used by payments | Customer-visible policy including used, remaining, period/reset and verified-tier projection. Policy chooses tier from authoritative KYC. Payments owns execution reservation/commit/release; BFF never double-reserves | +| KYC evidence and status | `KYCService.SubmitVerification`, `GetVerification`, `Reverify`, `ListVerifications`; existing edge compliance upload/submit | Delegated customer access, passport/selfie evidence roles, retention/access rules and immutable evidence IDs. Confirm manual review/correction/denial transitions and their public reason codes; public KYC proto alone does not expose a manual-review decision RPC | +| Backoffice review and eligibility | KYC plus `CaseManagementService`, Assessment and Policy | Confirm review routing and authoritative decision application; resolving a workflow case is not itself a KYC approval. Ensure KYC/tier changes feed payment eligibility, rather than assuming the separate KYC service already gates every payment | +| Status changes and reconciliation | Orchestrator reads, ledger statements, outbound `edge/callbacks` | Signed customer-scoped payment/KYC events, stable event IDs, at-least-once semantics, replay/ordering/version rules, retries and replayable status reads. Polling is a valid initial fallback; missing callbacks cannot leave funds permanently ambiguous | + +The current limits README says reservations are in process, but source now requires +`RuntimeRepository` and applies durable mutations with conflict/retry handling. +Use implementation and contract tests as evidence; confirm deployed durability and +restart/multi-instance behavior before money rollout rather than repeating the +README claim. + +## Client-facing contracts to implement + +Paths here are proposed **SXCHNGE API**, not existing Sendico endpoints. Keep them +versioned under `/api/v1`. HTTP bodies use explicit DTOs; product error responses +carry a stable code, request ID and, where useful, `retry_after` or field codes. +No raw upstream exceptions or internal compliance evidence are returned. + +| Client journey | Proposed BFF operations | Important contract | +| --- | --- | --- | +| Registration/email | `POST /registrations`, `/email-verifications`, `/email-verifications/confirm` | Idempotent registration/delivery; explicit unverified state; single-use expiring links; no session from merely creating an account | +| Login/session | `POST /sessions`, `POST /sessions/complete`, `GET /session`, `DELETE /session` | Credentials return pending challenge or confirmed session; only server can complete it. Restore, expiry, revocation, logout and frontend route guards | +| Recovery | `POST /password-resets`, `/password-resets/confirm` | Non-enumerating delivery, bounded retries and session revocation policy; do not reuse email-confirmation token purposes | +| 2FA | `GET /security`, `POST /security/challenges`, `POST /security/challenges/{id}/verify`, `/resend`, `PATCH /security` | User/session/purpose/operation-bound challenges and server-selected methods. Verified enable/disable; future TOTP setup is pending until a valid code confirms the secret | +| Preferences | `GET /profile`, `PATCH /profile` | Server-owned account language and validated mutable fields | +| Accounts/limits | `GET /accounts`, `/accounts/{id}`, `/limits` | Available balance only in primary UI; current usage and upgrade benefits from service; distinguish unavailable/stale from zero | +| Money | `POST /quotes`, `POST /operations`, `GET /operations`, `/operations/{id}`, `/operations/by-key/{key}` | Exact immutable quote, server confirmation proof, scoped idempotency and non-final acceptance. Funding instructions/hosted-action state where required | +| KYC | `GET /identity`, `POST /identity/uploads`, `POST /identity/submissions` | Bounded content validation and customer ownership; two evidence roles; idempotent submission; status, correction requests and denials | + +Define money on the wire as decimal-string minor units plus currency and scale; +map into checked application types. Never use floating-point amounts or unchecked +JavaScript-sized integers. Existing Flutter models use integer minor units; their +mappers must reject unsupported precision/overflow. Never invent balances, fees, +limits or rates on provider timeout. + +Current Flutter stubs need targeted extensions during integration: a session model/ +provider and HTTP transport; typed funding methods/recipients instead of the current +free-text recipient; `requiresAction`/funding instructions and settlement-aware funds +status. Keep the three broad operation states if useful visually, but represent +awaiting funding, review, execution, cancellation/reversal and returned funds in +models so “failed” cannot incorrectly promise that money has already returned. +The current `GuardedLoginService` is a local stand-in: replace it with the BFF's +pending-login contract, not a production client that mints its own pending session. +There is no public endpoint that simply trusts `authorize(purpose, operation)`; +the BFF consumes proof as part of the protected server-side action. + +## Critical transaction and identity flows + +### Authentication and verification + +Registration owns local credentials, confirms email, then idempotently provisions +or resumes the provider mapping. An incomplete provider mapping can yield an +explicit “account setup pending” state without losing the local registration. +No money access until mapping is confirmed; the optional unverified KYC state +still receives whatever basic policy Sendico returns. + +Adapt Sendico's pending-login and durable consumption patterns into SXCHNGE-owned +models. Browser routes use an HttpOnly Secure host-only opaque session cookie; +unsafe requests require an exact allowed Origin plus the CSRF header, independently +of client transport hints. SameSite Lax permits safe return navigation from email; +state-changing confirmation still requires POST validation. Production sits behind +HTTPS. Native mobile uses a separate server-selected token transport with secure +platform storage and rotation/revocation, not browser-origin bypass flags. + +Scope challenges by user, session/pending-login, purpose, operation ID and method. +Use independent purposes for login, email, password reset and setting changes. +Use durable attempt/cooldown state across instances; a resend must not reset the +attempt budget. High-entropy token digests can reuse the extracted helper; numeric +codes need a separately keyed verifier and strict attempt controls, never this +helper as a password/OTP hash. Future TOTP secrets require encryption, confirmed +enrollment, bounded clock drift/replay controls and recovery before availability. + +### Money execution without duplicate payment + +1. Resolve the authenticated customer and source/destination ownership server-side. +2. Obtain a Sendico quote carrying the mapped initiator and mandatory source-owner + constraint. Persist its local ID, user, provider ref, expiry and exact snapshot. +3. Bind the user's verification to that quote and action. A changed amount, + recipient, account or expired quote requires fresh review and authorization. +4. In one **local database transaction**, validate/consume approval and insert the + immutable operation command, scoped idempotency key, request digest and outbox + job. Reusing the key with another payload is a conflict. Replaying the same key + returns the same command before checking an already-consumed approval. +5. A worker submits the stored command using the same provider idempotency key. + Sendico rechecks ownership, quote validity, eligibility, spendability and limits. + A BFF approval does not override provider policy. +6. If a response is lost, reconcile by the provider/client operation reference; + retry only under Sendico's documented idempotency contract. Never create a new + key merely to escape uncertainty. A crash after approval consumption is recovered + by the persisted outbox, not by asking for and spending another authorization. +7. Persist observed status/version and correlate events; update balances/limits from + authoritative reads. Pending/manual review is not completion, and failed/reversed + status must describe whether funds actually returned. Callback duplicates and + out-of-order events must not regress terminal state; ambiguous order triggers a read. + +There is no cross-database atomic transaction with Sendico. Durable commands, +idempotent provider execution and reconciliation are the explicit bridge. This +behavior must be proven with an “accepted upstream, response lost” contract test. + +### KYC and operational observability + +Use customer-bound private evidence references; validate size/type/content and +read access before forwarding to the provider. Do not pass arbitrary public URLs, +local file paths or untrusted object keys. If uploads stage in SXCHNGE, lifecycle, +retention, orphan cleanup and delete responsibility must be explicit; prefer +provider-issued scoped upload intents when supported. Passport and selfie are +separate evidence roles in one idempotent submission. Backoffice decisions remain +in Sendico; project only safe actionable reasons and the resulting limits. + +Correlate requests, local commands and provider IDs on the server with structured +logs. Record provider latency/errors, outbox lag, reconciliation age, callback +retries and final outcomes. Exclude passwords, codes, cookies, documents, recipient +payloads and secrets. Existing dummy analytics remains a client sink; backend +settlement evidence supplies real outcomes, not browser “submitted” events. + +## Implementation sequence and acceptance gates + +| Phase | Deliverable | Exit evidence | +| --- | --- | --- | +| 0 — this task | Audit, reuse choices, independent shell and primitives | Unit/race tests, Go formatting/vet, no Sendico imports/replacements, source provenance | +| 1 — provider contract agreement | Customer provisioning/mapping, scoped access, spendability, quote/payment replay, KYC review and callback contract | Sandbox fixtures/API schema and two-customer isolation tests; determine whether existing partner edge can be extended | +| 2 — own authentication | Mongo repositories, email outbox, signup/email/login/session/2FA/recovery plus Flutter adapters/guards | Restart-safe consumption and delivery, session isolation/revocation, no session before required factor, web/mobile transport tests | +| 3 — read-only provider integration | Customer mapping, currency accounts, profile, limits, KYC status | Available-money semantics, no cross-customer list/direct reads, safe unknown/error handling, contract version validation | +| 4 — first money slice | One supported top-up rail followed by exchange and payout, recipient/funding UI extensions | Provider-confirmed credit/debit, fees and expiry; idempotent concurrency and lost-response recovery; funds explanations match actual statuses | +| 5 — evidence/review integration | Passport/selfie uploads, backoffice review, correction/deny, policy propagation | Customer isolation, evidence lifecycle, review-driven limits change and execution policy enforcement | +| 6 — rollout | Deployment, migrations, secret rotation, health/readiness, reconciliation operations and event delivery | Sandbox end-to-end runs, restart/recovery drills, ownership tests on every public resource and explicit supported-rail configuration | + +Authentication work can proceed while the provider contract is agreed. Do not block +it on cloning financial services. Conversely, no live money rollout until the scope, +spendability, idempotency and settlement contracts are verified. Keep each phase in +its own task/PR; Sendico-side facade work is a separate provider contribution. + +## Source references + +All paths below are in the audited Sendico revision; links pin that revision. + +- [Browser transport](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go), [verification hashing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go), [evidence keys](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go). +- [Pending/API login](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/internal/transport/http/router/public/login.go), [session binding](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/refreshtokens/filters.go), [verification indexes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/db.go). +- [Ledger contract](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/ledger/v1/ledger.proto), [payment visibility](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/visibility.go), [personal source constraints](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/personal_source.go). +- [Quotation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/quotation/v2/quotation.proto), [orchestration](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/orchestration/v2/orchestration.proto), [inbound routes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/inbound/routes/v1/routes.proto). +- [Limits projection](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/limits/v1/limits.proto), [durable mutation implementation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/reservation_mutation_persistence.go), [required runtime storage](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/service.go). +- [Personal-compliance ownership](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/accountapiimp/compliance.go), [KYC](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/compliance/kyc/v1/kyc.proto), [case workflow](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/workflow/case/v1/case.proto), [callback signing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/callbacks/internal/signing/service.go). diff --git a/docs/implementation.md b/docs/implementation.md index 8e0b6b8..85d57f4 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -362,3 +362,36 @@ See [the UX and measurement contract](ux/two-factor.md) and Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`; committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend label, tech owner and SXCHNGE project. No stacked dependencies. + +## Independent BFF plan and Sendico reuse — task #34 + +Audited `S_I/sendico` at `8bb20dcd622dd8eb92d289b6b34411e7eccab083` without +modifying its unrelated working-tree dependency edits. The +[BFF plan](architecture/bff.md) records source-backed reuse choices, independent +ownership, target structure, proposed Mongo persistence, client/API mapping, +required provider services and phased acceptance criteria. Source availability +is distinguished from supported/deployed external APIs. + +The main provider gaps are delegated customer provisioning/owner mapping, a +spendable-balance projection, customer-scoped compliance access/manual review, +and verified payment/callback replay contracts. Existing quotation/orchestration +owner constraints and durable limits mutation code can underpin the provider +facade. Sendico's personal-compliance ownership assumptions and stale limits +README are called out explicitly. Sendico remains authoritative for financial +execution, limits and compliance; the BFF does not clone those engines. + +Created a separate Go module and operational shell under `backend/`, plus adapted +browser cookie/CSRF policy, opaque credential generation/digests and evidence-key +confinement. [Provenance](../backend/PROVENANCE.md) pins source blobs and deviations. +No Sendico imports, sibling replacements, external Go modules, shared database +access or live provider calls. The starter exposes health and deliberately 503 +readiness; authentication, persistence and financial product routes remain planned. +Root/backend README and backend guidelines describe the current scope. + +Validation: `make test-backend` (race-enabled tests across four packages), +`make lint-backend` (gofmt/vet), `go build` and `git diff --check` passed. +CLI smoke verified health 200, readiness 503, unimplemented session 404 and SIGTERM +exit 0. Module/dependency inspection confirms standard-library-only dependencies. +Pinned source paths, source blobs and local documentation links were checked. +Reviewed all new backend files and the task diff. No Flutter code changed; +frontend tests and security scans were not run for this backend starter/plan task. -- 2.54.0 From 67dc5b5b1ebf318be25405d71282ee2f76b01e6a Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 13:11:57 +0200 Subject: [PATCH 02/11] Record BFF plan delivery in PR #35 --- docs/implementation.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/implementation.md b/docs/implementation.md index 85d57f4..d43e05f 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -395,3 +395,8 @@ exit 0. Module/dependency inspection confirms standard-library-only dependencies Pinned source paths, source blobs and local documentation links were checked. Reviewed all new backend files and the task diff. No Flutter code changed; frontend tests and security scans were not run for this backend starter/plan task. + +Delivery: [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), `bff-34` into +`main`, committed/pushed and unmerged. Issue #34 and PR metadata verified: backend +label, tech owner, SXCHNGE project. Larger authentication/provider work remains +separate from this completed audit and starter scope. -- 2.54.0 From 7062672a87c0b63ed8859b5af678e5cb88bafc80 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 13:37:48 +0200 Subject: [PATCH 03/11] Bootstrap copied BFF and local accounts with public Sendico contracts (#34) --- .agents/skills/review-backend-change/SKILL.md | 59 + .gitignore | 4 + AGENTS.md | 4 +- Makefile | 5 + README.md | 26 +- backend/AGENTS.md | 104 +- backend/PROVENANCE.json | 3079 +++++++++++++++++ backend/PROVENANCE.md | 82 +- backend/README.md | 62 +- backend/cmd/bff/main.go | 47 +- backend/config.example.yml | 35 + backend/contracts/sendico/api.yaml | 129 + .../sendico/api/accounts/auth_login.yaml | 29 + .../sendico/api/accounts/auth_login_api.yaml | 30 + .../sendico/api/accounts/auth_refresh.yaml | 31 + .../sendico/api/accounts/auth_rotate.yaml | 31 + .../sendico/api/accounts/bodies/auth.yaml | 24 + .../sendico/api/accounts/request/auth.yaml | 34 + .../sendico/api/accounts/response/auth.yaml | 62 + .../sendico/api/callbacks/archive.yaml | 37 + .../api/callbacks/bodies/callback.yaml | 17 + .../sendico/api/callbacks/create.yaml | 33 + .../contracts/sendico/api/callbacks/list.yaml | 35 + .../sendico/api/callbacks/object.yaml | 64 + .../api/callbacks/request/callback.yaml | 113 + .../api/callbacks/response/callback.yaml | 22 + .../sendico/api/callbacks/rotate_secret.yaml | 31 + .../sendico/api/callbacks/update.yaml | 31 + .../sendico/api/ledger_accounts/balance.yaml | 58 + .../api/ledger_accounts/operation.yaml | 36 + .../api/ledger_accounts/operations.yaml | 36 + .../api/ledger_accounts/response/balance.yaml | 15 + .../ledger_accounts/response/operation.yaml | 33 + .../sendico/api/organizations/get.yaml | 29 + .../organizations/response/organization.yaml | 18 + .../sendico/api/organizations/root.yaml | 27 + .../sendico/api/parameters/accounts_ref.yaml | 9 + .../sendico/api/parameters/archived.yaml | 9 + .../sendico/api/parameters/callbacks_ref.yaml | 9 + .../sendico/api/parameters/cascade.yaml | 9 + .../sendico/api/parameters/cursor.yaml | 9 + .../sendico/api/parameters/entry_ref.yaml | 9 + .../sendico/api/parameters/filter_states.yaml | 11 + .../api/parameters/ledger_account_ref.yaml | 9 + .../sendico/api/parameters/limit.yaml | 11 + .../sendico/api/parameters/offset.yaml | 11 + .../sendico/api/parameters/org_ref.yaml | 9 + .../api/parameters/organizations_ref.yaml | 9 + .../api/parameters/payment_methods_ref.yaml | 9 + .../sendico/api/parameters/payments_ref.yaml | 10 + .../api/parameters/recipients_ref.yaml | 9 + .../sendico/api/parameters/state.yaml | 11 + .../sendico/api/parameters/states.yaml | 10 + .../sendico/api/payment_methods/accounts.yaml | 74 + .../sendico/api/payment_methods/archive.yaml | 36 + .../bodies/payment_method.yaml | 17 + .../sendico/api/payment_methods/object.yaml | 102 + .../api/payment_methods/recipients.yaml | 74 + .../request/payment_method.yaml | 42 + .../response/payment_method.yaml | 18 + .../sendico/api/payments/bodies/payment.yaml | 33 + .../sendico/api/payments/by_multiquote.yaml | 37 + .../sendico/api/payments/by_quote.yaml | 41 + .../api/payments/documents_certificate.yaml | 47 + .../sendico/api/payments/fx_currencies.yaml | 40 + .../sendico/api/payments/fx_directions.yaml | 48 + .../sendico/api/payments/immediate.yaml | 48 + .../contracts/sendico/api/payments/list.yaml | 76 + .../sendico/api/payments/multiquote.yaml | 37 + .../sendico/api/payments/object.yaml | 31 + .../contracts/sendico/api/payments/quote.yaml | 37 + .../sendico/api/payments/registry.yaml | 30 + .../api/payments/registry_refresh.yaml | 32 + .../sendico/api/payments/request/payment.yaml | 163 + .../api/payments/response/payment.yaml | 114 + .../sendico/api/recipients/archive.yaml | 37 + .../api/recipients/bodies/recipient.yaml | 17 + .../sendico/api/recipients/create.yaml | 33 + .../sendico/api/recipients/list.yaml | 36 + .../sendico/api/recipients/object.yaml | 64 + .../api/recipients/request/recipient.yaml | 53 + .../api/recipients/response/recipient.yaml | 18 + .../sendico/api/recipients/update.yaml | 31 + .../contracts/sendico/api/response/error.yaml | 24 + .../sendico/api/response/operation.yaml | 85 + .../sendico/api/response/response.yaml | 47 + .../sendico/api/response/status.yaml | 10 + .../api/verification/bodies/verification.yaml | 17 + .../sendico/api/verification/create.yaml | 37 + .../verification/request/verification.yaml | 73 + .../sendico/api/verification/resend.yaml | 37 + .../verification/response/verification.yaml | 35 + .../sendico/api/verification/verify.yaml | 37 + .../contracts/sendico/api/wallets/assets.yaml | 35 + .../sendico/api/wallets/response/wallet.yaml | 18 + .../sendico/external/chain_network.yaml | 10 + .../sendico/external/endpoint_type.yaml | 15 + .../contracts/sendico/external/fx_side.yaml | 7 + .../sendico/external/payment_kind.yaml | 8 + .../sendico/external/payment_method_type.yaml | 12 + .../sendico/external/payment_state.yaml | 11 + .../sendico/external/settlement_mode.yaml | 8 + .../sendico/models/account/account.yaml | 15 + .../models/account/account_public.yaml | 33 + .../models/auth/client_refresh_token.yaml | 15 + .../sendico/models/auth/login_data.yaml | 21 + .../models/auth/session_identifier.yaml | 16 + .../sendico/models/auth/token_data.yaml | 17 + .../sendico/models/callback/callback.yaml | 152 + .../sendico/models/common/describable.yaml | 16 + .../sendico/models/common/money.yaml | 18 + .../sendico/models/common/pagination.yaml | 10 + .../sendico/models/ledger/balance.yaml | 53 + .../sendico/models/ledger/operation.yaml | 124 + .../contracts/sendico/models/objectid.yaml | 9 + .../models/organization/organization.yaml | 39 + .../sendico/models/payment/fx_metadata.yaml | 78 + .../sendico/models/payment/payment.yaml | 879 +++++ .../models/payment_method/payment_method.yaml | 29 + .../sendico/models/permission_bound.yaml | 23 + .../sendico/models/recipient/recipient.yaml | 30 + .../contracts/sendico/models/storable.yaml | 22 + .../sendico/models/wallet/wallet_asset.yaml | 19 + .../internal/join_organization_test.go | 119 + .../accountservice/internal/service.go | 450 +++ .../internal/service_deletion_test.go | 156 + .../accountservice/internal/service_test.go | 244 ++ .../product/interface/accountservice/types.go | 104 + backend/edge/shared/appversion/version.go | 27 + backend/edge/shared/auth/browser/browser.go | 121 + .../edge/shared/auth/browser/browser_test.go | 122 + backend/edge/shared/auth/client/client.go | 29 + .../edge/shared/auth/client/client_test.go | 53 + .../shared/auth/sessionmetrics/metrics.go | 35 + backend/edge/shared/auth/storage/stores.go | 148 + .../edge/shared/auth/tokens/prepare_test.go | 102 + backend/edge/shared/auth/tokens/tokens.go | 77 + backend/edge/shared/http/ipguard/ipguard.go | 73 + .../edge/shared/http/ipguard/ipguard_test.go | 116 + backend/edge/shared/interface/api/api.go | 25 + .../edge/shared/interface/api/auth_config.go | 41 + backend/edge/shared/interface/api/config.go | 17 + backend/edge/shared/interface/api/register.go | 15 + backend/edge/shared/interface/api/runtime.go | 40 + .../interface/api/srequest/changepolicies.go | 12 + .../interface/api/srequest/changerole.go | 8 + .../shared/interface/api/srequest/login.go | 9 + .../shared/interface/api/srequest/refresh.go | 5 + .../shared/interface/api/srequest/rotate.go | 5 + .../interface/api/srequest/validateable.go | 5 + .../interface/api/srequest/verification.go | 15 + .../shared/interface/api/sresponse/account.go | 60 + .../interface/api/sresponse/authresp.go | 7 + .../shared/interface/api/sresponse/file.go | 16 + .../shared/interface/api/sresponse/login.go | 35 + .../interface/api/sresponse/login_pending.go | 29 + .../interface/api/sresponse/login_test.go | 46 + .../interface/api/sresponse/organization.go | 35 + .../interface/api/sresponse/permissions.go | 45 + .../interface/api/sresponse/response.go | 14 + .../shared/interface/api/sresponse/result.go | 27 + .../shared/interface/api/sresponse/token.go | 8 + .../interface/api/sresponse/verification.go | 8 + .../interface/api/verification_rate_limit.go | 39 + .../api/verification_rate_limit_test.go | 32 + .../edge/shared/interface/api/ws/config.go | 9 + .../edge/shared/interface/api/ws/handler.go | 12 + .../edge/shared/interface/api/ws/message.go | 9 + .../shared/interface/middleware/cors_test.go | 49 + .../interface/middleware/login_rate_limit.go | 47 + .../middleware/login_rate_limit_test.go | 60 + .../shared/interface/middleware/middleware.go | 116 + .../shared/interface/middleware/token_test.go | 52 + backend/edge/shared/interface/model/token.go | 140 + .../edge/shared/interface/model/token_test.go | 32 + .../edge/shared/interface/ratelimit/config.go | 38 + .../edge/shared/interface/storage/config.go | 25 + backend/edge/shared/internal/runtime/api.go | 74 + .../edge/shared/internal/runtime/bootstrap.go | 109 + .../edge/shared/internal/runtime/lifecycle.go | 31 + .../edge/shared/internal/runtime/services.go | 39 + .../shared/internal/server/config_test.go | 34 + backend/edge/shared/internal/server/server.go | 177 + .../shared/internal/server/server_test.go | 22 + .../internal/transport/http/middleware.go | 172 + .../transport/http/middleware_test.go | 155 + .../http/router/authorized/handler.go | 154 + .../http/router/authorized/router.go | 61 + .../http/router/authorized/session_test.go | 113 + .../transport/http/router/dispatcher.go | 66 + .../http/router/endpoint/endpoint.go | 36 + .../transport/http/router/endpoint/install.go | 55 + .../transport/http/router/endpoint/token.go | 30 + .../transport/http/router/metrics/handler.go | 40 + .../transport/http/router/metrics/router.go | 14 + .../transport/http/router/public/login.go | 249 ++ .../http/router/public/login_rate_limit.go | 179 + .../router/public/login_rate_limit_test.go | 316 ++ .../http/router/public/login_test.go | 268 ++ .../router/public/login_test_support_test.go | 288 ++ .../transport/http/router/public/logout.go | 55 + .../http/router/public/logout_test.go | 204 ++ .../transport/http/router/public/refresh.go | 53 + .../http/router/public/refresh_test.go | 137 + .../transport/http/router/public/respond.go | 48 + .../transport/http/router/public/rotate.go | 36 + .../transport/http/router/public/router.go | 69 + .../transport/http/router/public/validate.go | 98 + .../internal/transport/http/router/router.go | 16 + .../transport/http/websocket/dispimp.go | 68 + .../transport/http/websocket/router.go | 15 + backend/edge/shared/server/server.go | 12 + .../edge/shared/storage/mongo/dependencies.go | 50 + .../shared/storage/organization/archived.go | 32 + .../shared/storage/organization/cascade.go | 23 + .../shared/storage/organization/create.go | 19 + .../edge/shared/storage/organization/db.go | 113 + .../edge/shared/storage/organization/get.go | 12 + .../shared/storage/organization/index_test.go | 54 + .../edge/shared/storage/organization/list.go | 16 + .../shared/storage/organization/member.go | 49 + .../storage/organization/member_test.go | 153 + .../memory_repository_queries_test.go | 378 ++ .../organization/memory_repository_test.go | 149 + .../storage/organization/organization.go | 19 + .../edge/shared/storage/organization/owned.go | 14 + .../shared/storage/organization/provider.go | 14 + .../storage/organization/provisioning.go | 166 + .../storage/organization/provisioning_test.go | 180 + .../storage/organization/setarchived_test.go | 51 + .../storage/organization/test_helpers_test.go | 58 + backend/edge/shared/storage/ratelimits/db.go | 184 + .../shared/storage/refreshtokens/client.go | 12 + .../edge/shared/storage/refreshtokens/crud.go | 189 + .../shared/storage/refreshtokens/crud_test.go | 162 + .../edge/shared/storage/refreshtokens/db.go | 102 + .../shared/storage/refreshtokens/fields.go | 11 + .../shared/storage/refreshtokens/filters.go | 37 + .../storage/refreshtokens/refreshtokens.go | 19 + .../refreshtokens_indexes_test.go | 92 + .../refreshtokens/refreshtokens_misc_test.go | 253 ++ .../refreshtokens_session_replacement_test.go | 193 ++ .../refreshtokens_session_test.go | 148 + .../refreshtokens/refreshtokens_test.go | 231 ++ .../shared/storage/refreshtokens/revoke.go | 24 + backend/edge/shared/storage/s3key/key.go | 35 + backend/edge/shared/storage/s3key/key_test.go | 42 + .../shared/storage/verification/consume.go | 80 + .../storage/verification/consume_logging.go | 67 + .../storage/verification/consume_lookup.go | 183 + .../storage/verification/consume_scope.go | 112 + .../storage/verification/consume_update.go | 149 + .../storage/verification/context_ref.go | 22 + .../storage/verification/context_ref_test.go | 99 + .../shared/storage/verification/create.go | 248 ++ .../edge/shared/storage/verification/db.go | 88 + .../shared/storage/verification/errors.go | 69 + .../edge/shared/storage/verification/hash.go | 106 + .../shared/storage/verification/hash_test.go | 19 + .../storage/verification/idempotency.go | 84 + .../storage/verification/issuance_limit.go | 47 + .../verification/issuance_limit_test.go | 171 + .../memory_token_repository_test.go | 324 ++ .../shared/storage/verification/request.go | 102 + .../verification/token_query_matcher_test.go | 257 ++ .../storage/verification/verification.go | 15 + .../verification_attempts_test.go | 148 + .../verification_idempotency_test.go | 177 + .../storage/verification/verification_test.go | 289 ++ .../verification_test_helpers_test.go | 103 + backend/go.mod | 97 +- backend/go.sum | 295 ++ .../internal/architecture/boundary_test.go | 42 + .../internal/architecture/contracts_test.go | 60 + backend/internal/architecture/errors_test.go | 69 + backend/internal/browser/policy.go | 118 - backend/internal/browser/policy_test.go | 79 - backend/internal/credential/token.go | 29 - backend/internal/credential/token_test.go | 24 - backend/internal/evidence/key.go | 43 - backend/internal/evidence/key_test.go | 18 - backend/internal/httpapi/router.go | 28 - backend/internal/httpapi/router_test.go | 17 - backend/internal/profile/runtime.go | 22 + backend/pkg/api/http/methods.go | 36 + backend/pkg/api/http/response/response.go | 228 ++ .../pkg/api/http/response/response_test.go | 57 + backend/pkg/api/http/response/result.go | 19 + backend/pkg/api/http/status.go | 8 + backend/pkg/api/routers/health.go | 18 + backend/pkg/api/routers/health/status.go | 10 + .../api/routers/internal/healthimp/health.go | 76 + .../routers/internal/healthimp/health_test.go | 53 + .../api/routers/internal/healthimp/status.go | 50 + backend/pkg/auth/anyobject/anyobject.go | 3 + backend/pkg/auth/archivable.go | 35 + backend/pkg/auth/archivableimp.go | 107 + backend/pkg/auth/authorizer.go | 87 + backend/pkg/auth/authorizer_test.go | 147 + backend/pkg/auth/config.go | 12 + backend/pkg/auth/db.go | 38 + backend/pkg/auth/dbab.go | 51 + backend/pkg/auth/dbimp.go | 319 ++ backend/pkg/auth/dbimpab.go | 394 +++ backend/pkg/auth/dbimpab_test.go | 80 + backend/pkg/auth/dbimpscoped.go | 142 + backend/pkg/auth/dbimpscoped_test.go | 848 +++++ backend/pkg/auth/dbimpscopedcrud.go | 262 ++ backend/pkg/auth/dbimpscopedpatch.go | 129 + backend/pkg/auth/enforcer.go | 32 + backend/pkg/auth/factory.go | 58 + backend/pkg/auth/helper.go | 61 + backend/pkg/auth/indexable.go | 29 + backend/pkg/auth/indexableimp.go | 182 + .../pkg/auth/internal/casbin/config/config.go | 127 + backend/pkg/auth/internal/casbin/enforcer.go | 206 ++ backend/pkg/auth/internal/casbin/factory.go | 34 + backend/pkg/auth/internal/casbin/logger.go | 61 + backend/pkg/auth/internal/casbin/manager.go | 61 + .../internal/casbin/mongoadapter/adapter.go | 357 ++ .../casbin/mongoadapter/adapter_test.go | 68 + .../pkg/auth/internal/casbin/permissions.go | 167 + backend/pkg/auth/internal/casbin/role.go | 209 ++ .../casbin/serialization/internal/policy.go | 81 + .../casbin/serialization/internal/role.go | 57 + .../internal/casbin/serialization/policy.go | 12 + .../internal/casbin/serialization/role.go | 12 + .../casbin/serialization/serializer.go | 10 + .../pkg/auth/internal/native/db/policies.go | 151 + backend/pkg/auth/internal/native/db/roles.go | 99 + .../pkg/auth/internal/native/dbpolicies.go | 27 + backend/pkg/auth/internal/native/dbroles.go | 24 + backend/pkg/auth/internal/native/enforcer.go | 282 ++ .../pkg/auth/internal/native/enforcer_test.go | 746 ++++ backend/pkg/auth/internal/native/manager.go | 57 + .../internal/native/nstructures/policies.go | 17 + .../auth/internal/native/nstructures/role.go | 15 + .../auth/internal/native/object_scope_test.go | 175 + .../pkg/auth/internal/native/permission.go | 101 + backend/pkg/auth/internal/native/role.go | 142 + backend/pkg/auth/management/permission.go | 27 + backend/pkg/auth/management/role.go | 41 + backend/pkg/auth/manager.go | 15 + backend/pkg/auth/patchguard.go | 30 + backend/pkg/auth/provider.go | 14 + backend/pkg/auth/scoped.go | 71 + backend/pkg/auth/scoped_test.go | 130 + backend/pkg/auth/scopeddb.go | 129 + backend/pkg/auth/scopeddb_test.go | 256 ++ backend/pkg/db/account/account.go | 16 + backend/pkg/db/config.go | 103 + backend/pkg/db/connection.go | 59 + backend/pkg/db/factory.go | 53 + backend/pkg/db/internal/mongo/accountdb/db.go | 42 + .../pkg/db/internal/mongo/accountdb/user.go | 21 + backend/pkg/db/internal/mongo/db.go | 328 ++ .../db/internal/mongo/invitationdb/accept.go | 12 + .../internal/mongo/invitationdb/archived.go | 49 + .../db/internal/mongo/invitationdb/cascade.go | 24 + .../pkg/db/internal/mongo/invitationdb/db.go | 48 + .../mongo/invitationdb/db_integration_test.go | 108 + .../db/internal/mongo/invitationdb/db_test.go | 81 + .../db/internal/mongo/invitationdb/decline.go | 12 + .../internal/mongo/invitationdb/getpublic.go | 121 + .../db/internal/mongo/invitationdb/indexes.go | 30 + .../db/internal/mongo/invitationdb/list.go | 28 + .../mongo/invitationdb/updatestatus.go | 26 + backend/pkg/db/internal/mongo/mongo.go | 49 + .../pkg/db/internal/mongo/policiesdb/all.go | 20 + .../db/internal/mongo/policiesdb/builtin.go | 13 + .../pkg/db/internal/mongo/policiesdb/db.go | 21 + .../db/internal/mongo/policiesdb/db_test.go | 355 ++ .../db/internal/mongo/policiesdb/policies.go | 18 + .../repositoryimp/builderimp/accumulator.go | 90 + .../mongo/repositoryimp/builderimp/alias.go | 87 + .../mongo/repositoryimp/builderimp/array.go | 27 + .../repositoryimp/builderimp/expression.go | 108 + .../mongo/repositoryimp/builderimp/field.go | 71 + .../mongo/repositoryimp/builderimp/func.go | 137 + .../repositoryimp/builderimp/gaccumulator.go | 35 + .../mongo/repositoryimp/builderimp/patch.go | 104 + .../repositoryimp/builderimp/patch_test.go | 98 + .../repositoryimp/builderimp/pipeline.go | 133 + .../repositoryimp/builderimp/pipeline_test.go | 576 +++ .../repositoryimp/builderimp/projection.go | 97 + .../mongo/repositoryimp/builderimp/query.go | 162 + .../repositoryimp/builderimp/query_test.go | 31 + .../mongo/repositoryimp/builderimp/value.go | 17 + .../db/internal/mongo/repositoryimp/index.go | 143 + .../repositoryimp/index_integration_test.go | 95 + .../mongo/repositoryimp/index_test.go | 52 + .../mongo/repositoryimp/repository.go | 305 ++ .../repository_comprehensive_test.go | 576 +++ .../mongo/repositoryimp/repository_filter.go | 59 + .../repository_insertmany_test.go | 153 + .../repositoryimp/repository_patch_test.go | 288 ++ .../mongo/repositoryimp/repository_test.go | 188 + .../repositoryimp/repository_upsert_test.go | 99 + backend/pkg/db/internal/mongo/rolesdb/db.go | 21 + backend/pkg/db/internal/mongo/rolesdb/list.go | 15 + .../pkg/db/internal/mongo/rolesdb/roles.go | 15 + .../internal/mongo/transactionimp/factory.go | 18 + .../mongo/transactionimp/transaction.go | 30 + backend/pkg/db/invitation/invitation.go | 19 + backend/pkg/db/permissions_config_test.go | 129 + backend/pkg/db/policy/policy.go | 17 + backend/pkg/db/repository/abfilter.go | 78 + .../pkg/db/repository/builder/accumulator.go | 11 + backend/pkg/db/repository/builder/alias.go | 8 + backend/pkg/db/repository/builder/array.go | 7 + .../pkg/db/repository/builder/expression.go | 5 + backend/pkg/db/repository/builder/field.go | 7 + backend/pkg/db/repository/builder/keyword.go | 16 + .../pkg/db/repository/builder/operators.go | 57 + backend/pkg/db/repository/builder/patch.go | 16 + backend/pkg/db/repository/builder/pipeline.go | 24 + .../pkg/db/repository/builder/projection.go | 7 + backend/pkg/db/repository/builder/query.go | 24 + backend/pkg/db/repository/builder/unwind.go | 23 + backend/pkg/db/repository/builder/value.go | 5 + backend/pkg/db/repository/builders.go | 269 ++ backend/pkg/db/repository/cursor.go | 19 + backend/pkg/db/repository/decoder/decoder.go | 5 + .../pkg/db/repository/filter_factory_test.go | 93 + backend/pkg/db/repository/index/index.go | 25 + backend/pkg/db/repository/index/types.go | 36 + backend/pkg/db/repository/repository.go | 61 + backend/pkg/db/role/role.go | 15 + backend/pkg/db/storable/id.go | 39 + backend/pkg/db/storable/ref.go | 11 + backend/pkg/db/storable/storable.go | 10 + backend/pkg/db/template/interface.go | 21 + backend/pkg/db/template/template.go | 104 + backend/pkg/db/template/tseries.go | 14 + backend/pkg/db/transaction/context.go | 12 + backend/pkg/db/transaction/context_test.go | 21 + backend/pkg/db/transaction/factory.go | 5 + backend/pkg/db/transaction/transaction.go | 11 + backend/pkg/db/tseries/point/interface.go | 14 + backend/pkg/decimal/money.go | 129 + backend/pkg/decimal/rational.go | 161 + backend/pkg/decimal/rounding.go | 29 + backend/pkg/domainprovider/domain_provider.go | 15 + .../pkg/domainprovider/imp/domain_provider.go | 35 + backend/pkg/merrors/accounting.go | 25 + backend/pkg/merrors/aml.go | 20 + backend/pkg/merrors/aml_test.go | 21 + backend/pkg/merrors/chain_execution.go | 21 + backend/pkg/merrors/chain_execution_test.go | 17 + backend/pkg/merrors/classified.go | 48 + backend/pkg/merrors/documents.go | 44 + backend/pkg/merrors/documents_test.go | 37 + backend/pkg/merrors/errors.go | 268 ++ backend/pkg/merrors/errors_test.go | 125 + backend/pkg/merrors/invalid_argument.go | 44 + backend/pkg/merrors/ledger.go | 65 + backend/pkg/merrors/ledger_test.go | 48 + backend/pkg/merrors/limits.go | 19 + backend/pkg/merrors/organization.go | 66 + backend/pkg/merrors/paymaster_control.go | 9 + backend/pkg/merrors/payment_execution.go | 47 + backend/pkg/merrors/payment_execution_test.go | 26 + backend/pkg/merrors/quotation.go | 32 + backend/pkg/merrors/quotation_test.go | 44 + backend/pkg/merrors/reason.go | 105 + backend/pkg/merrors/routes.go | 127 + backend/pkg/merrors/routes_test.go | 52 + backend/pkg/merrors/signup_email_policy.go | 78 + .../pkg/merrors/signup_email_policy_test.go | 52 + backend/pkg/merrors/throttle.go | 50 + backend/pkg/merrors/throttle_test.go | 35 + backend/pkg/merrors/treasury.go | 15 + backend/pkg/mlogger/factory/mlogger.go | 10 + .../pkg/mlogger/internal/mlogger/mlogger.go | 23 + backend/pkg/mlogger/logger.go | 119 + backend/pkg/mlogger/logger_test.go | 71 + backend/pkg/model/account.go | 110 + backend/pkg/model/account_metadata.go | 45 + .../pkg/model/account_role/account_role.go | 57 + backend/pkg/model/account_test.go | 51 + backend/pkg/model/archivable.go | 18 + backend/pkg/model/auth.go | 84 + backend/pkg/model/card.go | 169 + backend/pkg/model/card_test.go | 68 + backend/pkg/model/chains.go | 97 + backend/pkg/model/chains_test.go | 33 + backend/pkg/model/chainwalletroute.go | 29 + backend/pkg/model/client.go | 25 + backend/pkg/model/cryptoaddress.go | 127 + backend/pkg/model/cryptoaddress_test.go | 84 + backend/pkg/model/ctoken.go | 47 + backend/pkg/model/ctoken_test.go | 30 + backend/pkg/model/currency.go | 77 + backend/pkg/model/currency_test.go | 41 + backend/pkg/model/describable.go | 11 + backend/pkg/model/fconfig.go | 8 + backend/pkg/model/iban.go | 26 + backend/pkg/model/indexable.go | 12 + backend/pkg/model/invitation.go | 72 + backend/pkg/model/invitation_status.go | 18 + backend/pkg/model/invitation_test.go | 62 + backend/pkg/model/invitation_validation.go | 23 + backend/pkg/model/ledger.go | 132 + backend/pkg/model/object.go | 20 + backend/pkg/model/organization.go | 111 + backend/pkg/model/ownerbound.go | 21 + backend/pkg/model/ownerkind.go | 24 + backend/pkg/model/payment.go | 276 ++ backend/pkg/model/payment_ledger.go | 77 + backend/pkg/model/payment_method_json_test.go | 122 + backend/pkg/model/permission.go | 33 + backend/pkg/model/rba.go | 52 + backend/pkg/model/recipient.go | 128 + backend/pkg/model/recipient_json_test.go | 62 + backend/pkg/model/recipient_wire_test.go | 64 + backend/pkg/model/refresh.go | 29 + backend/pkg/model/scopedstorablemodel.go | 19 + backend/pkg/model/sessionid.go | 7 + backend/pkg/model/signup_provisioning.go | 150 + backend/pkg/model/userdata.go | 30 + backend/pkg/model/verificaton.go | 60 + backend/pkg/model/viewcursor.go | 8 + backend/pkg/model/wallet.go | 25 + backend/pkg/model/walletprovisioning.go | 105 + backend/pkg/moneybase/fx_rounding.go | 50 + backend/pkg/moneybase/fx_rounding_test.go | 29 + backend/pkg/moneybase/scale.go | 12 + backend/pkg/mservice/mservice.go | 10 + backend/pkg/mservice/services.go | 108 + backend/pkg/mservice/services_test.go | 40 + backend/pkg/mutil/config/param.go | 74 + backend/pkg/mutil/db/archive.go | 37 + backend/pkg/mutil/db/array.go | 26 + backend/pkg/mutil/db/auth/accountbound.go | 89 + backend/pkg/mutil/db/auth/protected.go | 58 + backend/pkg/mutil/db/db.go | 20 + backend/pkg/mutil/duration/duration.go | 7 + backend/pkg/mutil/mask/mask.go | 29 + backend/pkg/mutil/mask/mask_test.go | 21 + backend/pkg/mutil/mzap/account.go | 28 + backend/pkg/mutil/mzap/error.go | 14 + backend/pkg/mutil/mzap/object.go | 28 + backend/pkg/person/name.go | 38 + backend/pkg/person/name_test.go | 14 + backend/pkg/server/factory.go | 5 + backend/pkg/server/internal/instance.go | 40 + backend/pkg/server/internal/server.go | 66 + backend/pkg/server/main/run.go | 11 + backend/pkg/server/server.go | 7 + backend/pkg/version/factory/factory.go | 10 + backend/pkg/version/info.go | 9 + backend/pkg/version/internal/version.go | 130 + backend/pkg/version/version.go | 10 + backend/scripts/seed-policies.js | 88 + docs/architecture/bff.md | 369 +- docs/implementation.md | 68 +- 556 files changed, 42291 insertions(+), 777 deletions(-) create mode 100644 .agents/skills/review-backend-change/SKILL.md create mode 100644 backend/PROVENANCE.json create mode 100644 backend/config.example.yml create mode 100644 backend/contracts/sendico/api.yaml create mode 100644 backend/contracts/sendico/api/accounts/auth_login.yaml create mode 100644 backend/contracts/sendico/api/accounts/auth_login_api.yaml create mode 100644 backend/contracts/sendico/api/accounts/auth_refresh.yaml create mode 100644 backend/contracts/sendico/api/accounts/auth_rotate.yaml create mode 100644 backend/contracts/sendico/api/accounts/bodies/auth.yaml create mode 100644 backend/contracts/sendico/api/accounts/request/auth.yaml create mode 100644 backend/contracts/sendico/api/accounts/response/auth.yaml create mode 100644 backend/contracts/sendico/api/callbacks/archive.yaml create mode 100644 backend/contracts/sendico/api/callbacks/bodies/callback.yaml create mode 100644 backend/contracts/sendico/api/callbacks/create.yaml create mode 100644 backend/contracts/sendico/api/callbacks/list.yaml create mode 100644 backend/contracts/sendico/api/callbacks/object.yaml create mode 100644 backend/contracts/sendico/api/callbacks/request/callback.yaml create mode 100644 backend/contracts/sendico/api/callbacks/response/callback.yaml create mode 100644 backend/contracts/sendico/api/callbacks/rotate_secret.yaml create mode 100644 backend/contracts/sendico/api/callbacks/update.yaml create mode 100644 backend/contracts/sendico/api/ledger_accounts/balance.yaml create mode 100644 backend/contracts/sendico/api/ledger_accounts/operation.yaml create mode 100644 backend/contracts/sendico/api/ledger_accounts/operations.yaml create mode 100644 backend/contracts/sendico/api/ledger_accounts/response/balance.yaml create mode 100644 backend/contracts/sendico/api/ledger_accounts/response/operation.yaml create mode 100644 backend/contracts/sendico/api/organizations/get.yaml create mode 100644 backend/contracts/sendico/api/organizations/response/organization.yaml create mode 100644 backend/contracts/sendico/api/organizations/root.yaml create mode 100644 backend/contracts/sendico/api/parameters/accounts_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/archived.yaml create mode 100644 backend/contracts/sendico/api/parameters/callbacks_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/cascade.yaml create mode 100644 backend/contracts/sendico/api/parameters/cursor.yaml create mode 100644 backend/contracts/sendico/api/parameters/entry_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/filter_states.yaml create mode 100644 backend/contracts/sendico/api/parameters/ledger_account_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/limit.yaml create mode 100644 backend/contracts/sendico/api/parameters/offset.yaml create mode 100644 backend/contracts/sendico/api/parameters/org_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/organizations_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/payment_methods_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/payments_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/recipients_ref.yaml create mode 100644 backend/contracts/sendico/api/parameters/state.yaml create mode 100644 backend/contracts/sendico/api/parameters/states.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/accounts.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/archive.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/object.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/recipients.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/request/payment_method.yaml create mode 100644 backend/contracts/sendico/api/payment_methods/response/payment_method.yaml create mode 100644 backend/contracts/sendico/api/payments/bodies/payment.yaml create mode 100644 backend/contracts/sendico/api/payments/by_multiquote.yaml create mode 100644 backend/contracts/sendico/api/payments/by_quote.yaml create mode 100644 backend/contracts/sendico/api/payments/documents_certificate.yaml create mode 100644 backend/contracts/sendico/api/payments/fx_currencies.yaml create mode 100644 backend/contracts/sendico/api/payments/fx_directions.yaml create mode 100644 backend/contracts/sendico/api/payments/immediate.yaml create mode 100644 backend/contracts/sendico/api/payments/list.yaml create mode 100644 backend/contracts/sendico/api/payments/multiquote.yaml create mode 100644 backend/contracts/sendico/api/payments/object.yaml create mode 100644 backend/contracts/sendico/api/payments/quote.yaml create mode 100644 backend/contracts/sendico/api/payments/registry.yaml create mode 100644 backend/contracts/sendico/api/payments/registry_refresh.yaml create mode 100644 backend/contracts/sendico/api/payments/request/payment.yaml create mode 100644 backend/contracts/sendico/api/payments/response/payment.yaml create mode 100644 backend/contracts/sendico/api/recipients/archive.yaml create mode 100644 backend/contracts/sendico/api/recipients/bodies/recipient.yaml create mode 100644 backend/contracts/sendico/api/recipients/create.yaml create mode 100644 backend/contracts/sendico/api/recipients/list.yaml create mode 100644 backend/contracts/sendico/api/recipients/object.yaml create mode 100644 backend/contracts/sendico/api/recipients/request/recipient.yaml create mode 100644 backend/contracts/sendico/api/recipients/response/recipient.yaml create mode 100644 backend/contracts/sendico/api/recipients/update.yaml create mode 100644 backend/contracts/sendico/api/response/error.yaml create mode 100644 backend/contracts/sendico/api/response/operation.yaml create mode 100644 backend/contracts/sendico/api/response/response.yaml create mode 100644 backend/contracts/sendico/api/response/status.yaml create mode 100644 backend/contracts/sendico/api/verification/bodies/verification.yaml create mode 100644 backend/contracts/sendico/api/verification/create.yaml create mode 100644 backend/contracts/sendico/api/verification/request/verification.yaml create mode 100644 backend/contracts/sendico/api/verification/resend.yaml create mode 100644 backend/contracts/sendico/api/verification/response/verification.yaml create mode 100644 backend/contracts/sendico/api/verification/verify.yaml create mode 100644 backend/contracts/sendico/api/wallets/assets.yaml create mode 100644 backend/contracts/sendico/api/wallets/response/wallet.yaml create mode 100644 backend/contracts/sendico/external/chain_network.yaml create mode 100644 backend/contracts/sendico/external/endpoint_type.yaml create mode 100644 backend/contracts/sendico/external/fx_side.yaml create mode 100644 backend/contracts/sendico/external/payment_kind.yaml create mode 100644 backend/contracts/sendico/external/payment_method_type.yaml create mode 100644 backend/contracts/sendico/external/payment_state.yaml create mode 100644 backend/contracts/sendico/external/settlement_mode.yaml create mode 100644 backend/contracts/sendico/models/account/account.yaml create mode 100644 backend/contracts/sendico/models/account/account_public.yaml create mode 100644 backend/contracts/sendico/models/auth/client_refresh_token.yaml create mode 100644 backend/contracts/sendico/models/auth/login_data.yaml create mode 100644 backend/contracts/sendico/models/auth/session_identifier.yaml create mode 100644 backend/contracts/sendico/models/auth/token_data.yaml create mode 100644 backend/contracts/sendico/models/callback/callback.yaml create mode 100644 backend/contracts/sendico/models/common/describable.yaml create mode 100644 backend/contracts/sendico/models/common/money.yaml create mode 100644 backend/contracts/sendico/models/common/pagination.yaml create mode 100644 backend/contracts/sendico/models/ledger/balance.yaml create mode 100644 backend/contracts/sendico/models/ledger/operation.yaml create mode 100644 backend/contracts/sendico/models/objectid.yaml create mode 100644 backend/contracts/sendico/models/organization/organization.yaml create mode 100644 backend/contracts/sendico/models/payment/fx_metadata.yaml create mode 100644 backend/contracts/sendico/models/payment/payment.yaml create mode 100644 backend/contracts/sendico/models/payment_method/payment_method.yaml create mode 100644 backend/contracts/sendico/models/permission_bound.yaml create mode 100644 backend/contracts/sendico/models/recipient/recipient.yaml create mode 100644 backend/contracts/sendico/models/storable.yaml create mode 100644 backend/contracts/sendico/models/wallet/wallet_asset.yaml create mode 100644 backend/edge/product/interface/accountservice/internal/join_organization_test.go create mode 100644 backend/edge/product/interface/accountservice/internal/service.go create mode 100644 backend/edge/product/interface/accountservice/internal/service_deletion_test.go create mode 100644 backend/edge/product/interface/accountservice/internal/service_test.go create mode 100644 backend/edge/product/interface/accountservice/types.go create mode 100644 backend/edge/shared/appversion/version.go create mode 100644 backend/edge/shared/auth/browser/browser.go create mode 100644 backend/edge/shared/auth/browser/browser_test.go create mode 100644 backend/edge/shared/auth/client/client.go create mode 100644 backend/edge/shared/auth/client/client_test.go create mode 100644 backend/edge/shared/auth/sessionmetrics/metrics.go create mode 100644 backend/edge/shared/auth/storage/stores.go create mode 100644 backend/edge/shared/auth/tokens/prepare_test.go create mode 100644 backend/edge/shared/auth/tokens/tokens.go create mode 100644 backend/edge/shared/http/ipguard/ipguard.go create mode 100644 backend/edge/shared/http/ipguard/ipguard_test.go create mode 100644 backend/edge/shared/interface/api/api.go create mode 100644 backend/edge/shared/interface/api/auth_config.go create mode 100644 backend/edge/shared/interface/api/config.go create mode 100644 backend/edge/shared/interface/api/register.go create mode 100644 backend/edge/shared/interface/api/runtime.go create mode 100644 backend/edge/shared/interface/api/srequest/changepolicies.go create mode 100644 backend/edge/shared/interface/api/srequest/changerole.go create mode 100644 backend/edge/shared/interface/api/srequest/login.go create mode 100644 backend/edge/shared/interface/api/srequest/refresh.go create mode 100644 backend/edge/shared/interface/api/srequest/rotate.go create mode 100644 backend/edge/shared/interface/api/srequest/validateable.go create mode 100644 backend/edge/shared/interface/api/srequest/verification.go create mode 100644 backend/edge/shared/interface/api/sresponse/account.go create mode 100644 backend/edge/shared/interface/api/sresponse/authresp.go create mode 100644 backend/edge/shared/interface/api/sresponse/file.go create mode 100644 backend/edge/shared/interface/api/sresponse/login.go create mode 100644 backend/edge/shared/interface/api/sresponse/login_pending.go create mode 100644 backend/edge/shared/interface/api/sresponse/login_test.go create mode 100644 backend/edge/shared/interface/api/sresponse/organization.go create mode 100644 backend/edge/shared/interface/api/sresponse/permissions.go create mode 100644 backend/edge/shared/interface/api/sresponse/response.go create mode 100644 backend/edge/shared/interface/api/sresponse/result.go create mode 100644 backend/edge/shared/interface/api/sresponse/token.go create mode 100644 backend/edge/shared/interface/api/sresponse/verification.go create mode 100644 backend/edge/shared/interface/api/verification_rate_limit.go create mode 100644 backend/edge/shared/interface/api/verification_rate_limit_test.go create mode 100644 backend/edge/shared/interface/api/ws/config.go create mode 100644 backend/edge/shared/interface/api/ws/handler.go create mode 100644 backend/edge/shared/interface/api/ws/message.go create mode 100644 backend/edge/shared/interface/middleware/cors_test.go create mode 100644 backend/edge/shared/interface/middleware/login_rate_limit.go create mode 100644 backend/edge/shared/interface/middleware/login_rate_limit_test.go create mode 100644 backend/edge/shared/interface/middleware/middleware.go create mode 100644 backend/edge/shared/interface/middleware/token_test.go create mode 100644 backend/edge/shared/interface/model/token.go create mode 100644 backend/edge/shared/interface/model/token_test.go create mode 100644 backend/edge/shared/interface/ratelimit/config.go create mode 100644 backend/edge/shared/interface/storage/config.go create mode 100644 backend/edge/shared/internal/runtime/api.go create mode 100644 backend/edge/shared/internal/runtime/bootstrap.go create mode 100644 backend/edge/shared/internal/runtime/lifecycle.go create mode 100644 backend/edge/shared/internal/runtime/services.go create mode 100644 backend/edge/shared/internal/server/config_test.go create mode 100644 backend/edge/shared/internal/server/server.go create mode 100644 backend/edge/shared/internal/server/server_test.go create mode 100644 backend/edge/shared/internal/transport/http/middleware.go create mode 100644 backend/edge/shared/internal/transport/http/middleware_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/authorized/handler.go create mode 100644 backend/edge/shared/internal/transport/http/router/authorized/router.go create mode 100644 backend/edge/shared/internal/transport/http/router/authorized/session_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/dispatcher.go create mode 100644 backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go create mode 100644 backend/edge/shared/internal/transport/http/router/endpoint/install.go create mode 100644 backend/edge/shared/internal/transport/http/router/endpoint/token.go create mode 100644 backend/edge/shared/internal/transport/http/router/metrics/handler.go create mode 100644 backend/edge/shared/internal/transport/http/router/metrics/router.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/login.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/login_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/logout.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/logout_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/refresh.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/refresh_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/respond.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/rotate.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/router.go create mode 100644 backend/edge/shared/internal/transport/http/router/public/validate.go create mode 100644 backend/edge/shared/internal/transport/http/router/router.go create mode 100644 backend/edge/shared/internal/transport/http/websocket/dispimp.go create mode 100644 backend/edge/shared/internal/transport/http/websocket/router.go create mode 100644 backend/edge/shared/server/server.go create mode 100644 backend/edge/shared/storage/mongo/dependencies.go create mode 100644 backend/edge/shared/storage/organization/archived.go create mode 100644 backend/edge/shared/storage/organization/cascade.go create mode 100644 backend/edge/shared/storage/organization/create.go create mode 100644 backend/edge/shared/storage/organization/db.go create mode 100644 backend/edge/shared/storage/organization/get.go create mode 100644 backend/edge/shared/storage/organization/index_test.go create mode 100644 backend/edge/shared/storage/organization/list.go create mode 100644 backend/edge/shared/storage/organization/member.go create mode 100644 backend/edge/shared/storage/organization/member_test.go create mode 100644 backend/edge/shared/storage/organization/memory_repository_queries_test.go create mode 100644 backend/edge/shared/storage/organization/memory_repository_test.go create mode 100644 backend/edge/shared/storage/organization/organization.go create mode 100644 backend/edge/shared/storage/organization/owned.go create mode 100644 backend/edge/shared/storage/organization/provider.go create mode 100644 backend/edge/shared/storage/organization/provisioning.go create mode 100644 backend/edge/shared/storage/organization/provisioning_test.go create mode 100644 backend/edge/shared/storage/organization/setarchived_test.go create mode 100644 backend/edge/shared/storage/organization/test_helpers_test.go create mode 100644 backend/edge/shared/storage/ratelimits/db.go create mode 100644 backend/edge/shared/storage/refreshtokens/client.go create mode 100644 backend/edge/shared/storage/refreshtokens/crud.go create mode 100644 backend/edge/shared/storage/refreshtokens/crud_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/db.go create mode 100644 backend/edge/shared/storage/refreshtokens/fields.go create mode 100644 backend/edge/shared/storage/refreshtokens/filters.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens_test.go create mode 100644 backend/edge/shared/storage/refreshtokens/revoke.go create mode 100644 backend/edge/shared/storage/s3key/key.go create mode 100644 backend/edge/shared/storage/s3key/key_test.go create mode 100644 backend/edge/shared/storage/verification/consume.go create mode 100644 backend/edge/shared/storage/verification/consume_logging.go create mode 100644 backend/edge/shared/storage/verification/consume_lookup.go create mode 100644 backend/edge/shared/storage/verification/consume_scope.go create mode 100644 backend/edge/shared/storage/verification/consume_update.go create mode 100644 backend/edge/shared/storage/verification/context_ref.go create mode 100644 backend/edge/shared/storage/verification/context_ref_test.go create mode 100644 backend/edge/shared/storage/verification/create.go create mode 100644 backend/edge/shared/storage/verification/db.go create mode 100644 backend/edge/shared/storage/verification/errors.go create mode 100644 backend/edge/shared/storage/verification/hash.go create mode 100644 backend/edge/shared/storage/verification/hash_test.go create mode 100644 backend/edge/shared/storage/verification/idempotency.go create mode 100644 backend/edge/shared/storage/verification/issuance_limit.go create mode 100644 backend/edge/shared/storage/verification/issuance_limit_test.go create mode 100644 backend/edge/shared/storage/verification/memory_token_repository_test.go create mode 100644 backend/edge/shared/storage/verification/request.go create mode 100644 backend/edge/shared/storage/verification/token_query_matcher_test.go create mode 100644 backend/edge/shared/storage/verification/verification.go create mode 100644 backend/edge/shared/storage/verification/verification_attempts_test.go create mode 100644 backend/edge/shared/storage/verification/verification_idempotency_test.go create mode 100644 backend/edge/shared/storage/verification/verification_test.go create mode 100644 backend/edge/shared/storage/verification/verification_test_helpers_test.go create mode 100644 backend/go.sum create mode 100644 backend/internal/architecture/boundary_test.go create mode 100644 backend/internal/architecture/contracts_test.go create mode 100644 backend/internal/architecture/errors_test.go delete mode 100644 backend/internal/browser/policy.go delete mode 100644 backend/internal/browser/policy_test.go delete mode 100644 backend/internal/credential/token.go delete mode 100644 backend/internal/credential/token_test.go delete mode 100644 backend/internal/evidence/key.go delete mode 100644 backend/internal/evidence/key_test.go delete mode 100644 backend/internal/httpapi/router.go delete mode 100644 backend/internal/httpapi/router_test.go create mode 100644 backend/internal/profile/runtime.go create mode 100644 backend/pkg/api/http/methods.go create mode 100644 backend/pkg/api/http/response/response.go create mode 100644 backend/pkg/api/http/response/response_test.go create mode 100644 backend/pkg/api/http/response/result.go create mode 100644 backend/pkg/api/http/status.go create mode 100644 backend/pkg/api/routers/health.go create mode 100644 backend/pkg/api/routers/health/status.go create mode 100644 backend/pkg/api/routers/internal/healthimp/health.go create mode 100644 backend/pkg/api/routers/internal/healthimp/health_test.go create mode 100644 backend/pkg/api/routers/internal/healthimp/status.go create mode 100644 backend/pkg/auth/anyobject/anyobject.go create mode 100644 backend/pkg/auth/archivable.go create mode 100644 backend/pkg/auth/archivableimp.go create mode 100644 backend/pkg/auth/authorizer.go create mode 100644 backend/pkg/auth/authorizer_test.go create mode 100644 backend/pkg/auth/config.go create mode 100644 backend/pkg/auth/db.go create mode 100644 backend/pkg/auth/dbab.go create mode 100644 backend/pkg/auth/dbimp.go create mode 100644 backend/pkg/auth/dbimpab.go create mode 100644 backend/pkg/auth/dbimpab_test.go create mode 100644 backend/pkg/auth/dbimpscoped.go create mode 100644 backend/pkg/auth/dbimpscoped_test.go create mode 100644 backend/pkg/auth/dbimpscopedcrud.go create mode 100644 backend/pkg/auth/dbimpscopedpatch.go create mode 100644 backend/pkg/auth/enforcer.go create mode 100644 backend/pkg/auth/factory.go create mode 100644 backend/pkg/auth/helper.go create mode 100644 backend/pkg/auth/indexable.go create mode 100644 backend/pkg/auth/indexableimp.go create mode 100644 backend/pkg/auth/internal/casbin/config/config.go create mode 100644 backend/pkg/auth/internal/casbin/enforcer.go create mode 100644 backend/pkg/auth/internal/casbin/factory.go create mode 100644 backend/pkg/auth/internal/casbin/logger.go create mode 100644 backend/pkg/auth/internal/casbin/manager.go create mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter.go create mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go create mode 100644 backend/pkg/auth/internal/casbin/permissions.go create mode 100644 backend/pkg/auth/internal/casbin/role.go create mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/policy.go create mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/role.go create mode 100644 backend/pkg/auth/internal/casbin/serialization/policy.go create mode 100644 backend/pkg/auth/internal/casbin/serialization/role.go create mode 100644 backend/pkg/auth/internal/casbin/serialization/serializer.go create mode 100644 backend/pkg/auth/internal/native/db/policies.go create mode 100644 backend/pkg/auth/internal/native/db/roles.go create mode 100644 backend/pkg/auth/internal/native/dbpolicies.go create mode 100644 backend/pkg/auth/internal/native/dbroles.go create mode 100644 backend/pkg/auth/internal/native/enforcer.go create mode 100644 backend/pkg/auth/internal/native/enforcer_test.go create mode 100644 backend/pkg/auth/internal/native/manager.go create mode 100644 backend/pkg/auth/internal/native/nstructures/policies.go create mode 100644 backend/pkg/auth/internal/native/nstructures/role.go create mode 100644 backend/pkg/auth/internal/native/object_scope_test.go create mode 100644 backend/pkg/auth/internal/native/permission.go create mode 100644 backend/pkg/auth/internal/native/role.go create mode 100644 backend/pkg/auth/management/permission.go create mode 100644 backend/pkg/auth/management/role.go create mode 100644 backend/pkg/auth/manager.go create mode 100644 backend/pkg/auth/patchguard.go create mode 100644 backend/pkg/auth/provider.go create mode 100644 backend/pkg/auth/scoped.go create mode 100644 backend/pkg/auth/scoped_test.go create mode 100644 backend/pkg/auth/scopeddb.go create mode 100644 backend/pkg/auth/scopeddb_test.go create mode 100644 backend/pkg/db/account/account.go create mode 100644 backend/pkg/db/config.go create mode 100644 backend/pkg/db/connection.go create mode 100644 backend/pkg/db/factory.go create mode 100644 backend/pkg/db/internal/mongo/accountdb/db.go create mode 100644 backend/pkg/db/internal/mongo/accountdb/user.go create mode 100644 backend/pkg/db/internal/mongo/db.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/accept.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/archived.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/cascade.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/db.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_test.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/decline.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/getpublic.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/indexes.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/list.go create mode 100644 backend/pkg/db/internal/mongo/invitationdb/updatestatus.go create mode 100644 backend/pkg/db/internal/mongo/mongo.go create mode 100644 backend/pkg/db/internal/mongo/policiesdb/all.go create mode 100644 backend/pkg/db/internal/mongo/policiesdb/builtin.go create mode 100644 backend/pkg/db/internal/mongo/policiesdb/db.go create mode 100644 backend/pkg/db/internal/mongo/policiesdb/db_test.go create mode 100644 backend/pkg/db/internal/mongo/policiesdb/policies.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/index.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/index_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_test.go create mode 100644 backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go create mode 100644 backend/pkg/db/internal/mongo/rolesdb/db.go create mode 100644 backend/pkg/db/internal/mongo/rolesdb/list.go create mode 100644 backend/pkg/db/internal/mongo/rolesdb/roles.go create mode 100644 backend/pkg/db/internal/mongo/transactionimp/factory.go create mode 100644 backend/pkg/db/internal/mongo/transactionimp/transaction.go create mode 100644 backend/pkg/db/invitation/invitation.go create mode 100644 backend/pkg/db/permissions_config_test.go create mode 100644 backend/pkg/db/policy/policy.go create mode 100644 backend/pkg/db/repository/abfilter.go create mode 100644 backend/pkg/db/repository/builder/accumulator.go create mode 100644 backend/pkg/db/repository/builder/alias.go create mode 100644 backend/pkg/db/repository/builder/array.go create mode 100644 backend/pkg/db/repository/builder/expression.go create mode 100644 backend/pkg/db/repository/builder/field.go create mode 100644 backend/pkg/db/repository/builder/keyword.go create mode 100644 backend/pkg/db/repository/builder/operators.go create mode 100644 backend/pkg/db/repository/builder/patch.go create mode 100644 backend/pkg/db/repository/builder/pipeline.go create mode 100644 backend/pkg/db/repository/builder/projection.go create mode 100644 backend/pkg/db/repository/builder/query.go create mode 100644 backend/pkg/db/repository/builder/unwind.go create mode 100644 backend/pkg/db/repository/builder/value.go create mode 100644 backend/pkg/db/repository/builders.go create mode 100644 backend/pkg/db/repository/cursor.go create mode 100644 backend/pkg/db/repository/decoder/decoder.go create mode 100644 backend/pkg/db/repository/filter_factory_test.go create mode 100644 backend/pkg/db/repository/index/index.go create mode 100644 backend/pkg/db/repository/index/types.go create mode 100644 backend/pkg/db/repository/repository.go create mode 100644 backend/pkg/db/role/role.go create mode 100644 backend/pkg/db/storable/id.go create mode 100644 backend/pkg/db/storable/ref.go create mode 100644 backend/pkg/db/storable/storable.go create mode 100644 backend/pkg/db/template/interface.go create mode 100644 backend/pkg/db/template/template.go create mode 100644 backend/pkg/db/template/tseries.go create mode 100644 backend/pkg/db/transaction/context.go create mode 100644 backend/pkg/db/transaction/context_test.go create mode 100644 backend/pkg/db/transaction/factory.go create mode 100644 backend/pkg/db/transaction/transaction.go create mode 100644 backend/pkg/db/tseries/point/interface.go create mode 100644 backend/pkg/decimal/money.go create mode 100644 backend/pkg/decimal/rational.go create mode 100644 backend/pkg/decimal/rounding.go create mode 100644 backend/pkg/domainprovider/domain_provider.go create mode 100644 backend/pkg/domainprovider/imp/domain_provider.go create mode 100644 backend/pkg/merrors/accounting.go create mode 100644 backend/pkg/merrors/aml.go create mode 100644 backend/pkg/merrors/aml_test.go create mode 100644 backend/pkg/merrors/chain_execution.go create mode 100644 backend/pkg/merrors/chain_execution_test.go create mode 100644 backend/pkg/merrors/classified.go create mode 100644 backend/pkg/merrors/documents.go create mode 100644 backend/pkg/merrors/documents_test.go create mode 100644 backend/pkg/merrors/errors.go create mode 100644 backend/pkg/merrors/errors_test.go create mode 100644 backend/pkg/merrors/invalid_argument.go create mode 100644 backend/pkg/merrors/ledger.go create mode 100644 backend/pkg/merrors/ledger_test.go create mode 100644 backend/pkg/merrors/limits.go create mode 100644 backend/pkg/merrors/organization.go create mode 100644 backend/pkg/merrors/paymaster_control.go create mode 100644 backend/pkg/merrors/payment_execution.go create mode 100644 backend/pkg/merrors/payment_execution_test.go create mode 100644 backend/pkg/merrors/quotation.go create mode 100644 backend/pkg/merrors/quotation_test.go create mode 100644 backend/pkg/merrors/reason.go create mode 100644 backend/pkg/merrors/routes.go create mode 100644 backend/pkg/merrors/routes_test.go create mode 100644 backend/pkg/merrors/signup_email_policy.go create mode 100644 backend/pkg/merrors/signup_email_policy_test.go create mode 100644 backend/pkg/merrors/throttle.go create mode 100644 backend/pkg/merrors/throttle_test.go create mode 100644 backend/pkg/merrors/treasury.go create mode 100644 backend/pkg/mlogger/factory/mlogger.go create mode 100644 backend/pkg/mlogger/internal/mlogger/mlogger.go create mode 100644 backend/pkg/mlogger/logger.go create mode 100644 backend/pkg/mlogger/logger_test.go create mode 100644 backend/pkg/model/account.go create mode 100644 backend/pkg/model/account_metadata.go create mode 100644 backend/pkg/model/account_role/account_role.go create mode 100644 backend/pkg/model/account_test.go create mode 100644 backend/pkg/model/archivable.go create mode 100644 backend/pkg/model/auth.go create mode 100644 backend/pkg/model/card.go create mode 100644 backend/pkg/model/card_test.go create mode 100644 backend/pkg/model/chains.go create mode 100644 backend/pkg/model/chains_test.go create mode 100644 backend/pkg/model/chainwalletroute.go create mode 100644 backend/pkg/model/client.go create mode 100644 backend/pkg/model/cryptoaddress.go create mode 100644 backend/pkg/model/cryptoaddress_test.go create mode 100644 backend/pkg/model/ctoken.go create mode 100644 backend/pkg/model/ctoken_test.go create mode 100644 backend/pkg/model/currency.go create mode 100644 backend/pkg/model/currency_test.go create mode 100644 backend/pkg/model/describable.go create mode 100644 backend/pkg/model/fconfig.go create mode 100644 backend/pkg/model/iban.go create mode 100644 backend/pkg/model/indexable.go create mode 100644 backend/pkg/model/invitation.go create mode 100644 backend/pkg/model/invitation_status.go create mode 100644 backend/pkg/model/invitation_test.go create mode 100644 backend/pkg/model/invitation_validation.go create mode 100644 backend/pkg/model/ledger.go create mode 100644 backend/pkg/model/object.go create mode 100644 backend/pkg/model/organization.go create mode 100644 backend/pkg/model/ownerbound.go create mode 100644 backend/pkg/model/ownerkind.go create mode 100644 backend/pkg/model/payment.go create mode 100644 backend/pkg/model/payment_ledger.go create mode 100644 backend/pkg/model/payment_method_json_test.go create mode 100644 backend/pkg/model/permission.go create mode 100644 backend/pkg/model/rba.go create mode 100644 backend/pkg/model/recipient.go create mode 100644 backend/pkg/model/recipient_json_test.go create mode 100644 backend/pkg/model/recipient_wire_test.go create mode 100644 backend/pkg/model/refresh.go create mode 100644 backend/pkg/model/scopedstorablemodel.go create mode 100644 backend/pkg/model/sessionid.go create mode 100644 backend/pkg/model/signup_provisioning.go create mode 100644 backend/pkg/model/userdata.go create mode 100644 backend/pkg/model/verificaton.go create mode 100644 backend/pkg/model/viewcursor.go create mode 100644 backend/pkg/model/wallet.go create mode 100644 backend/pkg/model/walletprovisioning.go create mode 100644 backend/pkg/moneybase/fx_rounding.go create mode 100644 backend/pkg/moneybase/fx_rounding_test.go create mode 100644 backend/pkg/moneybase/scale.go create mode 100644 backend/pkg/mservice/mservice.go create mode 100644 backend/pkg/mservice/services.go create mode 100644 backend/pkg/mservice/services_test.go create mode 100644 backend/pkg/mutil/config/param.go create mode 100644 backend/pkg/mutil/db/archive.go create mode 100644 backend/pkg/mutil/db/array.go create mode 100644 backend/pkg/mutil/db/auth/accountbound.go create mode 100644 backend/pkg/mutil/db/auth/protected.go create mode 100644 backend/pkg/mutil/db/db.go create mode 100644 backend/pkg/mutil/duration/duration.go create mode 100644 backend/pkg/mutil/mask/mask.go create mode 100644 backend/pkg/mutil/mask/mask_test.go create mode 100644 backend/pkg/mutil/mzap/account.go create mode 100644 backend/pkg/mutil/mzap/error.go create mode 100644 backend/pkg/mutil/mzap/object.go create mode 100644 backend/pkg/person/name.go create mode 100644 backend/pkg/person/name_test.go create mode 100644 backend/pkg/server/factory.go create mode 100644 backend/pkg/server/internal/instance.go create mode 100644 backend/pkg/server/internal/server.go create mode 100644 backend/pkg/server/main/run.go create mode 100644 backend/pkg/server/server.go create mode 100644 backend/pkg/version/factory/factory.go create mode 100644 backend/pkg/version/info.go create mode 100644 backend/pkg/version/internal/version.go create mode 100644 backend/pkg/version/version.go create mode 100644 backend/scripts/seed-policies.js diff --git a/.agents/skills/review-backend-change/SKILL.md b/.agents/skills/review-backend-change/SKILL.md new file mode 100644 index 0000000..194b0d0 --- /dev/null +++ b/.agents/skills/review-backend-change/SKILL.md @@ -0,0 +1,59 @@ +--- +name: review-backend-change +description: Review, fix, and validate the complete SXCHNGE backend contribution for a task. Use before reporting completion whenever Go, protobuf, or other backend code under backend/ was added or modified, or when the user requests a backend implementation review. +--- + +# Review Backend Change + +## Establish the review scope + +1. Read the repository root `AGENTS.md` and `backend/AGENTS.md` completely. +2. Inspect repository status and the full backend diff attributable to the + current task, including uncommitted and committed changes against the task's + base. +3. Separate unrelated pre-existing work from the task contribution. Preserve + it and do not include it in review fixes. +4. Identify changed behavior, public or internal contracts, and expected test + coverage before judging individual lines. + +## Review and fix the contribution + +1. Apply every quality requirement in `backend/AGENTS.md` to the entire task diff, + not only the most recently edited file. +2. Search the repository for canonical protobuf messages, enums, Go types, + interfaces, and shared domain definitions before accepting new ones. +3. Check project-error usage, cause preservation, cohesion, naming, API and + module boundaries, duplication, dead code, and testability. +4. Review structured logging at meaningful operational and domain boundaries. + Check level selection, context, sensitive-data handling, and duplicate error + logging. +5. Review success, edge, and failure paths. Add or update focused tests for the + changed behavior. +6. Fix every in-scope issue found, then re-read the resulting complete backend + diff. Repeat until no task-caused finding remains. + +Do not weaken checks, suppress failures, or modify unrelated user work to make +validation pass. + +## Validate + +After review fixes are complete, run these commands from the repository root: + +```text +make test +make lint-backend +``` + +Then select and run every applicable security command from the change-scoped +security completion requirements in the root `AGENTS.md`. + +All commands must succeed before reporting the backend task as complete. If a +command fails, determine whether the task caused it and fix in-scope failures. +Report an external or environmental blocker with the exact command and failure; +never represent blocked validation as successful. + +## Report the result + +Summarize the reviewed backend scope, material fixes made during review, tests +added or updated, and the outcome of each required command. Call out contract, +migration, rollout, security, or observability implications that remain relevant. diff --git a/.gitignore b/.gitignore index 7030e4f..6534200 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ pnpm-debug.log* # Local editor configuration /.vscode/ + +# Local BFF configuration and build output +/backend/config.yml +/backend/bff diff --git a/AGENTS.md b/AGENTS.md index bc0ad44..7dc5675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,8 @@ repository changes do not require a branch. ## Scoped implementation requirements -- For backend code under `api/`, read and follow - [`api/AGENTS.md`](api/AGENTS.md). Before reporting completion, use the +- For backend code under `backend/`, read and follow + [`backend/AGENTS.md`](backend/AGENTS.md). Before reporting completion, use the [`$review-backend-change`](.agents/skills/review-backend-change/SKILL.md) skill. - For Dart or Flutter code under `frontend/`, read and follow diff --git a/Makefile b/Makefile index edc5a03..84aeb8a 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +.PHONY: test + +test: test-backend test-frontend + .PHONY: test-frontend test-frontend: @@ -15,6 +19,7 @@ test-backend: cd backend && go test -race ./... lint-backend: + cd backend && go test ./internal/architecture cd backend && test -z "$$(gofmt -l .)" cd backend && go vet ./... diff --git a/README.md b/README.md index c511b64..82629d9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SXCHNGE Product repository for the sxchnge public website, Flutter consumer app, reusable -packages, and planned backend for frontend (BFF). +packages, and a backend for frontend (BFF) bootstrapped from Sendico. ## Current state @@ -11,9 +11,9 @@ with `go_router`. Its interface uses the shared theme's colors and typography. **Authentication, email verification and analytics use dummy services.** No real account or session is created, no email is delivered, and no analytics data is -transmitted. The backend has an independent Go operational shell; product services -are not connected. Session/KYC access guards and Sendico integration are not -implemented. +transmitted by those app services. The backend now contains locally owned copies +of Sendico’s BFF runtime, auth/session stack and account service. The app is not +connected to that backend or to Sendico yet. ## Repository structure @@ -24,7 +24,7 @@ implemented. | [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font | | [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared auth contracts, providers, two-factor page and dummy services | | [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics | -| [`backend/`](backend/README.md) | Independent Go BFF starter, adapted foundations and provider integration plan | +| [`backend/`](backend/README.md) | Copied BFF/auth/account foundation and isolated public Sendico contracts | | [`.agents/skills/`](.agents/skills) | Repository agent skills | | [`docs/`](docs/implementation.md) | Implementation records and UX measurement contracts | @@ -144,17 +144,19 @@ usability, accessibility and journey measurement under the repository's UX rules ## Product boundaries -The intended integration is: **consumer app → sxchnge BFF → supported Sendico API**. +The integration is: **consumer app → sxchnge BFF → Sendico public HTTP API**. +SXCHNGE is one organization in Sendico; its customers are owned locally. - sxchnge will own consumer registration/sessions, onboarding and exchange UX, - and mappings to stable, organization-scoped Sendico customer references. + and mappings from local customers to organization-scoped platform resources. - Sendico remains authoritative for payment execution, KYC records and verification, policy decisions and limit enforcement. - The sxchnge organization, backend integration principal and individual customer references are distinct identities. Consumers must not share the integration account's customer identity. -- The BFF uses a supported provider API. Selected Sendico components may be - adapted into SXCHNGE-owned code; local Sendico module dependencies are not used. +- The BFF uses only the public HTTP API. Existing BFF/auth/account components + are copied into SXCHNGE-owned code; private transports and sibling module + dependencies are not used. Integration credentials belong in the backend. Before backend integration, agree on the customer, KYC, eligibility/limits and @@ -234,5 +236,7 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded [provenance](backend/PROVENANCE.md); the projects share no runtime modules. Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. -The starter exposes health and unconfigured readiness only. Authentication, -persistence and live financial operations remain the next implementation phases. +The copied runtime installs local authentication transport and initializes its +account service. Signup/profile endpoints, verification delivery/completion, +Flutter HTTP adapters and live Sendico operations remain to be wired. See the +[backend run guide](backend/README.md) for local MongoDB configuration. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 1a271dd..b17e35b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,15 +1,95 @@ -# BFF guidelines +# Backend Agent Guidelines -Follow root AGENTS.md. This is an independent Go module; Sendico is an external -provider, not a local module dependency. Never add sibling-path replacements, -shared database access or internal Sendico discovery/messaging dependencies. +These instructions extend the repository-wide rules in the root `AGENTS.md` +and apply whenever a task adds or modifies backend code, including Go and +protobuf files under `backend/`. -Keep HTTP DTOs/mappers, application models/use cases and provider adapters distinct. -Resolve ownership from the authenticated SXCHNGE principal and server-side mappings. -Only adapters may expose provider transport types internally. Adapted code belongs -to SXCHNGE; record source revision and changes in PROVENANCE.md. +Before completing the task, review the entire backend diff produced by the +current task, fix every in-scope issue found, and then perform the required +validation. Use the +[`$review-backend-change`](../.agents/skills/review-backend-change/SKILL.md) +skill to perform this completion workflow. -Review the complete backend contribution before delivery. Run `make test-backend` -and `make lint-backend` at the repository root. Add behavioral tests for changed -boundaries and failure paths. Errors returned publicly must be stable typed codes; -never expose raw provider errors, secrets, document contents or credentials. +## Reuse existing definitions + +- Search the repository before introducing a protobuf message, enum, Go struct, + interface, or shared domain type. +- Reuse or extend the canonical existing definition when its semantics match. + Do not create parallel representations or conversion layers for the same + concept without a clear architectural reason. +- Avoid naming stutter in packages, types, fields, parameters, and access paths. + Each nested name should add meaning instead of repeating its parent context. + For example, prefer a concise path such as `payment.Details.ID` over + `payment.Payment.PaymentID`. + +## Use the project error model + +- Do not introduce `fmt.Errorf` or `errors.New`. Use the constructors and + wrapping helpers in `backend/pkg/merrors`. +- Preserve the underlying cause when adding context by using an appropriate + `merrors` wrapping helper. +- When no suitable project error exists, add the smallest generally useful + definition or helper to `backend/pkg/merrors` instead of creating an ad hoc error + elsewhere. + +## Keep modules cohesive and names concise + +- Review touched files for excessive size, mixed responsibilities, duplicated + logic, and poor separation of concerns. +- Refactor oversized contributions into focused, cohesive files or packages. + Do not split code mechanically; organize it along clear domain or + architectural boundaries. +- Avoid repeating directory or package context in child directory and file + names. For example, prefer `payment/widget.dart` over + `payment/payment_widget.dart`. + +## Review implementation quality + +- Review the current task's backend contribution for correctness, readability, + maintainability, testability, and consistency with the surrounding + architecture. +- Keep public APIs and module boundaries small and intentional. Remove + unnecessary indirection, duplication, dead code, and speculative abstractions. +- Add or update tests for changed behavior, edge cases, and failure paths. +- Fix all findings caused by the current task before reporting completion. + +## Review logging + +- Ensure the contribution has sufficient structured logging at meaningful + operational and domain boundaries. Include useful identifiers and context, + but never log credentials, secrets, or sensitive payment data. +- Follow the repository's logging style and use levels consistently: + - `Debug`: fine-grained runtime execution tracking useful during debugging. + - `Info`: important payment-domain events, module-boundary operations, + effective module configuration, and noteworthy but expected deviations from + the usual path. + - `Warn`: runtime errors and unexpected conditions from which the service can + continue or which are returned to a caller. + - `Error`: unrecoverable failures that cause, or imminently lead to, service + or component termination. +- Avoid logging the same error repeatedly at multiple layers. Log it where the + layer has enough context to make the event actionable. + +## Required validation + +- From the repository root, run `make test` and `make lint-backend`, plus every + applicable command from the root `AGENTS.md` change-scoped security + requirements, after all review fixes are complete. +- All commands must succeed before the task is reported as complete. Fix + failures introduced by the current task; do not silently skip, suppress, or + weaken checks. +- Report the commands run and their outcomes in the final response. If an + external or environmental blocker prevents a command from running, report + the exact command, failure, and blocker explicitly; do not claim successful + validation. + +## SXCHNGE provider boundary + +SXCHNGE owns auth, sessions and customer management. SXCHNGE is one organization +inside Sendico. Copied BFF components are locally owned and may diverge. +Use only Sendico's public HTTP API for platform operations. Never import sibling +Sendico modules or access its database, internal gRPC APIs, discovery or message +bus. Keep the pinned public wire contract separate from local DTOs and models. +Resolve the configured provider organization and customer resource ownership on +the server. Never treat all customers as the backend integration principal. +Use the copied structured logger, injected at operational boundaries. diff --git a/backend/PROVENANCE.json b/backend/PROVENANCE.json new file mode 100644 index 0000000..5823010 --- /dev/null +++ b/backend/PROVENANCE.json @@ -0,0 +1,3079 @@ +{ + "repository": "S_I/sendico", + "revision": "8bb20dcd622dd8eb92d289b6b34411e7eccab083", + "files": [ + { + "source": "api/edge/shared/storage/s3key/key.go", + "target": "edge/shared/storage/s3key/key.go", + "blob": "e3cca16304d2c4cb9f256f510d5a1a30a21e22a6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/s3key/key_test.go", + "target": "edge/shared/storage/s3key/key_test.go", + "blob": "e016ff8b05c0975c72015d952e408dba3ba85f6f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/accounting.go", + "target": "pkg/merrors/accounting.go", + "blob": "e5f34a8c17af718e16c624ee72abf45c913b250c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/aml.go", + "target": "pkg/merrors/aml.go", + "blob": "c544dfcad1c701fbbfb89afa2e35235cbec9ad39", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/aml_test.go", + "target": "pkg/merrors/aml_test.go", + "blob": "bf03151338f835e5275ca03a26531cd0a8186325", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/chain_execution.go", + "target": "pkg/merrors/chain_execution.go", + "blob": "6dd96e073549f15f91ed25ea3191103ddc645097", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/chain_execution_test.go", + "target": "pkg/merrors/chain_execution_test.go", + "blob": "85831a603568cca18e0db4e0ffb8359d043f0838", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/classified.go", + "target": "pkg/merrors/classified.go", + "blob": "569049273d437caa7d7d62e8f804f2d8b5ba632f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/documents.go", + "target": "pkg/merrors/documents.go", + "blob": "f94d13e56de3e57fd6faaf975d7f44acda984fce", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/documents_test.go", + "target": "pkg/merrors/documents_test.go", + "blob": "6b048c6066bf23c2821e960d6dc494269c92a4a0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/errors.go", + "target": "pkg/merrors/errors.go", + "blob": "dceb39670721ed0b4bc59e8da9a96a0428c87684", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/errors_test.go", + "target": "pkg/merrors/errors_test.go", + "blob": "4602c7cbc06b59f99059bc8e6e2780ba899f55a2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/invalid_argument.go", + "target": "pkg/merrors/invalid_argument.go", + "blob": "954402a713c11f113377a08cdb27ef87006e40c3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/ledger.go", + "target": "pkg/merrors/ledger.go", + "blob": "bfaa6f9bd8683d5830ad24f9353047ae9764b0e9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/ledger_test.go", + "target": "pkg/merrors/ledger_test.go", + "blob": "05287d741d2d97230be202e91736952c54e16161", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/limits.go", + "target": "pkg/merrors/limits.go", + "blob": "8e16a8fc4e13c34cdc0712bd4ead4c7e405522aa", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/organization.go", + "target": "pkg/merrors/organization.go", + "blob": "591ada734f2a3d9b4ed590de577cb432ac2a0cbf", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/paymaster_control.go", + "target": "pkg/merrors/paymaster_control.go", + "blob": "db12becdc40206d4cbc344a628776f26c703d7e0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/payment_execution.go", + "target": "pkg/merrors/payment_execution.go", + "blob": "86b0b6aa3395fa392c2c952775881da8cc8ad013", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/payment_execution_test.go", + "target": "pkg/merrors/payment_execution_test.go", + "blob": "82c5ae93c047eb0d2902e990e53b2ded983e5e0b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/quotation.go", + "target": "pkg/merrors/quotation.go", + "blob": "b65c15845a6e0c51513a06af57ae4ea18a26b260", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/quotation_test.go", + "target": "pkg/merrors/quotation_test.go", + "blob": "584af1ef24af82fa63f4c41ebb02489edbce309d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/reason.go", + "target": "pkg/merrors/reason.go", + "blob": "9205bac7ece87eaf56e410ff96ec68a0f0f69e51", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/routes.go", + "target": "pkg/merrors/routes.go", + "blob": "546018c398d8417975c0986184daec643e1acbf3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/routes_test.go", + "target": "pkg/merrors/routes_test.go", + "blob": "40f2fbdf82fc4d701835243ffc844ffd233afdc0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/signup_email_policy.go", + "target": "pkg/merrors/signup_email_policy.go", + "blob": "7fdc705e8d71641b95c6fef42dedbc471515d8bf", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/signup_email_policy_test.go", + "target": "pkg/merrors/signup_email_policy_test.go", + "blob": "40b44ba9833e8e870b2c185b6535e89cb107c34c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/throttle.go", + "target": "pkg/merrors/throttle.go", + "blob": "3ff3da6a67b09911c14170fe04cadf8810f3a5f9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/throttle_test.go", + "target": "pkg/merrors/throttle_test.go", + "blob": "10014a308444208cc67fbefc61c0bb815697dafe", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/merrors/treasury.go", + "target": "pkg/merrors/treasury.go", + "blob": "d4c4d5347cb775012a5a1232e3b3bf50c319da1b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/appversion/version.go", + "target": "edge/shared/appversion/version.go", + "blob": "85e224562f93e7e813b5bf5728cac8903c9d5cd7", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/version/factory/factory.go", + "target": "pkg/version/factory/factory.go", + "blob": "be90064ac5e583446678fdbbc12126c57e82ced6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/version/internal/version.go", + "target": "pkg/version/internal/version.go", + "blob": "15d6674c3d6c3b5ac75ce455b228fb01d6003639", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/version/info.go", + "target": "pkg/version/info.go", + "blob": "c9a9ce553da2905e1415d414ed190029b13e53e2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/version/version.go", + "target": "pkg/version/version.go", + "blob": "7ab617fe20f65d080795cfa7d3bf8c3b21b96126", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/server/main/run.go", + "target": "pkg/server/main/run.go", + "blob": "103dbeaf5fa7bb533dad3ce2c5e0aed1cd8667c7", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/server/internal/instance.go", + "target": "pkg/server/internal/instance.go", + "blob": "e1d8f2a5b4cd6df2740562632c49cae5ee336d77", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/server/internal/server.go", + "target": "pkg/server/internal/server.go", + "blob": "b841af395a1ceaaedc337000cb75ec44834e27d3", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/server/factory.go", + "target": "pkg/server/factory.go", + "blob": "a91e7251de0d882cfb389fc155d636d2872b9749", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/server/server.go", + "target": "pkg/server/server.go", + "blob": "127219004cdea545dc0da6f11026e18522449989", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mlogger/logger.go", + "target": "pkg/mlogger/logger.go", + "blob": "71172f8361d8185d1b86106e9de5e568cc39fe8e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mlogger/logger_test.go", + "target": "pkg/mlogger/logger_test.go", + "blob": "9c7b9e8b6981c8a363810d84dd2050a872872499", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mlogger/factory/mlogger.go", + "target": "pkg/mlogger/factory/mlogger.go", + "blob": "d3315d2c6b54c44963c23900d5a84379bb1104ff", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mlogger/internal/mlogger/mlogger.go", + "target": "pkg/mlogger/internal/mlogger/mlogger.go", + "blob": "ad7c579f7c5a26668f171853677b52424a2f203f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mservice/mservice.go", + "target": "pkg/mservice/mservice.go", + "blob": "87e24f67707a64084f6eaf4d7ed9b4e14c17177c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mservice/services.go", + "target": "pkg/mservice/services.go", + "blob": "d02e6f5b5af4ba7dac1fdd1cf3f6331ecd1800a1", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mservice/services_test.go", + "target": "pkg/mservice/services_test.go", + "blob": "546cbf5ad65032e0cd594a98cd0c4e08cf99c3ec", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/account.go", + "target": "pkg/model/account.go", + "blob": "552c87f19dedb4927b3758d7ca86f091be8e2f34", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/account_metadata.go", + "target": "pkg/model/account_metadata.go", + "blob": "5f0e82c1abf347df42db7410bfb91b40a1eef7f0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/account_test.go", + "target": "pkg/model/account_test.go", + "blob": "0aff70c89c5a52a92f943d6e3f8da513bf89d4ba", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/archivable.go", + "target": "pkg/model/archivable.go", + "blob": "9b51c661f5c09825a6ea8dfc46aed09f94166654", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/auth.go", + "target": "pkg/model/auth.go", + "blob": "2c6bda159fbf0ee5fb64e8de8faf59bb3ceee166", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/card.go", + "target": "pkg/model/card.go", + "blob": "426c22e21bace53eef91b6ddcd82365944f7c902", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/card_test.go", + "target": "pkg/model/card_test.go", + "blob": "2ca8c0a88376dc19ca2372ea372cfc255e0b3c9d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/chains.go", + "target": "pkg/model/chains.go", + "blob": "b0e8b27da0f2c551fb4861fe704d1f262d3cdf2d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/chains_test.go", + "target": "pkg/model/chains_test.go", + "blob": "84c4a9b8f7edd95146c83e463ce2cb34932daf5b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/chainwalletroute.go", + "target": "pkg/model/chainwalletroute.go", + "blob": "d5f16f43ede5b0750fdd84f1c447dc6fc36009a7", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/client.go", + "target": "pkg/model/client.go", + "blob": "d61c63a55985b8b3bf05bb417f1079ab98fbf495", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/cryptoaddress.go", + "target": "pkg/model/cryptoaddress.go", + "blob": "73a63eef658f91e722a0c8849414a2936eb7da84", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/cryptoaddress_test.go", + "target": "pkg/model/cryptoaddress_test.go", + "blob": "71601d1782e1d9a16b35639b9f979126b23d0a0d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/ctoken.go", + "target": "pkg/model/ctoken.go", + "blob": "0b9f239d63d83ce16203e2c76d24546aa2082019", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/ctoken_test.go", + "target": "pkg/model/ctoken_test.go", + "blob": "51f9066c8e532613c75c7d7c7fa71fd61afcbeb9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/currency.go", + "target": "pkg/model/currency.go", + "blob": "8938cecae14171a0b0c1756d279fb6d648868cbb", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/currency_test.go", + "target": "pkg/model/currency_test.go", + "blob": "c490497b254f00d90cd32d9e685d541f6839a9bb", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/describable.go", + "target": "pkg/model/describable.go", + "blob": "1c9b1ce051384b5b8cab4388a6be547bc9b2c1cd", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/fconfig.go", + "target": "pkg/model/fconfig.go", + "blob": "8806c3b3c30f17033a8b1a0228b445c437f7b50d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/iban.go", + "target": "pkg/model/iban.go", + "blob": "6a2550b382fa369765838cfe01f04f8c8635a3d3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/indexable.go", + "target": "pkg/model/indexable.go", + "blob": "7cba1fb5e1b457dfeabae5da7972ae21adcf7dba", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/invitation.go", + "target": "pkg/model/invitation.go", + "blob": "e300325868e4bbf8662352f3c1652e5870e3c429", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/invitation_status.go", + "target": "pkg/model/invitation_status.go", + "blob": "b5799c21c0d5a0801eb3c2dbdb44dc83537af13e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/invitation_test.go", + "target": "pkg/model/invitation_test.go", + "blob": "fd80f2b7094cd6578e6ead214fff22264a8bc6e3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/invitation_validation.go", + "target": "pkg/model/invitation_validation.go", + "blob": "e7b594d602c95c942c207dbedcacdb616a7c69b4", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/ledger.go", + "target": "pkg/model/ledger.go", + "blob": "7738efe09c48393ee363f38c3ba7f02bd8f56c1c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/object.go", + "target": "pkg/model/object.go", + "blob": "32b43c8549e342bad8304a44b01de45932c9e805", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/organization.go", + "target": "pkg/model/organization.go", + "blob": "514fd23e2ce101f89dab7f78a24eb6a27a63611f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/ownerbound.go", + "target": "pkg/model/ownerbound.go", + "blob": "34fdc46f738cd9f89c2588cb66cce4fc4bc4ce4f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/ownerkind.go", + "target": "pkg/model/ownerkind.go", + "blob": "47313d9ec4cf7b1937dafddc598c6b4c71a9073f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/payment.go", + "target": "pkg/model/payment.go", + "blob": "05acab59443febad981d02e258e9fd1b481c12e7", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/payment_ledger.go", + "target": "pkg/model/payment_ledger.go", + "blob": "232af9035327944b723321d304a6cd8ffd9ea20c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/payment_method_json_test.go", + "target": "pkg/model/payment_method_json_test.go", + "blob": "828d23294a2d24fc9fa69bfc48e18e9bd438109e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/permission.go", + "target": "pkg/model/permission.go", + "blob": "2b80f1bb4fec3b0e2e9956ce57f9812cadedde82", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/rba.go", + "target": "pkg/model/rba.go", + "blob": "fca3808c1ae663dd15a824424f9c73405518e250", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/recipient.go", + "target": "pkg/model/recipient.go", + "blob": "99157a525e0fa511f38af31d9c93842422224cf6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/recipient_json_test.go", + "target": "pkg/model/recipient_json_test.go", + "blob": "563ec39a91b23c12a1a72a25f7e01b9b78ca0b00", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/recipient_wire_test.go", + "target": "pkg/model/recipient_wire_test.go", + "blob": "bbcd1355d222d47b4b68e317633a13660e278c66", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/refresh.go", + "target": "pkg/model/refresh.go", + "blob": "43e956be1babedf7857f7697dd3478eb9d05b006", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/scopedstorablemodel.go", + "target": "pkg/model/scopedstorablemodel.go", + "blob": "c96b6dcec5324ce9b1e723c3a212a01d0eb366ff", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/sessionid.go", + "target": "pkg/model/sessionid.go", + "blob": "251eea9f0bb56d67d89d32851e004313d17aae52", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/signup_provisioning.go", + "target": "pkg/model/signup_provisioning.go", + "blob": "6d0c3e0ba4f0f51a31308f8f180f77562bfa850b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/userdata.go", + "target": "pkg/model/userdata.go", + "blob": "8a56bd19ffececc4ede58155263eff8e50721101", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/verificaton.go", + "target": "pkg/model/verificaton.go", + "blob": "4367db5eaaebdb9caea1815dca8d641adff05a42", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/viewcursor.go", + "target": "pkg/model/viewcursor.go", + "blob": "0143c3f99f3131f778fba713c33f796fb4fb9b6e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/wallet.go", + "target": "pkg/model/wallet.go", + "blob": "b8ab22548c62c21ddc70eb5839a6d2ec159160a6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/walletprovisioning.go", + "target": "pkg/model/walletprovisioning.go", + "blob": "dcd5f369090822d038da7c9ccdde96c3314d5a75", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/storable/id.go", + "target": "pkg/db/storable/id.go", + "blob": "58d06374158e2e606aa98fc21860a5077b26b934", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/storable/ref.go", + "target": "pkg/db/storable/ref.go", + "blob": "58e346847d00d8e70f435fc1346a540f76241e83", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/storable/storable.go", + "target": "pkg/db/storable/storable.go", + "blob": "e048582bbc669013eaf02ea57076072a3d4d16e6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/person/name.go", + "target": "pkg/person/name.go", + "blob": "862fbd765dbc5f8b0f21b3c5800b0c33bb94cfa9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/person/name_test.go", + "target": "pkg/person/name_test.go", + "blob": "828b4f7a30312620c5d77641edb1771dfe29334c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/model/account_role/account_role.go", + "target": "pkg/model/account_role/account_role.go", + "blob": "2a6e35e9ef99a90ecf1f74ccadcc049faf41b19e", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/moneybase/fx_rounding.go", + "target": "pkg/moneybase/fx_rounding.go", + "blob": "cf24e19179f7f5a6fb8382ee92064a5294105e29", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/moneybase/fx_rounding_test.go", + "target": "pkg/moneybase/fx_rounding_test.go", + "blob": "424051f72310b48bf77f7848482bc03291f3c0d9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/moneybase/scale.go", + "target": "pkg/moneybase/scale.go", + "blob": "60c218d246fb64f22e5a13cfaf0af336d80285a1", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/decimal/money.go", + "target": "pkg/decimal/money.go", + "blob": "4d781719fa10f743ebe9b2c22afaae6843ada245", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/decimal/rational.go", + "target": "pkg/decimal/rational.go", + "blob": "ebbc45d72f58227702343815fbb7b6a6d50f8510", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/decimal/rounding.go", + "target": "pkg/decimal/rounding.go", + "blob": "65b345d20df1d961a5c37eb4a12b6c060d4dfbcd", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/mzap/account.go", + "target": "pkg/mutil/mzap/account.go", + "blob": "628c21d887583a26b6f804c284b9383213b17627", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/mzap/error.go", + "target": "pkg/mutil/mzap/error.go", + "blob": "e2c47cb94bdbd8bafe87022002cd86168713091c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/mzap/object.go", + "target": "pkg/mutil/mzap/object.go", + "blob": "663574556a14cd592fcaa3be615bed3644f7dea1", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/mask/mask.go", + "target": "pkg/mutil/mask/mask.go", + "blob": "9d3ea7912594b23d51ea0e06262d5a2a7f1ff22e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/mask/mask_test.go", + "target": "pkg/mutil/mask/mask_test.go", + "blob": "6c723a9554c63a719e10c2dc3f1dc1267a698d38", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/product/interface/accountservice/types.go", + "target": "edge/product/interface/accountservice/types.go", + "blob": "848519b9000cda122be8fa652544b33ed2f7fd52", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/config.go", + "target": "pkg/db/config.go", + "blob": "86d46f9f0c78e76e782f134222028fe91d955f53", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/connection.go", + "target": "pkg/db/connection.go", + "blob": "ce8914683aa4cee98e6a0d75c8622329b3500995", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/factory.go", + "target": "pkg/db/factory.go", + "blob": "b9ea3bb4da4fae375d9fabe7c816c7a6b65847bf", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/permissions_config_test.go", + "target": "pkg/db/permissions_config_test.go", + "blob": "00b0dfafa2a1951052e8a41c45a154cdaa8a9b99", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/transaction/context.go", + "target": "pkg/db/transaction/context.go", + "blob": "0b89e5fe74e08ecdd5709128acbe4edd40980fb2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/transaction/context_test.go", + "target": "pkg/db/transaction/context_test.go", + "blob": "5f0c7ed46bd3ccafbf987106814622115fa77570", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/transaction/factory.go", + "target": "pkg/db/transaction/factory.go", + "blob": "d26e313f69414ce7fecb63169450934d0255007a", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/transaction/transaction.go", + "target": "pkg/db/transaction/transaction.go", + "blob": "859977f653db2e0dac676cd12f161acc2d91b72b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/role/role.go", + "target": "pkg/db/role/role.go", + "blob": "30a4e4e1f82ff2601cce8f592ee10b33a1e11804", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/template/interface.go", + "target": "pkg/db/template/interface.go", + "blob": "cbf1657146d27705999f732f0e5fad11678f5c64", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/template/template.go", + "target": "pkg/db/template/template.go", + "blob": "abe24d42447e1759aa373a5ecadd06ccb879d672", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/template/tseries.go", + "target": "pkg/db/template/tseries.go", + "blob": "6a1504731ad18ae2cc4b3487dc9fec4acecea8cd", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/tseries/point/interface.go", + "target": "pkg/db/tseries/point/interface.go", + "blob": "efb9483928a38f8b5599254222d88ccb3c594282", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/accumulator.go", + "target": "pkg/db/repository/builder/accumulator.go", + "blob": "7d2729a40035770a43f7cbe12e07aae93dd7f48d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/alias.go", + "target": "pkg/db/repository/builder/alias.go", + "blob": "ba587bc8b89263624167daf56156d47ba82c21eb", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/array.go", + "target": "pkg/db/repository/builder/array.go", + "blob": "59e78a3b50b35b037442ea488f14bb61608f65e1", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/expression.go", + "target": "pkg/db/repository/builder/expression.go", + "blob": "0c1d284e0236891be603aea6e75bbce857b07883", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/field.go", + "target": "pkg/db/repository/builder/field.go", + "blob": "4d2caaa547588d3e6b55b5a9e3f79e4b6efef84c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/keyword.go", + "target": "pkg/db/repository/builder/keyword.go", + "blob": "64d67b0c826821b7d679a8c76db5db105dd06982", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/operators.go", + "target": "pkg/db/repository/builder/operators.go", + "blob": "c54bbd18c6ebf873b1587254a362f88f86ba92be", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/patch.go", + "target": "pkg/db/repository/builder/patch.go", + "blob": "5da3eeebac492cf46ca2d099617e2898395b9b96", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/pipeline.go", + "target": "pkg/db/repository/builder/pipeline.go", + "blob": "47350e0d61eaecbdfa36c3cac8106b2f50fb7c44", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/projection.go", + "target": "pkg/db/repository/builder/projection.go", + "blob": "466f07a7dbc9d0fd4737df1dc601cdf0807d83ca", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/query.go", + "target": "pkg/db/repository/builder/query.go", + "blob": "1fc397a04e2c1266f4abffb8785140c6ba770be2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/unwind.go", + "target": "pkg/db/repository/builder/unwind.go", + "blob": "9ed2c689ffb378b019706b2a2923cbae048df5b9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builder/value.go", + "target": "pkg/db/repository/builder/value.go", + "blob": "45b011c84f129e508231c314b4935c0f33bf08ad", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/abfilter.go", + "target": "pkg/db/repository/abfilter.go", + "blob": "8e09216ecdfb07aeaae364ba1e6976a3fe48e686", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/builders.go", + "target": "pkg/db/repository/builders.go", + "blob": "ba4190e467397693c5dfce8c991bfbbd2fcf7cd5", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/cursor.go", + "target": "pkg/db/repository/cursor.go", + "blob": "bfcbb351acc317539655053a5ff7861d1c987fdf", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/filter_factory_test.go", + "target": "pkg/db/repository/filter_factory_test.go", + "blob": "9ba32e80407fd44319c08784cc626ea5ff81eb31", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/repository.go", + "target": "pkg/db/repository/repository.go", + "blob": "14c4c10705cc641fe788dd0b10d9e1344c699599", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/index/index.go", + "target": "pkg/db/repository/index/index.go", + "blob": "a689631c9eaa3b5d34198452633efa2c853a5543", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/index/types.go", + "target": "pkg/db/repository/index/types.go", + "blob": "281ec1d8a98e3bd8ba21bff3d0769e3048888993", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/repository/decoder/decoder.go", + "target": "pkg/db/repository/decoder/decoder.go", + "blob": "3a5fb78ead22a12649817946d031a1f092fb9d35", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/index.go", + "target": "pkg/db/internal/mongo/repositoryimp/index.go", + "blob": "ea49d7875b38d296d98012cb801319076254639a", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/index_integration_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/index_integration_test.go", + "blob": "52a6f3d257266124897cdfde952dc9deb84e19f0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/index_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/index_test.go", + "blob": "a84a6a225c1aa9c822936d9885d9141f87a19eba", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository.go", + "blob": "28d3683fc0b9ad9ce2ba8dc04eebad0247c04465", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", + "blob": "1395cc67768367047dcee5b39ac60725f58e546c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_filter.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_filter.go", + "blob": "05b5698e2b28a39e3150b8893dcd687809e6f09f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go", + "blob": "1b4836cdbede198b5f8ca7a6eebd6d8f07b8f635", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_patch_test.go", + "blob": "8a7ae9ba0caf91d866853ba260253a1979868155", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_test.go", + "blob": "89f5cb2c61aabf58a7d0440d62bd1d93067f5204", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go", + "blob": "acffdb9f7913b43bfd2457beb5fd9cfd42b63b03", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go", + "blob": "09ff732bd77561ba180d7af84b1c7b1e306cac40", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/alias.go", + "blob": "325f315782b4988774fb1c54a5efd7d242e26c5a", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/array.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/array.go", + "blob": "738fc9e1bcd3274475a435b602985b1cd384f125", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/expression.go", + "blob": "376a2fb8ba00ff256918652de46f72db6909f18b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/field.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/field.go", + "blob": "3a864b1a4872f7a4abaf76d7b0d98a52a1b4f16b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/func.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/func.go", + "blob": "b69410e9226a9dc540bc06f2b2acafd78c293106", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go", + "blob": "762a5da23a2f5fa2258689358a9a8f61aca6a8db", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/patch.go", + "blob": "d5519688e724745d2c20b70e98cf2e6ecbf94750", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go", + "blob": "ef86937d1c3c65dc0e7d8bd4b02ac5d262e68097", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go", + "blob": "8ab981135123f96df0a537723ca9199d582daa3e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go", + "blob": "25a2989ae5f059426fed2ad90a84abdf614a31c2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/projection.go", + "blob": "e53ec47bcfe487d5d9e8a12c5d8ea21c9d3056d9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/query.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/query.go", + "blob": "5aaa57826cd4af46209ec1bf3190b74ef984bb23", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go", + "blob": "c915baaa5a26d51349c58971ed44dbcca9b040c9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/value.go", + "target": "pkg/db/internal/mongo/repositoryimp/builderimp/value.go", + "blob": "10de4fa645cc76a6e6fdb369177d63bf5f723974", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/policy/policy.go", + "target": "pkg/db/policy/policy.go", + "blob": "30b0eeaaa8cae80408acc81c0d0ff1e499b56766", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/invitation/invitation.go", + "target": "pkg/db/invitation/invitation.go", + "blob": "3097375dd85fdd1a88fa752f07c81e35ddeea3bd", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/archivable.go", + "target": "pkg/auth/archivable.go", + "blob": "e770edc4751021fd49dcd9c80a5f1c15a3cf9324", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/archivableimp.go", + "target": "pkg/auth/archivableimp.go", + "blob": "256da6fe5b7916761f19c586971169f6aed375f0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/authorizer.go", + "target": "pkg/auth/authorizer.go", + "blob": "3594b5aaa3986f89dfe3d38037b12cf7dfa0f2bb", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/authorizer_test.go", + "target": "pkg/auth/authorizer_test.go", + "blob": "66caca0a2e0dfdec3b3bc352e25cb9ba6461bff5", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/auth/config.go", + "target": "pkg/auth/config.go", + "blob": "4ec922ec8517f5b9a28a2837416943d23d89500d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/db.go", + "target": "pkg/auth/db.go", + "blob": "9ccd3fd3da0f52a30796fa0032c209c434f8efd8", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbab.go", + "target": "pkg/auth/dbab.go", + "blob": "b8fa72ab976d768c3195495db52e9a6b0357b772", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimp.go", + "target": "pkg/auth/dbimp.go", + "blob": "83cb6cc2b864882e58a2e39d55b60e06cf406dce", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimpab.go", + "target": "pkg/auth/dbimpab.go", + "blob": "612db1719bcc37f8e55d6fd75e9ddffd08121748", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimpab_test.go", + "target": "pkg/auth/dbimpab_test.go", + "blob": "008b09a1cb83267c3d1df2738daaf51e542ea97e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimpscoped.go", + "target": "pkg/auth/dbimpscoped.go", + "blob": "43886943b4e5f56029f43d17e5a52c39897213f3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimpscoped_test.go", + "target": "pkg/auth/dbimpscoped_test.go", + "blob": "c87042f5020fe466d721442a9b42c910ba6263f6", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/auth/dbimpscopedcrud.go", + "target": "pkg/auth/dbimpscopedcrud.go", + "blob": "ce4e9bddac3108697064b03ab8387be3374c697f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/dbimpscopedpatch.go", + "target": "pkg/auth/dbimpscopedpatch.go", + "blob": "239cf5b6e6c848fd975ade685900d5bde0140e91", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/enforcer.go", + "target": "pkg/auth/enforcer.go", + "blob": "487c3b22396fe8589a09f9e7e069d1da095ea0d8", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/factory.go", + "target": "pkg/auth/factory.go", + "blob": "3e7fb12d677a80ac11a42dbb023905ae3ea072f1", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/helper.go", + "target": "pkg/auth/helper.go", + "blob": "dbdfabb90bf5dd3dea991ca2ff00faa06a7333ba", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/indexable.go", + "target": "pkg/auth/indexable.go", + "blob": "4ba65ec9dddc6d0ccb15a1a8ce8ff8e56cc4d66d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/indexableimp.go", + "target": "pkg/auth/indexableimp.go", + "blob": "eb47986d9c8623933b365b6396144ca3d54b112e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/manager.go", + "target": "pkg/auth/manager.go", + "blob": "0184a198b9bc6a6522851aed5ca742b365c873dc", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/patchguard.go", + "target": "pkg/auth/patchguard.go", + "blob": "5762afa0ed126d4b446f476bb054e4db548008c3", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/provider.go", + "target": "pkg/auth/provider.go", + "blob": "21c3bb5e1d7cb9174d245bbe52097ec15d810343", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/scoped.go", + "target": "pkg/auth/scoped.go", + "blob": "83fe6d0adfaadb4e14e73ec16a76503f3030b6f4", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/scoped_test.go", + "target": "pkg/auth/scoped_test.go", + "blob": "d6b11145b87fbddb987d3df7360a9beebb54d901", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/auth/scopeddb.go", + "target": "pkg/auth/scopeddb.go", + "blob": "ffbd1ea3cc91deabba2a36d9c7a0fe6a1f8aae8c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/scopeddb_test.go", + "target": "pkg/auth/scopeddb_test.go", + "blob": "70e58db28ba4cd1ab1aa17b81d54f2c317613b20", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/auth/management/permission.go", + "target": "pkg/auth/management/permission.go", + "blob": "35ea4729da67a39797772f0604a70ded9ea48b5c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/management/role.go", + "target": "pkg/auth/management/role.go", + "blob": "ea3b902effbbc2aa2c2381453c1d833807645821", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/dbpolicies.go", + "target": "pkg/auth/internal/native/dbpolicies.go", + "blob": "35958c0c109df2bd8d8ab716ca5a84ebcb1ddee5", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/dbroles.go", + "target": "pkg/auth/internal/native/dbroles.go", + "blob": "72b98ab1e2874980a5e169c5a489f79a66818639", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/enforcer.go", + "target": "pkg/auth/internal/native/enforcer.go", + "blob": "b1382b5448c4c5202b17c059f955a1399f567ab4", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/enforcer_test.go", + "target": "pkg/auth/internal/native/enforcer_test.go", + "blob": "d31e40531d1877459a6e3337339c65055e698407", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/manager.go", + "target": "pkg/auth/internal/native/manager.go", + "blob": "58b021c774a72b68d078800986fb54afae5b7c2e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/object_scope_test.go", + "target": "pkg/auth/internal/native/object_scope_test.go", + "blob": "740f09f825ebfc8d9ea1b640e5226f570d248815", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/permission.go", + "target": "pkg/auth/internal/native/permission.go", + "blob": "4d2f04b620a5654fe544953ec0ac856ddf766866", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/role.go", + "target": "pkg/auth/internal/native/role.go", + "blob": "6c155b540c7e5d6617faf1a38373a78f591a5c5f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/nstructures/policies.go", + "target": "pkg/auth/internal/native/nstructures/policies.go", + "blob": "a6791ab0d9e27ffb4dccb8e60793fd63513fcf3e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/nstructures/role.go", + "target": "pkg/auth/internal/native/nstructures/role.go", + "blob": "af3c6c1b86eea99b99247ca97b0bb77c2904851e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/db/policies.go", + "target": "pkg/auth/internal/native/db/policies.go", + "blob": "51f51ec24da544737c8b5078538efa3e0c0eee40", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/native/db/roles.go", + "target": "pkg/auth/internal/native/db/roles.go", + "blob": "b57868046c36e82849b568926445e68bb5770e57", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/db/archive.go", + "target": "pkg/mutil/db/archive.go", + "blob": "d4205a5d03dc534da82b6448339a5b5fdb61e09e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/db/array.go", + "target": "pkg/mutil/db/array.go", + "blob": "b0fb240cb832169a753bd4aa5076ec5c33dd5b26", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/db/db.go", + "target": "pkg/mutil/db/db.go", + "blob": "d29855c92fe4df1abf5e342d5f025c4e6e7434ab", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/enforcer.go", + "target": "pkg/auth/internal/casbin/enforcer.go", + "blob": "b5614b07360e37e580ad5539e8abface9eb0c0ee", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/factory.go", + "target": "pkg/auth/internal/casbin/factory.go", + "blob": "8a1a2da2965c5796d02da5a31e7baa384fd324f0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/logger.go", + "target": "pkg/auth/internal/casbin/logger.go", + "blob": "1a172bdf25514f970e4a3f187d942c193ca73b55", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/manager.go", + "target": "pkg/auth/internal/casbin/manager.go", + "blob": "1499471a58548d3147e385e3e28b16b3e199234b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/permissions.go", + "target": "pkg/auth/internal/casbin/permissions.go", + "blob": "554e702620b99ae2fc90bc67058e0a404703e450", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/role.go", + "target": "pkg/auth/internal/casbin/role.go", + "blob": "359f4ff2b746a90b51dab9aa7972f3b0e805e757", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/serialization/policy.go", + "target": "pkg/auth/internal/casbin/serialization/policy.go", + "blob": "0e1fa29b7307a83ea4eef7888cd375f63de7eebc", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/serialization/role.go", + "target": "pkg/auth/internal/casbin/serialization/role.go", + "blob": "59ea1dc241d99dce612ff9150c54b5530527fc36", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/serialization/serializer.go", + "target": "pkg/auth/internal/casbin/serialization/serializer.go", + "blob": "36d11db558d4ea903f400b0754cfc453cfc2fa7d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/serialization/internal/policy.go", + "target": "pkg/auth/internal/casbin/serialization/internal/policy.go", + "blob": "e119f97631a87bce2230bb45cc3b5f7c15fee762", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/serialization/internal/role.go", + "target": "pkg/auth/internal/casbin/serialization/internal/role.go", + "blob": "796914fdbcdf789c416a06deb9c259ae6d06fa56", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/anyobject/anyobject.go", + "target": "pkg/auth/anyobject/anyobject.go", + "blob": "124da0fed3bc57ed35deae1382ef6364f686b1ca", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter.go", + "target": "pkg/auth/internal/casbin/mongoadapter/adapter.go", + "blob": "0d64409a9a8cbf9470ad917dc805c7717fd5c483", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter_test.go", + "target": "pkg/auth/internal/casbin/mongoadapter/adapter_test.go", + "blob": "36ea81dd413ce6c84370b0f65cce32e9f76062a5", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/auth/internal/casbin/config/config.go", + "target": "pkg/auth/internal/casbin/config/config.go", + "blob": "ce4b097622d26c0f6c4a43e526276c0d61bda153", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/db.go", + "target": "pkg/db/internal/mongo/db.go", + "blob": "ae2659cc356ef53a5a228ec387c516be27b9d2bc", + "adapted_beyond_namespace": true + }, + { + "source": "api/pkg/db/internal/mongo/mongo.go", + "target": "pkg/db/internal/mongo/mongo.go", + "blob": "45915bc5409969720ff7d5e3558ac6f7a70d9758", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/config/param.go", + "target": "pkg/mutil/config/param.go", + "blob": "b2dad64a6b9167e686c04ab0cc28f0173a4fb4a5", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/transactionimp/factory.go", + "target": "pkg/db/internal/mongo/transactionimp/factory.go", + "blob": "6b5c0424c6b8a27c5607d6bfb7d06ac2745dee0d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/transactionimp/transaction.go", + "target": "pkg/db/internal/mongo/transactionimp/transaction.go", + "blob": "266feed66a033d86f8c75785718d424ac10f8db8", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/rolesdb/db.go", + "target": "pkg/db/internal/mongo/rolesdb/db.go", + "blob": "2f5f3848c31add6ff42c6d32c3ce6ec50501c164", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/rolesdb/list.go", + "target": "pkg/db/internal/mongo/rolesdb/list.go", + "blob": "54a993dad72cea6ea3d1b9e81fe017db83e16801", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/rolesdb/roles.go", + "target": "pkg/db/internal/mongo/rolesdb/roles.go", + "blob": "4d070ce586a21251312749bbe5f48337dc9c014f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/policiesdb/all.go", + "target": "pkg/db/internal/mongo/policiesdb/all.go", + "blob": "d944be2159240a7df7055162adb71364024bf18f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/policiesdb/builtin.go", + "target": "pkg/db/internal/mongo/policiesdb/builtin.go", + "blob": "758d423bf3b4ecd330143c6ee18557aaf984877f", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/policiesdb/db.go", + "target": "pkg/db/internal/mongo/policiesdb/db.go", + "blob": "b4c0e9733c80fced9c3403a61d11ca6c8d7f36a6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/policiesdb/db_test.go", + "target": "pkg/db/internal/mongo/policiesdb/db_test.go", + "blob": "5089eec6afd4fa32fbe4e4c7e66bcede8d54d6b2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/policiesdb/policies.go", + "target": "pkg/db/internal/mongo/policiesdb/policies.go", + "blob": "b802cb5d99749e06945e592103808d66e0d9f5fe", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/accept.go", + "target": "pkg/db/internal/mongo/invitationdb/accept.go", + "blob": "8b22999c234e9019c21b4c70512907af2701d747", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/archived.go", + "target": "pkg/db/internal/mongo/invitationdb/archived.go", + "blob": "c899d1aed20cdcfa4421d8ec8093cd267aefe681", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/cascade.go", + "target": "pkg/db/internal/mongo/invitationdb/cascade.go", + "blob": "6ec580bf26da2d725ef544c52e8619277cde90ea", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/db.go", + "target": "pkg/db/internal/mongo/invitationdb/db.go", + "blob": "7f1f4f1735880bba21dd4b6ce35184ec2a6463fe", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/db_integration_test.go", + "target": "pkg/db/internal/mongo/invitationdb/db_integration_test.go", + "blob": "05482982cd2b2f7e66ef145f8791689dc451d1b0", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/db_test.go", + "target": "pkg/db/internal/mongo/invitationdb/db_test.go", + "blob": "c0c2d973796d2873af3366dbeaa0d1cd023b8dcd", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/decline.go", + "target": "pkg/db/internal/mongo/invitationdb/decline.go", + "blob": "0cd0b9896f3bbdc6dbf16cd4500e2d810cfe2381", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/getpublic.go", + "target": "pkg/db/internal/mongo/invitationdb/getpublic.go", + "blob": "d072f157dfa6d6dc70d5e5192058283d1b88c3b9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/indexes.go", + "target": "pkg/db/internal/mongo/invitationdb/indexes.go", + "blob": "014fcfa60822306d86c05b7439caf5fb50fb69be", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/list.go", + "target": "pkg/db/internal/mongo/invitationdb/list.go", + "blob": "4eb61b8ac8258d10cbcc5a0c26cecd3ed677f3e9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/invitationdb/updatestatus.go", + "target": "pkg/db/internal/mongo/invitationdb/updatestatus.go", + "blob": "a55ba4600ca41695987a99d74a87ae19e0df97d6", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/db/auth/accountbound.go", + "target": "pkg/mutil/db/auth/accountbound.go", + "blob": "097eb5a688970b81059a36299562afdb6df44e95", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/db/auth/protected.go", + "target": "pkg/mutil/db/auth/protected.go", + "blob": "d91a8a9a391dd0a8159c4f90fa7e8c27453f9279", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/accountdb/db.go", + "target": "pkg/db/internal/mongo/accountdb/db.go", + "blob": "43c302e80b8b1f931b02d5595a5ccd19bf95543c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/internal/mongo/accountdb/user.go", + "target": "pkg/db/internal/mongo/accountdb/user.go", + "blob": "22d68e5e07e5ac002c661e07409e3f21ba5c475c", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/db/account/account.go", + "target": "pkg/db/account/account.go", + "blob": "22f6191c47730d7e1373e96c8aa691940525fcf3", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/middleware/cors_test.go", + "target": "edge/shared/interface/middleware/cors_test.go", + "blob": "613ab83cd921e3e0a59ea40be3d7e226abdfcec0", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/middleware/login_rate_limit.go", + "target": "edge/shared/interface/middleware/login_rate_limit.go", + "blob": "8b09e5c11e80e2af12341e7084a0a91b616fcbf0", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/middleware/login_rate_limit_test.go", + "target": "edge/shared/interface/middleware/login_rate_limit_test.go", + "blob": "c5991e9e3f0966feca907c49423b7757d41ecc21", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/middleware/middleware.go", + "target": "edge/shared/interface/middleware/middleware.go", + "blob": "be197a8313920db99c2c002417feb063dc3273d4", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/interface/middleware/token_test.go", + "target": "edge/shared/interface/middleware/token_test.go", + "blob": "090f6fe0041c8dc71658bcdf63cacf2791c3bc66", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/ratelimit/config.go", + "target": "edge/shared/interface/ratelimit/config.go", + "blob": "1854a6abeadc2a9ce014e79f124c8cb213e17a65", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/product/interface/accountservice/internal/join_organization_test.go", + "target": "edge/product/interface/accountservice/internal/join_organization_test.go", + "blob": "1a06c563c9cb0d9d056ed7b4450e34cf199c0639", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/product/interface/accountservice/internal/service.go", + "target": "edge/product/interface/accountservice/internal/service.go", + "blob": "e0a26dd3f3d740ad5103e98a56bfa68f1065b1b6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/product/interface/accountservice/internal/service_deletion_test.go", + "target": "edge/product/interface/accountservice/internal/service_deletion_test.go", + "blob": "0100b9aa1c727b213e4d6e20dc2c7ee874c6be46", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/product/interface/accountservice/internal/service_test.go", + "target": "edge/product/interface/accountservice/internal/service_test.go", + "blob": "49dedb715e1861fdf2e5dfdbea870964a2a740b1", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/consume.go", + "target": "edge/shared/storage/verification/consume.go", + "blob": "95b713591d6bc13004b0c83ceeece35bc88893b6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/consume_logging.go", + "target": "edge/shared/storage/verification/consume_logging.go", + "blob": "250b5e3a3850d1d163a45709d424d75eb7f53f3e", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/consume_lookup.go", + "target": "edge/shared/storage/verification/consume_lookup.go", + "blob": "597d2a9cad6deae6537c48b36cd5af813477acc9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/consume_scope.go", + "target": "edge/shared/storage/verification/consume_scope.go", + "blob": "835c3618a0c80617e21146f358057e9f56c34adb", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/consume_update.go", + "target": "edge/shared/storage/verification/consume_update.go", + "blob": "c0c58007b9a2a076918bacf2d88a386bc84713a2", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/context_ref.go", + "target": "edge/shared/storage/verification/context_ref.go", + "blob": "a0cde6224e47fd8261b8a18fcac1ece12df3ecbc", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/context_ref_test.go", + "target": "edge/shared/storage/verification/context_ref_test.go", + "blob": "71fc7eff86bd263b0070303b1feb49415d360a4c", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/create.go", + "target": "edge/shared/storage/verification/create.go", + "blob": "af1b06d68c0123f8ee222fe762e4167fc42da8e7", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/db.go", + "target": "edge/shared/storage/verification/db.go", + "blob": "8e8e89d262d5c934177a15f495446e80690cbed9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/errors.go", + "target": "edge/shared/storage/verification/errors.go", + "blob": "232dcadf98a3af9f80b1d0f6dd9760fd05a0b7f8", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/hash.go", + "target": "edge/shared/storage/verification/hash.go", + "blob": "36fadcf404bfc77608b28cca1e669a4a62f31774", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/hash_test.go", + "target": "edge/shared/storage/verification/hash_test.go", + "blob": "342d56cd5f13ca7716de491a37a9da8851c6b42b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/idempotency.go", + "target": "edge/shared/storage/verification/idempotency.go", + "blob": "0d74b5908dc775dc1d03f9fdfe2ce9a37e25d1f8", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/issuance_limit.go", + "target": "edge/shared/storage/verification/issuance_limit.go", + "blob": "052f1247e52b8c7e329009adda53ee518173e18f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/issuance_limit_test.go", + "target": "edge/shared/storage/verification/issuance_limit_test.go", + "blob": "01e81a5d2c8dd39ab07aeb6722f5bab17bf6ab3b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/memory_token_repository_test.go", + "target": "edge/shared/storage/verification/memory_token_repository_test.go", + "blob": "3a24298b02e90323416f1b47a1012d36b7cd26bc", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/request.go", + "target": "edge/shared/storage/verification/request.go", + "blob": "4d767260e3136a4688bfe011a328967826213da8", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/token_query_matcher_test.go", + "target": "edge/shared/storage/verification/token_query_matcher_test.go", + "blob": "9a5c088919884056e793b3a791e159c7ce4c0927", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/verification.go", + "target": "edge/shared/storage/verification/verification.go", + "blob": "2e4b0e9bb12612b3766bf40c56abdb4f6efe3245", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/verification_attempts_test.go", + "target": "edge/shared/storage/verification/verification_attempts_test.go", + "blob": "eb2cbc58cbe68606dc152a23568fc34ec5084f32", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/verification_idempotency_test.go", + "target": "edge/shared/storage/verification/verification_idempotency_test.go", + "blob": "35e38cc81794dcdd4b4d7ef07efda2d6736080aa", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/verification_test.go", + "target": "edge/shared/storage/verification/verification_test.go", + "blob": "d0eaab49114e1c7c9a3fa0d609cb695566585a22", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/verification/verification_test_helpers_test.go", + "target": "edge/shared/storage/verification/verification_test_helpers_test.go", + "blob": "83f5a63f4f057befd01c83b8e667953ba3199a0f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/ratelimits/db.go", + "target": "edge/shared/storage/ratelimits/db.go", + "blob": "6d5d118198d62f545f0ef10216466c4cbec9b16d", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/archived.go", + "target": "edge/shared/storage/organization/archived.go", + "blob": "f0a947a6bec2f360ccf7162b5e0d7aab58747499", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/cascade.go", + "target": "edge/shared/storage/organization/cascade.go", + "blob": "f735105520c2bc379c887445dda74516a8d4f29f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/create.go", + "target": "edge/shared/storage/organization/create.go", + "blob": "9cf661ded9a9e8187471ac85682fb2c5d5800815", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/db.go", + "target": "edge/shared/storage/organization/db.go", + "blob": "841d914c1741c3a71e189e32ad1dfe2ae8f999eb", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/get.go", + "target": "edge/shared/storage/organization/get.go", + "blob": "54df2ef59f9ffddc0f2917d1d748b1bd608a3e80", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/index_test.go", + "target": "edge/shared/storage/organization/index_test.go", + "blob": "4b42ae41dfc351dba8140657077dd7ca7cbb5394", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/list.go", + "target": "edge/shared/storage/organization/list.go", + "blob": "d50ab508ab749ab55d49cb974a363d3a47148e74", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/member.go", + "target": "edge/shared/storage/organization/member.go", + "blob": "073b5f461d9e8caf1a32c0c677f18b33f399b8fa", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/member_test.go", + "target": "edge/shared/storage/organization/member_test.go", + "blob": "4d82611cdbe96285c67c65e49199f569515d4d51", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/memory_repository_queries_test.go", + "target": "edge/shared/storage/organization/memory_repository_queries_test.go", + "blob": "a05138478d0be4e3f4caca500266f5ca3b34861d", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/memory_repository_test.go", + "target": "edge/shared/storage/organization/memory_repository_test.go", + "blob": "3c2578380f193c61cb2647cb7a76d77205bbd3ce", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/organization.go", + "target": "edge/shared/storage/organization/organization.go", + "blob": "5fd085d8dfa08e388cead3fe6d50d87ace2beb83", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/owned.go", + "target": "edge/shared/storage/organization/owned.go", + "blob": "1d342fb4006fe44924f2a3017c19b10233f23495", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/provider.go", + "target": "edge/shared/storage/organization/provider.go", + "blob": "03e1dbfdeb2714241bea537b8156539a649aef2a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/provisioning.go", + "target": "edge/shared/storage/organization/provisioning.go", + "blob": "c76533cd227c9f64b96eb46c92f4e4bc3c5728b6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/provisioning_test.go", + "target": "edge/shared/storage/organization/provisioning_test.go", + "blob": "ff2dc3a4650da4059785e8abd2ecc43be5d0dc6e", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/setarchived_test.go", + "target": "edge/shared/storage/organization/setarchived_test.go", + "blob": "20933949c004cba73c2b3f4eadf46cfa5cc1458c", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/organization/test_helpers_test.go", + "target": "edge/shared/storage/organization/test_helpers_test.go", + "blob": "d32f33069a11f97bf5d1d782157fb05fc119177d", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/mongo/dependencies.go", + "target": "edge/shared/storage/mongo/dependencies.go", + "blob": "4aa6a89fb359a305ddc74d6e4e9bdcdb50cadb49", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/server/server.go", + "target": "edge/shared/server/server.go", + "blob": "186175bab73d2f2368f45fb5ad16854afec81a68", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/server/server.go", + "target": "edge/shared/internal/server/server.go", + "blob": "e6a780272db88437ce4928cb5cb07610754628ce", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/internal/server/server_test.go", + "target": "edge/shared/internal/server/server_test.go", + "blob": "fa287130eb6ebb0e572a68470baae58411efe0bc", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/api.go", + "target": "edge/shared/interface/api/api.go", + "blob": "7eca87432217088eef1639f47c513c1bd0341cf0", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/interface/api/auth_config.go", + "target": "edge/shared/interface/api/auth_config.go", + "blob": "e155092ea9646e7320e1dc3f66b8ae2bc2081409", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/config.go", + "target": "edge/shared/interface/api/config.go", + "blob": "1ea1e746f6bd9d28494e9b26d9f97bbdda1f000b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/register.go", + "target": "edge/shared/interface/api/register.go", + "blob": "756ca1e6e3c5d5f0f91184a5013cfb2993990851", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/interface/api/runtime.go", + "target": "edge/shared/interface/api/runtime.go", + "blob": "8ab8dc821bcdf1f5d6dfa6183990d30dcbdb63df", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/interface/api/verification_rate_limit.go", + "target": "edge/shared/interface/api/verification_rate_limit.go", + "blob": "c1186157f843fe780f6cb54c6c5cf48b1962164b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/verification_rate_limit_test.go", + "target": "edge/shared/interface/api/verification_rate_limit_test.go", + "blob": "dd85c48ab87e5556f25bb73a0b120f21031dbe4a", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/http/methods.go", + "target": "pkg/api/http/methods.go", + "blob": "ec73a29c5e7f2d3ade83667ed666a9c15fe6d6e5", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/http/status.go", + "target": "pkg/api/http/status.go", + "blob": "76f0b7a6e861e1519e7ee018564e5bfadf4be4f6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/ws/config.go", + "target": "edge/shared/interface/api/ws/config.go", + "blob": "3f8bafa8d31b4c3542e0e9fc5a7f23ab36eecf9b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/ws/handler.go", + "target": "edge/shared/interface/api/ws/handler.go", + "blob": "34979bb556fd75d2b83e999bd6c62dbdbb49958e", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/ws/message.go", + "target": "edge/shared/interface/api/ws/message.go", + "blob": "66544fcdce615e704fa3b51c27b17ec970a34bf9", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/http/response/response.go", + "target": "pkg/api/http/response/response.go", + "blob": "8904d881b718a9ec20d3c4644717118760d8d27e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/http/response/response_test.go", + "target": "pkg/api/http/response/response_test.go", + "blob": "592cbefaeec2bac5bef8d10223e156b6da150a98", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/http/response/result.go", + "target": "pkg/api/http/response/result.go", + "blob": "aa65a480fbf69e3249bb3848f0bc57206c5561bd", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/account.go", + "target": "edge/shared/interface/api/sresponse/account.go", + "blob": "0369f802304ab0b19a57349939ac8a73e924322a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/authresp.go", + "target": "edge/shared/interface/api/sresponse/authresp.go", + "blob": "c148e9be39be6ecfdae127f8d9980360f0114051", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/file.go", + "target": "edge/shared/interface/api/sresponse/file.go", + "blob": "041d7b5d6d56dc059d54bc648b47fe7ba7151f35", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/login.go", + "target": "edge/shared/interface/api/sresponse/login.go", + "blob": "962b9ca0852da034c70b9d4174f497b887267ac8", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/login_pending.go", + "target": "edge/shared/interface/api/sresponse/login_pending.go", + "blob": "5bcd839cb379c44c4a95d897af4a055b6b9375fc", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/login_test.go", + "target": "edge/shared/interface/api/sresponse/login_test.go", + "blob": "419995b9a91ebe8db544c7224be6e945de0c5e55", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/organization.go", + "target": "edge/shared/interface/api/sresponse/organization.go", + "blob": "1d0fa6782ba1f9ccde8e2e86fd48683a28aa6b84", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/permissions.go", + "target": "edge/shared/interface/api/sresponse/permissions.go", + "blob": "5dced1126eeb6c98936ac25bb74b49268afd1ee9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/response.go", + "target": "edge/shared/interface/api/sresponse/response.go", + "blob": "094ec49a7c7ca98f98be42d1946651f666a8287c", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/result.go", + "target": "edge/shared/interface/api/sresponse/result.go", + "blob": "64671a5ba2839e8a193a5c199a988401017b78c3", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/token.go", + "target": "edge/shared/interface/api/sresponse/token.go", + "blob": "3bbf9df40d9b0e9f7771b851e66d3afe3b71fdb7", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/sresponse/verification.go", + "target": "edge/shared/interface/api/sresponse/verification.go", + "blob": "bdd15716ff662433625fde76cb27c342509c4a3f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/model/token.go", + "target": "edge/shared/interface/model/token.go", + "blob": "8f65763eb1924a203417e5cfd85118e039b04502", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/model/token_test.go", + "target": "edge/shared/interface/model/token_test.go", + "blob": "9506c15cf24420eb7ca490a5dac5216e43c4e2a8", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/mutil/duration/duration.go", + "target": "pkg/mutil/duration/duration.go", + "blob": "c27a30425d5988831fcce76a3f13c3647ea0c4e5", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/browser/browser.go", + "target": "edge/shared/auth/browser/browser.go", + "blob": "3af7b54aea277d91e0d8c3eba9ee84c155f4b624", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/auth/browser/browser_test.go", + "target": "edge/shared/auth/browser/browser_test.go", + "blob": "d376b2879803f69acd3d52348d259f5ac3629b1b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/storage/config.go", + "target": "edge/shared/interface/storage/config.go", + "blob": "8d4369b4a69dbc7a146c27ca1b261f1220ad526d", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/domainprovider/domain_provider.go", + "target": "pkg/domainprovider/domain_provider.go", + "blob": "353aac0bf37609cfd0471ce43484ff15baac21f2", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/domainprovider/imp/domain_provider.go", + "target": "pkg/domainprovider/imp/domain_provider.go", + "blob": "20d7353be9aa53c9a372fe912af6dca6e78cd709", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/runtime/api.go", + "target": "edge/shared/internal/runtime/api.go", + "blob": "081cef6ae3887dc2990c5b8d1d10a670893191c6", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/internal/runtime/bootstrap.go", + "target": "edge/shared/internal/runtime/bootstrap.go", + "blob": "38108cc6b2f3257e32473877ce3e66fa4be2370c", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/internal/runtime/lifecycle.go", + "target": "edge/shared/internal/runtime/lifecycle.go", + "blob": "367e277a21360a21df742c31cb1f5a7712754938", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/internal/runtime/services.go", + "target": "edge/shared/internal/runtime/services.go", + "blob": "d3fdec1c394925f4790902fa9c18177b713fb66e", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/routers/health/status.go", + "target": "pkg/api/routers/health/status.go", + "blob": "9768bea4154c61d18712c052730f531c74fc6d53", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/routers/health.go", + "target": "pkg/api/routers/health.go", + "blob": "2c8ab2f2f973572f56a5865e849b4226da6ef86b", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/routers/internal/healthimp/health.go", + "target": "pkg/api/routers/internal/healthimp/health.go", + "blob": "3b641ce70d370c65809262252847cb1d104fc3bc", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/routers/internal/healthimp/health_test.go", + "target": "pkg/api/routers/internal/healthimp/health_test.go", + "blob": "5fe00ffd51338f96bf1fbdd9d18c53996f3822ac", + "adapted_beyond_namespace": false + }, + { + "source": "api/pkg/api/routers/internal/healthimp/status.go", + "target": "pkg/api/routers/internal/healthimp/status.go", + "blob": "af7f1bc54bc3449afdc2a36f61663c9234413f32", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/middleware.go", + "target": "edge/shared/internal/transport/http/middleware.go", + "blob": "3a4c3f910fe3cbeb3bf6566afa8833dd41b6ad0f", + "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/internal/transport/http/middleware_test.go", + "target": "edge/shared/internal/transport/http/middleware_test.go", + "blob": "7359d959b4c29ef95312fb49056351d408fc9957", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/websocket/dispimp.go", + "target": "edge/shared/internal/transport/http/websocket/dispimp.go", + "blob": "1ea2001be84217020e61a8fa827e3fe5cd62ab35", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/websocket/router.go", + "target": "edge/shared/internal/transport/http/websocket/router.go", + "blob": "49cbc75d4c298e5504943123bb9dee48dc169a16", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/metrics/handler.go", + "target": "edge/shared/internal/transport/http/router/metrics/handler.go", + "blob": "fd2e2de1e59c1510ca43b394fa6697497f83b9c9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/metrics/router.go", + "target": "edge/shared/internal/transport/http/router/metrics/router.go", + "blob": "8690f1e8f9b39b3957433dd61e887bbd9bf90ef9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/dispatcher.go", + "target": "edge/shared/internal/transport/http/router/dispatcher.go", + "blob": "f173a53bab40b61dead720e92fe1cd27f3e5b5a5", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/router.go", + "target": "edge/shared/internal/transport/http/router/router.go", + "blob": "d4e68b80e4b10c8aaf3e10e8e6c163f92595c0de", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/client.go", + "target": "edge/shared/storage/refreshtokens/client.go", + "blob": "7289529bdccc68c5678275638e818ebc8a7999e1", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/crud.go", + "target": "edge/shared/storage/refreshtokens/crud.go", + "blob": "5f8044ca8c7cd30d107f2272042e4b7b1cfea291", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/crud_test.go", + "target": "edge/shared/storage/refreshtokens/crud_test.go", + "blob": "385710f18897996b677b0b193c5407f3b78da27d", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/db.go", + "target": "edge/shared/storage/refreshtokens/db.go", + "blob": "2b2ac2504bac1f239e17105b940b375baa17e3f3", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/fields.go", + "target": "edge/shared/storage/refreshtokens/fields.go", + "blob": "ff7ce10d5ed25882a1fc2bf6e964410fa8e4f995", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/filters.go", + "target": "edge/shared/storage/refreshtokens/filters.go", + "blob": "4b51d1c8ee793fdad660d532aad3bccf3c18a167", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens.go", + "blob": "3d335877d4269ef51496f7c139fcc0dd7e7f8dd4", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go", + "blob": "d10a740f65ae64adde26d1438d1c2c17bbeaae0f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens_misc_test.go", + "blob": "12ecda38d22b7143910d57889ea8663d9e731958", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go", + "blob": "270025162d0902052771251f78f20ddb7f152e5e", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens_session_test.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens_session_test.go", + "blob": "15dbe4ab226ffcc5b287bdb6d74cc608360195cc", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/refreshtokens_test.go", + "target": "edge/shared/storage/refreshtokens/refreshtokens_test.go", + "blob": "e6c398c7db0f603a964eef39160e79e5f9fe2bde", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/storage/refreshtokens/revoke.go", + "target": "edge/shared/storage/refreshtokens/revoke.go", + "blob": "39511ee714bcdb9e9885522517735e596c43dd8a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/login.go", + "target": "edge/shared/internal/transport/http/router/public/login.go", + "blob": "92666648e8df7c93fc7ac28a4d0e413f0a370499", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit.go", + "target": "edge/shared/internal/transport/http/router/public/login_rate_limit.go", + "blob": "2122e383a68d56a81aa9ff165dd7661c4bf8068b", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go", + "target": "edge/shared/internal/transport/http/router/public/login_rate_limit_test.go", + "blob": "06d0cf195efa36290c17dd148d6bc2551011664a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/login_test.go", + "target": "edge/shared/internal/transport/http/router/public/login_test.go", + "blob": "0fabfa678c8f7df25c7ae744a99ec680668ca899", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/login_test_support_test.go", + "target": "edge/shared/internal/transport/http/router/public/login_test_support_test.go", + "blob": "7578548953e01a7cf33e265e4202e6c862ad6bb6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/logout.go", + "target": "edge/shared/internal/transport/http/router/public/logout.go", + "blob": "0d6d0f1fa27af82c805c28b11d0dcd90462ba34e", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/logout_test.go", + "target": "edge/shared/internal/transport/http/router/public/logout_test.go", + "blob": "c769afa87c0ffc12213335fe64b98576ff979457", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/refresh.go", + "target": "edge/shared/internal/transport/http/router/public/refresh.go", + "blob": "88f77303ad8386a42967077b8fbb716de325b4b0", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/refresh_test.go", + "target": "edge/shared/internal/transport/http/router/public/refresh_test.go", + "blob": "4f7b9f98a1705414fd1e312629d8ea498c80da64", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/respond.go", + "target": "edge/shared/internal/transport/http/router/public/respond.go", + "blob": "45b8841a9efff51cd8bc6910dcc4f2de8d640d86", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/rotate.go", + "target": "edge/shared/internal/transport/http/router/public/rotate.go", + "blob": "6c052ba5475fce58cbdc0b7827094613d63817a4", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/router.go", + "target": "edge/shared/internal/transport/http/router/public/router.go", + "blob": "6aacfa1fc8b4874011f90bcd730c16db39933a42", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/public/validate.go", + "target": "edge/shared/internal/transport/http/router/public/validate.go", + "blob": "5a5ea587e1a62eb194e68996e841d3a20cf97fce", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/changepolicies.go", + "target": "edge/shared/interface/api/srequest/changepolicies.go", + "blob": "2c6154e801e645134a1aba4b8d742792cba89ac6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/changerole.go", + "target": "edge/shared/interface/api/srequest/changerole.go", + "blob": "ec2b3517c840a05f5a17f45db19d21dbf8c5222f", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/login.go", + "target": "edge/shared/interface/api/srequest/login.go", + "blob": "c1f016d26b2cbacfd4352a8a1a2a8d655a0773e5", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/refresh.go", + "target": "edge/shared/interface/api/srequest/refresh.go", + "blob": "f19edff7cc9dd591d09bfd852e4e6137e86daf30", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/rotate.go", + "target": "edge/shared/interface/api/srequest/rotate.go", + "blob": "6315c790396b7201f0b70748087326e6079921c1", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/validateable.go", + "target": "edge/shared/interface/api/srequest/validateable.go", + "blob": "b4a5c12f33b1d73690a04732b1562480f544e40a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/interface/api/srequest/verification.go", + "target": "edge/shared/interface/api/srequest/verification.go", + "blob": "b3e6cbc77dc6e09feda045477a2f4f750ed37411", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/endpoint/endpoint.go", + "target": "edge/shared/internal/transport/http/router/endpoint/endpoint.go", + "blob": "f0254292b49f7e635b231a6c70098c8dcd8a86d8", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/endpoint/install.go", + "target": "edge/shared/internal/transport/http/router/endpoint/install.go", + "blob": "e7c1c5dd50b393642f593072ad3484d3c220fee9", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/endpoint/token.go", + "target": "edge/shared/internal/transport/http/router/endpoint/token.go", + "blob": "332924534a7b5fca9a142eaaa08f01098c529905", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/tokens/prepare_test.go", + "target": "edge/shared/auth/tokens/prepare_test.go", + "blob": "6e8892c9492c4dbc781fc6fb76e3e5aa27158f7a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/tokens/tokens.go", + "target": "edge/shared/auth/tokens/tokens.go", + "blob": "233d14be95e9560697709d03c8c3ec65f381a0aa", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/http/ipguard/ipguard.go", + "target": "edge/shared/http/ipguard/ipguard.go", + "blob": "6a4221d73346135468bba15bdfe8194e70b6d0be", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/http/ipguard/ipguard_test.go", + "target": "edge/shared/http/ipguard/ipguard_test.go", + "blob": "a2b7f31d4803844eead0a5fb517ef40e994f33c1", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/sessionmetrics/metrics.go", + "target": "edge/shared/auth/sessionmetrics/metrics.go", + "blob": "8b7917de210337c696af4eedc8eaec05597cff96", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/client/client.go", + "target": "edge/shared/auth/client/client.go", + "blob": "4424ce799dda3c4945fbeeaa79cbc21c1d7afefe", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/client/client_test.go", + "target": "edge/shared/auth/client/client_test.go", + "blob": "1a429187940a658e9cc63c830281f973fa93d084", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/authorized/handler.go", + "target": "edge/shared/internal/transport/http/router/authorized/handler.go", + "blob": "bb2fcde2e850dd687e78cad5f4dc95f9b9417ac6", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/authorized/router.go", + "target": "edge/shared/internal/transport/http/router/authorized/router.go", + "blob": "4347dd32db99ae4e46ded03bdff2e64a3a049515", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/internal/transport/http/router/authorized/session_test.go", + "target": "edge/shared/internal/transport/http/router/authorized/session_test.go", + "blob": "e81e9f40101bf43f55a5772215557802e15e9b1a", + "adapted_beyond_namespace": false + }, + { + "source": "api/edge/shared/auth/storage/stores.go", + "target": "edge/shared/auth/storage/stores.go", + "blob": "7c71f8e9a5216b40865388eb4a10900c0e9b4435", + "adapted_beyond_namespace": false + }, + { + "source": "docs/openapi/api.yaml", + "target": "contracts/sendico/api.yaml", + "blob": "c37aeba487b3eef2bedf5829a89fd3b666fe7944" + }, + { + "source": "docs/openapi/api/ledger_accounts/operation.yaml", + "target": "contracts/sendico/api/ledger_accounts/operation.yaml", + "blob": "06fe2c531824155fbb6ccc2a6bd13279c63daf06" + }, + { + "source": "docs/openapi/api/response/operation.yaml", + "target": "contracts/sendico/api/response/operation.yaml", + "blob": "08c5a5f57094642d9093f8ede394d06b08f03852" + }, + { + "source": "docs/openapi/api/response/response.yaml", + "target": "contracts/sendico/api/response/response.yaml", + "blob": "89589e58af97d3c02b21ec6fab61bc692b9c2bc6" + }, + { + "source": "docs/openapi/api/response/error.yaml", + "target": "contracts/sendico/api/response/error.yaml", + "blob": "c4a70141e44ad8040844d5fa35959b718cf99cce" + }, + { + "source": "docs/openapi/api/response/status.yaml", + "target": "contracts/sendico/api/response/status.yaml", + "blob": "1a7e12360568806a679bf2b7c1457d4000dbacc4" + }, + { + "source": "docs/openapi/api/ledger_accounts/response/operation.yaml", + "target": "contracts/sendico/api/ledger_accounts/response/operation.yaml", + "blob": "44929324586f15c6a1af87e9d6776aa406f2613d" + }, + { + "source": "docs/openapi/models/ledger/operation.yaml", + "target": "contracts/sendico/models/ledger/operation.yaml", + "blob": "60cb2bbc8e398268bec3c28619421d88cdbede41" + }, + { + "source": "docs/openapi/models/objectid.yaml", + "target": "contracts/sendico/models/objectid.yaml", + "blob": "01f1c3ddf61d0364983261ba106dc1b22da1125f" + }, + { + "source": "docs/openapi/models/common/money.yaml", + "target": "contracts/sendico/models/common/money.yaml", + "blob": "b130b4c94cc5789c9721092eb17f8bc9d42f99e1" + }, + { + "source": "docs/openapi/models/auth/token_data.yaml", + "target": "contracts/sendico/models/auth/token_data.yaml", + "blob": "5e6264d6ba053516a57298bb88ef1dd90a272b55" + }, + { + "source": "docs/openapi/api/parameters/entry_ref.yaml", + "target": "contracts/sendico/api/parameters/entry_ref.yaml", + "blob": "d035050889fc20d67f812315ad8e0493703d2d38" + }, + { + "source": "docs/openapi/api/parameters/ledger_account_ref.yaml", + "target": "contracts/sendico/api/parameters/ledger_account_ref.yaml", + "blob": "ac2315052ba8c2013860966a50e46c9aa9f81571" + }, + { + "source": "docs/openapi/api/parameters/organizations_ref.yaml", + "target": "contracts/sendico/api/parameters/organizations_ref.yaml", + "blob": "668d32230b1faf86826a3f46675c6e809781fd2f" + }, + { + "source": "docs/openapi/api/ledger_accounts/operations.yaml", + "target": "contracts/sendico/api/ledger_accounts/operations.yaml", + "blob": "2910a7ba27a90f23d613934163170f13a7a4dc28" + }, + { + "source": "docs/openapi/api/parameters/offset.yaml", + "target": "contracts/sendico/api/parameters/offset.yaml", + "blob": "03b8b7cbf07b533f578f62e5feba678c1fb47658" + }, + { + "source": "docs/openapi/api/parameters/limit.yaml", + "target": "contracts/sendico/api/parameters/limit.yaml", + "blob": "71fa974fe385c691c0ac7113f4bf2dd3f959046b" + }, + { + "source": "docs/openapi/api/ledger_accounts/balance.yaml", + "target": "contracts/sendico/api/ledger_accounts/balance.yaml", + "blob": "75156517b30344caf969e90b830cb886812c0bd3" + }, + { + "source": "docs/openapi/api/ledger_accounts/response/balance.yaml", + "target": "contracts/sendico/api/ledger_accounts/response/balance.yaml", + "blob": "254864b08634dbd503823682cc9df51c89a53a0d" + }, + { + "source": "docs/openapi/models/ledger/balance.yaml", + "target": "contracts/sendico/models/ledger/balance.yaml", + "blob": "df4423b667c6c3e053a781ebbeaaf803b179d3e9" + }, + { + "source": "docs/openapi/api/wallets/assets.yaml", + "target": "contracts/sendico/api/wallets/assets.yaml", + "blob": "17c8b22aae5226042eca93f9d0a4a7ff25859fec" + }, + { + "source": "docs/openapi/api/wallets/response/wallet.yaml", + "target": "contracts/sendico/api/wallets/response/wallet.yaml", + "blob": "b390b85084e3129714e10c37db9e5db6ddd41222" + }, + { + "source": "docs/openapi/models/wallet/wallet_asset.yaml", + "target": "contracts/sendico/models/wallet/wallet_asset.yaml", + "blob": "6d1a1a6686ea06d52717a7d9551a358a12eb4a89" + }, + { + "source": "docs/openapi/external/chain_network.yaml", + "target": "contracts/sendico/external/chain_network.yaml", + "blob": "c5770115083e47b26e2119f0ac301dcf88e40be2" + }, + { + "source": "docs/openapi/api/payments/fx_directions.yaml", + "target": "contracts/sendico/api/payments/fx_directions.yaml", + "blob": "264f35dc443b35f28587892a673519119c580ea3" + }, + { + "source": "docs/openapi/api/payments/response/payment.yaml", + "target": "contracts/sendico/api/payments/response/payment.yaml", + "blob": "d4c2938f87dc8ce39a8575a05f7912242679faee" + }, + { + "source": "docs/openapi/models/payment/fx_metadata.yaml", + "target": "contracts/sendico/models/payment/fx_metadata.yaml", + "blob": "857a2a1d84f41534c563d9090998ffe00b9a5c68" + }, + { + "source": "docs/openapi/external/fx_side.yaml", + "target": "contracts/sendico/external/fx_side.yaml", + "blob": "639e6f28c673cdee0f2e6405bf6304603a33d23a" + }, + { + "source": "docs/openapi/models/payment/payment.yaml", + "target": "contracts/sendico/models/payment/payment.yaml", + "blob": "e970feb77567efb87e18b20bfc4607b128032287" + }, + { + "source": "docs/openapi/external/payment_state.yaml", + "target": "contracts/sendico/external/payment_state.yaml", + "blob": "9685cd8ecb25a0ac700d0baa009a4ce427063957" + }, + { + "source": "docs/openapi/external/settlement_mode.yaml", + "target": "contracts/sendico/external/settlement_mode.yaml", + "blob": "e5531c92b60b923ada5893670a20a0fc3c98038a" + }, + { + "source": "docs/openapi/external/payment_kind.yaml", + "target": "contracts/sendico/external/payment_kind.yaml", + "blob": "06f68ef2f705b59d01dc6cfee55039a550693d9f" + }, + { + "source": "docs/openapi/external/endpoint_type.yaml", + "target": "contracts/sendico/external/endpoint_type.yaml", + "blob": "3a23e427a02288ad1cde4ec94e150b36bb3bb32b" + }, + { + "source": "docs/openapi/models/common/pagination.yaml", + "target": "contracts/sendico/models/common/pagination.yaml", + "blob": "2a923c3317d227c27c337635c93edc2818b0b8df" + }, + { + "source": "docs/openapi/api/payments/fx_currencies.yaml", + "target": "contracts/sendico/api/payments/fx_currencies.yaml", + "blob": "f2ecf5a89513a81458fb658295d9dc2fd7a9bf05" + }, + { + "source": "docs/openapi/api/payments/registry_refresh.yaml", + "target": "contracts/sendico/api/payments/registry_refresh.yaml", + "blob": "4719af7000f06f8012ce3ded03026b8d6d7391d2" + }, + { + "source": "docs/openapi/api/payments/registry.yaml", + "target": "contracts/sendico/api/payments/registry.yaml", + "blob": "42d796d7dea7482f1739302724292e3c7f6d3c5c" + }, + { + "source": "docs/openapi/api/payments/documents_certificate.yaml", + "target": "contracts/sendico/api/payments/documents_certificate.yaml", + "blob": "e7a33787537ef0762eb33f8e9d6065d93ce5dc93" + }, + { + "source": "docs/openapi/api/payments/list.yaml", + "target": "contracts/sendico/api/payments/list.yaml", + "blob": "e01a5c9170b14478cb8cfde8547ddc893646b425" + }, + { + "source": "docs/openapi/api/parameters/filter_states.yaml", + "target": "contracts/sendico/api/parameters/filter_states.yaml", + "blob": "1c1d34fd3ca5de628935c57a3c0db98a356ee07a" + }, + { + "source": "docs/openapi/api/parameters/states.yaml", + "target": "contracts/sendico/api/parameters/states.yaml", + "blob": "96d672d3e56877b8d3f107daaa181f48a792da25" + }, + { + "source": "docs/openapi/api/parameters/state.yaml", + "target": "contracts/sendico/api/parameters/state.yaml", + "blob": "cb05776de5b2325066e3751c9ed3b6ad94984251" + }, + { + "source": "docs/openapi/api/parameters/cursor.yaml", + "target": "contracts/sendico/api/parameters/cursor.yaml", + "blob": "15252a1a96874d58a5fe25eac85dfe537bcf31ec" + }, + { + "source": "docs/openapi/api/payments/object.yaml", + "target": "contracts/sendico/api/payments/object.yaml", + "blob": "0b717523ee8cfa4f4ed9ef77b58ba8c2f4b75f07" + }, + { + "source": "docs/openapi/api/parameters/payments_ref.yaml", + "target": "contracts/sendico/api/parameters/payments_ref.yaml", + "blob": "28a015bff27618e53d0dfb90bcaccd79337fcce8" + }, + { + "source": "docs/openapi/api/payments/by_multiquote.yaml", + "target": "contracts/sendico/api/payments/by_multiquote.yaml", + "blob": "2c41a746d935a191eda71c597b15c633a4ea3c76" + }, + { + "source": "docs/openapi/api/payments/bodies/payment.yaml", + "target": "contracts/sendico/api/payments/bodies/payment.yaml", + "blob": "4dc0349c2c62a140b575245289bd318b9755fcd8" + }, + { + "source": "docs/openapi/api/payments/request/payment.yaml", + "target": "contracts/sendico/api/payments/request/payment.yaml", + "blob": "3c425e3b458975922c1b0efa421c6ba77fdfa75d" + }, + { + "source": "docs/openapi/api/payments/by_quote.yaml", + "target": "contracts/sendico/api/payments/by_quote.yaml", + "blob": "703a98895e13fa79066326e4ebba06bb054711ec" + }, + { + "source": "docs/openapi/api/payments/immediate.yaml", + "target": "contracts/sendico/api/payments/immediate.yaml", + "blob": "2a7b885724484cb99cef21a7ca588392b045d244" + }, + { + "source": "docs/openapi/api/payments/multiquote.yaml", + "target": "contracts/sendico/api/payments/multiquote.yaml", + "blob": "3ac6728ac3d3d2c8ca38cc255eb5f26670af23f5" + }, + { + "source": "docs/openapi/api/payments/quote.yaml", + "target": "contracts/sendico/api/payments/quote.yaml", + "blob": "d40955416dfcf1b06d0277a6edaa31520cad3548" + }, + { + "source": "docs/openapi/api/callbacks/rotate_secret.yaml", + "target": "contracts/sendico/api/callbacks/rotate_secret.yaml", + "blob": "6296d25d6e621c522b5160ca88bd6de599f8da98" + }, + { + "source": "docs/openapi/api/callbacks/response/callback.yaml", + "target": "contracts/sendico/api/callbacks/response/callback.yaml", + "blob": "02e262ba392578a54190f9b6b4195d5af0513a85" + }, + { + "source": "docs/openapi/models/callback/callback.yaml", + "target": "contracts/sendico/models/callback/callback.yaml", + "blob": "f555a058f230548d1b7db7f518ae76fba86e580d" + }, + { + "source": "docs/openapi/models/common/describable.yaml", + "target": "contracts/sendico/models/common/describable.yaml", + "blob": "1bf6d5b7a7122e8253175e686c06893c2296598a" + }, + { + "source": "docs/openapi/models/permission_bound.yaml", + "target": "contracts/sendico/models/permission_bound.yaml", + "blob": "c574a13346031788c0b4f05a242f48ada59636a7" + }, + { + "source": "docs/openapi/models/storable.yaml", + "target": "contracts/sendico/models/storable.yaml", + "blob": "325b291481fea9a4c4e3f7c2b39a24156881fc99" + }, + { + "source": "docs/openapi/api/parameters/callbacks_ref.yaml", + "target": "contracts/sendico/api/parameters/callbacks_ref.yaml", + "blob": "18ba8143d1ac91846c3378d51eab5fa3dd703027", + "adaptation": "Trailing blank lines normalized; schema unchanged." + }, + { + "source": "docs/openapi/api/callbacks/archive.yaml", + "target": "contracts/sendico/api/callbacks/archive.yaml", + "blob": "38d8b53ef33cf61aee5b4cd83c6e6910d98ba6dd", + "adaptation": "Trailing blank lines normalized; schema unchanged." + }, + { + "source": "docs/openapi/api/parameters/cascade.yaml", + "target": "contracts/sendico/api/parameters/cascade.yaml", + "blob": "4077d851c091da108603209a8427a836c3617ad6" + }, + { + "source": "docs/openapi/api/parameters/org_ref.yaml", + "target": "contracts/sendico/api/parameters/org_ref.yaml", + "blob": "3ef0c64a6e6f959c0a1bad51317531b1a0595b26" + }, + { + "source": "docs/openapi/api/callbacks/update.yaml", + "target": "contracts/sendico/api/callbacks/update.yaml", + "blob": "e3f99320074f081c26ab930a077319ae3d47e160" + }, + { + "source": "docs/openapi/api/callbacks/bodies/callback.yaml", + "target": "contracts/sendico/api/callbacks/bodies/callback.yaml", + "blob": "850efd327b75c821ab07894cb545658b3bbd60af" + }, + { + "source": "docs/openapi/api/callbacks/request/callback.yaml", + "target": "contracts/sendico/api/callbacks/request/callback.yaml", + "blob": "a0535c32c7ded53d36c4e57cfe45e4d560d9176e" + }, + { + "source": "docs/openapi/api/callbacks/object.yaml", + "target": "contracts/sendico/api/callbacks/object.yaml", + "blob": "914b00db31432a82e57e512554882cc21647aa28", + "adaptation": "Trailing blank lines normalized; schema unchanged." + }, + { + "source": "docs/openapi/api/callbacks/create.yaml", + "target": "contracts/sendico/api/callbacks/create.yaml", + "blob": "92c5c32552ea7471a3dd19e38be1a496644ab33f" + }, + { + "source": "docs/openapi/api/callbacks/list.yaml", + "target": "contracts/sendico/api/callbacks/list.yaml", + "blob": "0072f453ea152f275f7c84ce88989206b93480ca" + }, + { + "source": "docs/openapi/api/parameters/archived.yaml", + "target": "contracts/sendico/api/parameters/archived.yaml", + "blob": "fb8af8ca3f0ed5fbdf0df29a67c8d4e95e9ea2f4" + }, + { + "source": "docs/openapi/api/payment_methods/archive.yaml", + "target": "contracts/sendico/api/payment_methods/archive.yaml", + "blob": "55772206b43a4f653dab9184ef295c5d62542747" + }, + { + "source": "docs/openapi/api/payment_methods/response/payment_method.yaml", + "target": "contracts/sendico/api/payment_methods/response/payment_method.yaml", + "blob": "40cb7aaa4a8b76d91e3e50b78f448ab8edae95fc" + }, + { + "source": "docs/openapi/models/payment_method/payment_method.yaml", + "target": "contracts/sendico/models/payment_method/payment_method.yaml", + "blob": "7b475f009573c91d23c2eb1c052f0c598d2b83fb" + }, + { + "source": "docs/openapi/external/payment_method_type.yaml", + "target": "contracts/sendico/external/payment_method_type.yaml", + "blob": "c8c4e84b005b24df1e83f3f985e041c8f88b9b70" + }, + { + "source": "docs/openapi/api/parameters/payment_methods_ref.yaml", + "target": "contracts/sendico/api/parameters/payment_methods_ref.yaml", + "blob": "6690d53707229be9a3c94adcd924038901a8364c" + }, + { + "source": "docs/openapi/api/payment_methods/object.yaml", + "target": "contracts/sendico/api/payment_methods/object.yaml", + "blob": "a183d571cb4da960a5959429a7c95104d0cbf650" + }, + { + "source": "docs/openapi/api/payment_methods/bodies/payment_method.yaml", + "target": "contracts/sendico/api/payment_methods/bodies/payment_method.yaml", + "blob": "56d488e05949965ffaf5a4d3b4d7c615da683b63" + }, + { + "source": "docs/openapi/api/payment_methods/request/payment_method.yaml", + "target": "contracts/sendico/api/payment_methods/request/payment_method.yaml", + "blob": "6f768e7f08cbed79e86b1dd3f750080ca65dfb8c" + }, + { + "source": "docs/openapi/api/payment_methods/recipients.yaml", + "target": "contracts/sendico/api/payment_methods/recipients.yaml", + "blob": "414ab3fb957e4a3e314331d61692d2e89fcd656b" + }, + { + "source": "docs/openapi/api/parameters/recipients_ref.yaml", + "target": "contracts/sendico/api/parameters/recipients_ref.yaml", + "blob": "945bca14a5caaf09b768ea3688588ec899772d19" + }, + { + "source": "docs/openapi/api/payment_methods/accounts.yaml", + "target": "contracts/sendico/api/payment_methods/accounts.yaml", + "blob": "9c7fc85d69cda0e8956155396dc8b2669588a3c0" + }, + { + "source": "docs/openapi/api/parameters/accounts_ref.yaml", + "target": "contracts/sendico/api/parameters/accounts_ref.yaml", + "blob": "a72a17d1144b1095947280a87e53c5a1b0c03788" + }, + { + "source": "docs/openapi/api/recipients/archive.yaml", + "target": "contracts/sendico/api/recipients/archive.yaml", + "blob": "4d3cb3f37639ea2a9dad5fa3c63b509fcea4edef" + }, + { + "source": "docs/openapi/api/recipients/response/recipient.yaml", + "target": "contracts/sendico/api/recipients/response/recipient.yaml", + "blob": "337b850bd9b2cdd4525326cc6ba3df8309b1fef6" + }, + { + "source": "docs/openapi/models/recipient/recipient.yaml", + "target": "contracts/sendico/models/recipient/recipient.yaml", + "blob": "5ab3d56462523f98b69230836c744437d347dccd" + }, + { + "source": "docs/openapi/api/recipients/update.yaml", + "target": "contracts/sendico/api/recipients/update.yaml", + "blob": "f814f5b2fd485b8d12b6a789780a15183a13fc78" + }, + { + "source": "docs/openapi/api/recipients/bodies/recipient.yaml", + "target": "contracts/sendico/api/recipients/bodies/recipient.yaml", + "blob": "5785002594afd5d7231a268fb4c294016c41492d" + }, + { + "source": "docs/openapi/api/recipients/request/recipient.yaml", + "target": "contracts/sendico/api/recipients/request/recipient.yaml", + "blob": "19656c44ff18c69551275b8c26722b1f2b34a181" + }, + { + "source": "docs/openapi/api/recipients/object.yaml", + "target": "contracts/sendico/api/recipients/object.yaml", + "blob": "091e5c7c5ebec4a29f7bd5e29378750c16d8e834" + }, + { + "source": "docs/openapi/api/recipients/create.yaml", + "target": "contracts/sendico/api/recipients/create.yaml", + "blob": "10fc1ba66b2c7617932e7f25287a34bf576b5694" + }, + { + "source": "docs/openapi/api/recipients/list.yaml", + "target": "contracts/sendico/api/recipients/list.yaml", + "blob": "33ca97f1a2ddd0b392f33a48eb2972eed1e0161d" + }, + { + "source": "docs/openapi/api/organizations/get.yaml", + "target": "contracts/sendico/api/organizations/get.yaml", + "blob": "c93ed7a4d5fecdfe72ebb7cefc2f38aae7f24bf0" + }, + { + "source": "docs/openapi/api/organizations/response/organization.yaml", + "target": "contracts/sendico/api/organizations/response/organization.yaml", + "blob": "56fc0f76a363e6ecf08413ff955f63798b736a84" + }, + { + "source": "docs/openapi/models/organization/organization.yaml", + "target": "contracts/sendico/models/organization/organization.yaml", + "blob": "8b6dec1dba78c16614211b21d574121db48713aa" + }, + { + "source": "docs/openapi/api/organizations/root.yaml", + "target": "contracts/sendico/api/organizations/root.yaml", + "blob": "7a00d19c7b2500c4199d17638b817097a0c4255f" + }, + { + "source": "docs/openapi/api/verification/verify.yaml", + "target": "contracts/sendico/api/verification/verify.yaml", + "blob": "cf26d7b3b26aa5b1dcbe9606cdb7dd1fb49bc94f" + }, + { + "source": "docs/openapi/api/verification/response/verification.yaml", + "target": "contracts/sendico/api/verification/response/verification.yaml", + "blob": "f169ab7b8f843e02e64c07fd3b4bf3f4916fd270" + }, + { + "source": "docs/openapi/api/accounts/response/auth.yaml", + "target": "contracts/sendico/api/accounts/response/auth.yaml", + "blob": "dd3265423b5854b908d17cef482c8d12fab5c958" + }, + { + "source": "docs/openapi/models/account/account.yaml", + "target": "contracts/sendico/models/account/account.yaml", + "blob": "b47ae25d410359cbfb7080560715e8e7389ecbdd" + }, + { + "source": "docs/openapi/models/account/account_public.yaml", + "target": "contracts/sendico/models/account/account_public.yaml", + "blob": "3f807896b73e06d23149ca7bd6d63cada27a042c" + }, + { + "source": "docs/openapi/api/verification/bodies/verification.yaml", + "target": "contracts/sendico/api/verification/bodies/verification.yaml", + "blob": "da9dbd6b43df2daccca60506889f7273bf1a0d99" + }, + { + "source": "docs/openapi/api/verification/request/verification.yaml", + "target": "contracts/sendico/api/verification/request/verification.yaml", + "blob": "a0050984df5818d36da6a4f8c307d9ba6bc915a4" + }, + { + "source": "docs/openapi/models/auth/session_identifier.yaml", + "target": "contracts/sendico/models/auth/session_identifier.yaml", + "blob": "fba55b0e4efe7f9960e97c4fe42cdf29ec3182eb" + }, + { + "source": "docs/openapi/api/verification/resend.yaml", + "target": "contracts/sendico/api/verification/resend.yaml", + "blob": "d2ee0fc195ecdc07196d4c7d777037c7cd42101c" + }, + { + "source": "docs/openapi/api/verification/create.yaml", + "target": "contracts/sendico/api/verification/create.yaml", + "blob": "9dd4e9790c5ca73937a8b7fb543f0c5682484ef6" + }, + { + "source": "docs/openapi/api/accounts/auth_refresh.yaml", + "target": "contracts/sendico/api/accounts/auth_refresh.yaml", + "blob": "2822daaa7b00efd4f688fc1eb21fd3279e386b32" + }, + { + "source": "docs/openapi/api/accounts/bodies/auth.yaml", + "target": "contracts/sendico/api/accounts/bodies/auth.yaml", + "blob": "f7a723d2c8142f9fe7b176c562b33648f2b3654f" + }, + { + "source": "docs/openapi/api/accounts/request/auth.yaml", + "target": "contracts/sendico/api/accounts/request/auth.yaml", + "blob": "b000fd83ab37af6ceb7c5ebe252fc61733ef17d2" + }, + { + "source": "docs/openapi/models/auth/client_refresh_token.yaml", + "target": "contracts/sendico/models/auth/client_refresh_token.yaml", + "blob": "43408e367e8139fb49416038f4de00f9146b108e" + }, + { + "source": "docs/openapi/models/auth/login_data.yaml", + "target": "contracts/sendico/models/auth/login_data.yaml", + "blob": "480b3d202f0d2cf437e83f4210ff21cd42ffbcdd" + }, + { + "source": "docs/openapi/api/accounts/auth_rotate.yaml", + "target": "contracts/sendico/api/accounts/auth_rotate.yaml", + "blob": "9515e4dd178a115b09fe92f9fe28ca1e39b708d6" + }, + { + "source": "docs/openapi/api/accounts/auth_login_api.yaml", + "target": "contracts/sendico/api/accounts/auth_login_api.yaml", + "blob": "3bb72b2e1d257dcc15db1d9f9986ea0c5617d1b8" + }, + { + "source": "docs/openapi/api/accounts/auth_login.yaml", + "target": "contracts/sendico/api/accounts/auth_login.yaml", + "blob": "ede859161d26767acc3e865c040343301e7e6f55" + }, + { + "source": "api/edge/product/main.go", + "target": "cmd/bff/main.go", + "blob": "4c9f6dfd4436bf75fac15690decdac0efaa5b9c2", + "adapted_beyond_namespace": true + }, + { + "source": "scripts/mongosh/product-permissions-seed.js", + "target": "scripts/seed-policies.js", + "blob": "52597d928f0154ba9eb1cfc6975d6c44cac104a9", + "adaptation": "SXCHNGE database; retain organization/account/role policy definitions and upsert helpers; omit unrelated policies, backfills and pruning." + } + ], + "excluded": [ + "Product platform service factories and organization provisioning", + "Private gRPC/protobuf clients and servers", + "Discovery and messaging bootstrap, interfaces and logging helpers", + "Unused model files and private protobuf conversion helpers", + "Recipient migration test and migration: no recipient migration was copied", + "Generated artifacts, local credentials/configuration and unrelated source checkout changes" + ] +} diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md index 6a7f17c..8863d29 100644 --- a/backend/PROVENANCE.md +++ b/backend/PROVENANCE.md @@ -1,38 +1,64 @@ -# Sendico reuse provenance +# Copied Sendico source -Source repository: `S_I/sendico`; revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. -Inspected 2026-09-12. These are owned forks inside SXCHNGE, not runtime imports. -Source files below match the committed revision; local dependency edits were not used. -No generated Sendico SDK, shared database models, deployment configuration or credentials were copied. +Source repository: `S_I/sendico` (`git.sendico.io`). Revision: +`8bb20dcd622dd8eb92d289b6b34411e7eccab083`. -## `internal/browser/policy.go` +[PROVENANCE.json](PROVENANCE.json) records each copied file, original path and +Git blob, plus whether a Go file changed beyond import namespace and formatting. +It contains 416 Go source/test files, 112 public OpenAPI files and one adapted policy seed. Sources were +read from the pinned commit, not Sendico's dirty working tree. These are owned +SXCHNGE copies, not submodules or automatic synchronization links. -- Source: [api/edge/shared/auth/browser/browser.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go) -- Source Git blob: `3af7b54aea277d91e0d8c3eba9ee84c155f4b624` -- Adaptation: Adapted cookie creation/clearing, origin validation and CSRF checks. Replaced Sendico names/errors/env path resolution with explicit SXCHNGE policy; checks do not depend on a client-controlled transport header. Host-only Secure/HttpOnly cookie, Lax same-site navigation, duplicate-cookie rejection and explicit expiry validation. -- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. +## What was retained -## `internal/credential/token.go` +- Shared BFF Chi runtime, process/version/logger bootstrap, HTTP middleware, + auth transport and response conventions. +- Password/account service, local Mongo account/role/policy repositories, + transactions and local organization membership. +- Verification issuance, expiry, attempt limits, consumption and idempotency; + refresh-session rotation/revocation and login rate-limit storage. +- Canonical models, `merrors`, structured logging, health and retained tests. +- The public OpenAPI 4.2.37 entry point and recursive local schema references, + copied into `contracts/sendico` without schema changes (trailing blank lines + normalized in three YAML files). -- Source: [api/edge/shared/storage/verification/hash.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go) -- Source Git blob: `36fadcf404bfc77608b28cca1e669a4a62f31774` -- Adaptation: Adapted generateMagic/tokenHash only: 32-byte crypto/rand, URL-safe encoding, SHA-256 digest. Added constant-time matching. Excluded deterministic token generation, Sendico models and all numeric OTP hashing. -- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. +## Deliberate adaptations -## `internal/evidence/key.go` +- Import roots now belong to `git.sendico.io/tech/sxchnge/bff`. Versions are seeded + from committed Sendico manifests and tidied for the selected dependency set. +- Product's composition root selects local account services instead of platform + services/provisioning. Its shared HTTP runtime remains the implementation. +- Removed internal discovery and broker configuration, registration, startup and + teardown. An instance ID uses the existing UUID dependency without discovery. +- Excluded internal gRPC/protobuf clients/converters and unused model files. + Local account-role values remain; private protobuf conversion helpers do not. +- Program identity and the refresh-cookie name use SXCHNGE. +- The policy seed retains the existing upsert helpers and three local + organization/account/role definitions, with an SXCHNGE database default. + Platform policies, automatic role backfills and pruning are excluded. +- Startup configuration now rejects missing database/middleware/HTTP settings + and invalid HTTP timeouts. Runtime-construction failure closes the database. + Listen/start failures preserve the cause and produce a nonzero CLI exit. +- Copied Casbin adapter errors and test sentinel errors use canonical `merrors` + instead of `errors.New`. Removed raw database settings from error logs. +- The recipient-migration-only test was excluded together with its migration: + neither that platform feature nor historical Sendico data migration is part of + this bootstrap. Tests for every retained runtime/component remain included. -- Source: [api/edge/shared/storage/s3key/key.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go) -- Source Git blob: `e3cca16304d2c4cb9f256f510d5a1a30a21e22a6` -- Adaptation: Adapted relative object-key confinement. Replaced merrors with a local sentinel and rejected encoded sequences, backslashes and embedded control characters. Not a filesystem or customer-authorization utility. -- Tests are SXCHNGE-specific behavioral checks for the adapted boundary. +No financial engine, internal service client, gateway, platform organization +provisioning, deployment configuration, credential or generated binary was copied. +The local account service's organization is an SXCHNGE authorization tenant; +it does not create Sendico organizations per customer. -## Maintenance +## Local additions -The command, operational HTTP shell, module configuration and tests are new SXCHNGE code. -There is no automatic synchronization or promise of source compatibility with Sendico. -For an upstream fix, review the pinned source difference, port only the relevant behavior, -record a new provenance entry and run backend tests/vet. Provider contract changes belong -in the future Sendico adapter and its transport fixtures, not application models or widgets. +`internal/profile` composes the copied account service. Architecture checks enforce +project errors, no private provider imports/transports and self-contained public +contract references. Configuration tests cover the executable example and missing +settings. Guidelines and the backend-review skill are restored from Sendico with +SXCHNGE paths and the public-only integration boundary. -These primitives do not create sessions, deliver email, validate TOTP or upload documents. -Use them inside the durable flows described in the [BFF plan](../docs/architecture/bff.md). +The source includes complete local auth building blocks, not a fully wired +SXCHNGE product API. Remaining endpoint, email, local tenant/client provisioning +and public Sendico integration work is explicit in the +[architecture plan](../docs/architecture/bff.md). diff --git a/backend/README.md b/backend/README.md index 5f44a9c..e442c9a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,38 +1,46 @@ # SXCHNGE BFF -Independent Go module with an operational HTTP shell and selected, adapted -Sendico foundations. Sendico remains an external financial-services provider; -there are no Sendico module imports, shared databases or sibling-path dependencies. -See the [architecture and service plan](../docs/architecture/bff.md) and -[reuse provenance](PROVENANCE.md). +A locally owned copy of Sendico's reusable BFF runtime, authentication/session +stack and account service. See [provenance](PROVENANCE.md) and the +[architecture and public API plan](../docs/architecture/bff.md). -## Run and validate +SXCHNGE owns its customers and authentication. SXCHNGE itself is one organization +in Sendico. Provider integration uses only Sendico's public HTTP API; the pinned +[OpenAPI snapshot](contracts/sendico/api.yaml) is isolated from local models. -Requires Go 1.26 or later. From the repository root: +## Run locally + +Requires Go 1.27 and an SXCHNGE-owned MongoDB replica set for transactions. +Copy `config.example.yml` to an untracked `config.yml`. Set: + +- `SXCHNGE_MONGO_URI`: URI for the local SXCHNGE database. +- `SXCHNGE_JWT_SECRET`: private random signing key, at least 32 bytes for HS256. +- `SXCHNGE_DOMAIN`: local API hostname (for example `localhost:8082`). +- `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment. +- `SXCHNGE_API_ENDPOINT`: `/api/v1`. + +Seed the local policy descriptions before first startup (requires `mongosh`): ```sh -make test-backend -make lint-backend -make run-backend +mongosh "$SXCHNGE_MONGO_URI" --file backend/scripts/seed-policies.js ``` -The default address is `127.0.0.1:8082`; override with `SXCHNGE_HTTP_ADDR`. -`GET /healthz` reports process health. `GET /readyz` deliberately returns **503** -with `not_configured`: persistence, authentication and Sendico are not connected. -No product routes return dummy success. The process handles SIGINT/SIGTERM with -bounded graceful shutdown. Production transport must terminate HTTPS. +The seed is repeatable and creates only the three local built-in policy +descriptions required by the copied runtime. It grants no role permissions. +Run `make run-backend` from the repository root. The copied CLI accepts +`-config.file`, `-version` and `-debug`. Configure exact browser origins in YAML; +secure refresh cookies require HTTPS for browser session testing. -## Present components +The runtime mounts `/api/v1/health` and the copied account login, API login, +refresh, rotation and logout handlers. Customer/client records must exist in the +local database. The account service is initialized locally; signup/profile and +verification-delivery/completion routes are the next wiring work. This is a +bootstrap, not a finished authentication deployment. No Sendico calls are made. -- `cmd/bff`, `internal/httpapi`: HTTP process, timeouts and operational endpoints. -- `internal/browser`: explicit HTTPS origin/CSRF policy and Secure HttpOnly - host-only session cookie. Apply this policy to every future browser mutation; - it is not yet connected to product routes because those do not exist. -- `internal/credential`: random opaque credentials and storage digests. Never use - this helper for passwords or short OTP codes. -- `internal/evidence`: confined server-generated object keys; not a substitute - for ownership, upload validation or private storage controls. +## Validate -Only the standard library is required. Larger adapted flows, persistence, -provider DTOs/mappers, email and live integration follow the plan in separate -contributions. Source-derived components evolve independently inside this repo. +Run `make test` and `make lint-backend`. Backend tests include real MongoDB +integration tests through Testcontainers, so Docker must be running. +`make test-backend` runs the backend suite with the race detector. +Architecture checks reject private Sendico imports/transports and ad hoc error +constructors. The frontend still uses dummy services; no Flutter source changed. diff --git a/backend/cmd/bff/main.go b/backend/cmd/bff/main.go index da44535..e6095d5 100644 --- a/backend/cmd/bff/main.go +++ b/backend/cmd/bff/main.go @@ -1,46 +1,21 @@ +// Adapted from Sendico api/edge/product/main.go; see PROVENANCE.md. package main import ( - "context" - "errors" - "log/slog" - "net/http" + "git.sendico.io/tech/sxchnge/bff/edge/shared/appversion" + "git.sendico.io/tech/sxchnge/bff/edge/shared/server" + "git.sendico.io/tech/sxchnge/bff/internal/profile" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + application "git.sendico.io/tech/sxchnge/bff/pkg/server" + smain "git.sendico.io/tech/sxchnge/bff/pkg/server/main" "os" - "os/signal" - "syscall" - "time" - - "git.sendico.io/tech/sxchnge/bff/internal/httpapi" ) +func factory(logger mlogger.Logger, file string, debug bool) (application.Application, error) { + return server.Create(logger, file, debug, profile.Runtime()) +} func main() { - if err := run(); err != nil { - slog.Error("BFF stopped", "error", err) + if smain.RunServer("sxchnge.bff", appversion.Create(), factory) != nil { os.Exit(1) } } - -func run() error { - addr := os.Getenv("SXCHNGE_HTTP_ADDR") - if addr == "" { - addr = "127.0.0.1:8082" - } - server := &http.Server{Addr: addr, Handler: httpapi.New(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - failures := make(chan error, 1) - go func() { failures <- server.ListenAndServe() }() - slog.Info("BFF operational shell starting", "address", addr, "provider_configured", false) - select { - case err := <-failures: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - case <-ctx.Done(): - shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - slog.Info("BFF shutting down") - return server.Shutdown(shutdown) - } -} diff --git a/backend/config.example.yml b/backend/config.example.yml new file mode 100644 index 0000000..0417f80 --- /dev/null +++ b/backend/config.example.yml @@ -0,0 +1,35 @@ +# SXCHNGE-owned Mongo database and JWT key. Never point these at Sendico. +http_server: + listen_address: 127.0.0.1:8082 + read_header_timeout: 5 + shutdown_timeout: 10 +database: + driver: mongodb + settings: + uri_env: SXCHNGE_MONGO_URI + database: sxchnge + enforcer: + driver: native +api: + middleware: + domain_env: SXCHNGE_DOMAIN + api_endpoint_env: SXCHNGE_API_ENDPOINT + api_protocol_env: SXCHNGE_API_PROTOCOL + signature: + algorithm: HS256 + secret_key_env: SXCHNGE_JWT_SECRET + CORS: + allowed_origins: ["http://localhost:5126"] + allowed_methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] + allowed_headers: ["Content-Type", "Authorization", "X-Auth-Transport", "X-CSRF-Protection"] + allow_credentials: true + max_age: 300 + token: + expiration_hours: + account: 1 + refresh: 720 + length: 32 + password: + token_length: 32 + check: + min_length: 12 diff --git a/backend/contracts/sendico/api.yaml b/backend/contracts/sendico/api.yaml new file mode 100644 index 0000000..c37aeba --- /dev/null +++ b/backend/contracts/sendico/api.yaml @@ -0,0 +1,129 @@ +openapi: 3.1.0 +info: + title: Sendico API Contract + version: 4.2.37 + summary: Minimal client contract for authenticated BFF flows. + description: | + OpenAPI 3.1 contract focused on authenticated client flows exposed by the BFF: + authentication, verification, recipients, payment methods, payments, wallets, + and ledger account history. +servers: + - url: /api/v1 + description: Main HTTP API base path + +tags: + - name: Auth + description: Authentication and token lifecycle + - name: Accounts + description: Account auth responses used by login/verification + - name: Verification + description: Pending-account code verification + - name: Organizations + description: Organization selection for payment operations + - name: Recipients + description: Recipient CRUD and archive flows + - name: Payment Methods + description: Payment method CRUD and archive flows + - name: Callbacks + description: Webhook callback subscription CRUD and signing material rotation for HMAC and detached-JWS callbacks + - name: Payments + description: Quotation and payment orchestration + - name: Wallets + description: Managed wallet metadata and balances + - name: Ledger Accounts + description: Ledger account balances and account-history operations + +paths: + /accounts/login: + $ref: ./api/accounts/auth_login.yaml + /accounts/login/api: + $ref: ./api/accounts/auth_login_api.yaml + /accounts/rotate: + $ref: ./api/accounts/auth_rotate.yaml + /accounts/refresh: + $ref: ./api/accounts/auth_refresh.yaml + + /verification: + $ref: ./api/verification/create.yaml + /verification/resend: + $ref: ./api/verification/resend.yaml + /verification/verify: + $ref: ./api/verification/verify.yaml + + /organizations: + $ref: ./api/organizations/root.yaml + /organizations/{org_ref}: + $ref: ./api/organizations/get.yaml + + /recipients/list/{org_ref}/{organizations_ref}: + $ref: ./api/recipients/list.yaml + /recipients/{org_ref}: + $ref: ./api/recipients/create.yaml + /recipients/{recipients_ref}: + $ref: ./api/recipients/object.yaml + /recipients: + $ref: ./api/recipients/update.yaml + /recipients/archive/{org_ref}/{recipients_ref}: + $ref: ./api/recipients/archive.yaml + + /payment_methods/accounts/{organizations_ref}/{accounts_ref}: + $ref: ./api/payment_methods/accounts.yaml + /payment_methods/recipients/{organizations_ref}/{recipients_ref}: + $ref: ./api/payment_methods/recipients.yaml + /payment_methods/{organizations_ref}/{payment_methods_ref}: + $ref: ./api/payment_methods/object.yaml + /payment_methods/archive/{organizations_ref}/{payment_methods_ref}: + $ref: ./api/payment_methods/archive.yaml + + /callbacks/list/{org_ref}/{organizations_ref}: + $ref: ./api/callbacks/list.yaml + /callbacks/{org_ref}: + $ref: ./api/callbacks/create.yaml + /callbacks/{callbacks_ref}: + $ref: ./api/callbacks/object.yaml + /callbacks: + $ref: ./api/callbacks/update.yaml + /callbacks/archive/{org_ref}/{callbacks_ref}: + $ref: ./api/callbacks/archive.yaml + /callbacks/rotate-secret/{callbacks_ref}: + $ref: ./api/callbacks/rotate_secret.yaml + + /payments/quote/{organizations_ref}: + $ref: ./api/payments/quote.yaml + /payments/multiquote/{organizations_ref}: + $ref: ./api/payments/multiquote.yaml + /payments/immediate/{organizations_ref}: + $ref: ./api/payments/immediate.yaml + /payments/by-quote/{organizations_ref}: + $ref: ./api/payments/by_quote.yaml + /payments/by-multiquote/{organizations_ref}: + $ref: ./api/payments/by_multiquote.yaml + /payments/by-ref/{payments_ref}: + $ref: ./api/payments/object.yaml + /payments/{organizations_ref}: + $ref: ./api/payments/list.yaml + /payments/documents/certificate/{organizations_ref}: + $ref: ./api/payments/documents_certificate.yaml + /payments/registry/{organizations_ref}: + $ref: ./api/payments/registry.yaml + /payments/registry/refresh/{organizations_ref}: + $ref: ./api/payments/registry_refresh.yaml + /payments/fx/currencies/{organizations_ref}: + $ref: ./api/payments/fx_currencies.yaml + /payments/fx/directions/{organizations_ref}: + $ref: ./api/payments/fx_directions.yaml + /chain_wallets/{organizations_ref}/assets: + $ref: ./api/wallets/assets.yaml + /ledger_accounts/{organizations_ref}/{ledger_account_ref}/balance: + $ref: ./api/ledger_accounts/balance.yaml + /ledger_accounts/{organizations_ref}/{ledger_account_ref}/operations: + $ref: ./api/ledger_accounts/operations.yaml + /ledger_accounts/{organizations_ref}/{ledger_account_ref}/operations/{entry_ref}: + $ref: ./api/ledger_accounts/operation.yaml + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/backend/contracts/sendico/api/accounts/auth_login.yaml b/backend/contracts/sendico/api/accounts/auth_login.yaml new file mode 100644 index 0000000..ede8591 --- /dev/null +++ b/backend/contracts/sendico/api/accounts/auth_login.yaml @@ -0,0 +1,29 @@ +post: + tags: [Accounts, Auth] + summary: Login using email/password and receive pending verification token + description: | + Validates account credentials and returns a short-lived `pendingToken`. + This endpoint does not return final access/refresh tokens; complete verification flow via `/verification/verify`. + operationId: accountsLogin + requestBody: + $ref: ./bodies/auth.yaml#/components/requestBodies/LoginBody + responses: + '202': + description: Pending verification token issued + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/auth.yaml#/components/schemas/PendingLoginData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_login_api.yaml b/backend/contracts/sendico/api/accounts/auth_login_api.yaml new file mode 100644 index 0000000..3bb72b2 --- /dev/null +++ b/backend/contracts/sendico/api/accounts/auth_login_api.yaml @@ -0,0 +1,30 @@ +post: + tags: [Accounts, Auth] + summary: API login using email/password and client credentials + description: | + Validates account credentials and eligible API client credentials, then returns final auth payload. + This endpoint bypasses login OTP/2FA and is intended only for approved API clients. + If the client definition includes `allowedCIDRs`, request source IP must match one of those CIDRs. + operationId: accountsApiLogin + requestBody: + $ref: ./bodies/auth.yaml#/components/requestBodies/ApiLoginBody + responses: + '200': + description: Login successful + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/auth.yaml#/components/schemas/LoginData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_refresh.yaml b/backend/contracts/sendico/api/accounts/auth_refresh.yaml new file mode 100644 index 0000000..2822daa --- /dev/null +++ b/backend/contracts/sendico/api/accounts/auth_refresh.yaml @@ -0,0 +1,31 @@ +post: + tags: [Accounts, Auth] + summary: Refresh access token by refresh token + description: | + Validates refresh token + session identifiers and issues a new access token. + Use this endpoint when refresh token stays the same. + operationId: accountsRefreshAccessToken + requestBody: + $ref: ./bodies/auth.yaml#/components/requestBodies/RefreshTokenBody + responses: + '200': + description: Access token refreshed + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/auth.yaml#/components/schemas/AccountAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_rotate.yaml b/backend/contracts/sendico/api/accounts/auth_rotate.yaml new file mode 100644 index 0000000..9515e4d --- /dev/null +++ b/backend/contracts/sendico/api/accounts/auth_rotate.yaml @@ -0,0 +1,31 @@ +post: + tags: [Accounts, Auth] + summary: Rotate refresh token and issue new access/refresh tokens + description: | + Validates refresh token + session identifiers and returns a new token pair. + Use this endpoint when replacing both access and refresh tokens. + operationId: accountsRotateRefreshToken + requestBody: + $ref: ./bodies/auth.yaml#/components/requestBodies/RefreshTokenBody + responses: + '200': + description: New token pair issued + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/auth.yaml#/components/schemas/LoginData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/bodies/auth.yaml b/backend/contracts/sendico/api/accounts/bodies/auth.yaml new file mode 100644 index 0000000..f7a723d --- /dev/null +++ b/backend/contracts/sendico/api/accounts/bodies/auth.yaml @@ -0,0 +1,24 @@ +components: + requestBodies: + LoginBody: + description: JSON credentials payload for standard login. + required: true + content: + application/json: + schema: + $ref: ../request/auth.yaml#/components/schemas/LoginRequest + ApiLoginBody: + description: JSON credentials payload for API client login. + required: true + content: + application/json: + schema: + $ref: ../request/auth.yaml#/components/schemas/ApiLoginRequest + + RefreshTokenBody: + description: JSON payload containing session and refresh token data. + required: true + content: + application/json: + schema: + $ref: ../request/auth.yaml#/components/schemas/RefreshTokenRequest diff --git a/backend/contracts/sendico/api/accounts/request/auth.yaml b/backend/contracts/sendico/api/accounts/request/auth.yaml new file mode 100644 index 0000000..b000fd8 --- /dev/null +++ b/backend/contracts/sendico/api/accounts/request/auth.yaml @@ -0,0 +1,34 @@ +components: + schemas: + LoginRequest: + type: object + additionalProperties: false + required: + - login + properties: + clientId: + type: string + description: Client identifier bound to refresh token lifecycle and client policy checks. + deviceId: + type: string + description: Device/server identifier associated with the client session. + login: + $ref: ../../../models/auth/login_data.yaml#/components/schemas/LoginData + + ApiLoginRequest: + allOf: + - $ref: ./auth.yaml#/components/schemas/LoginRequest + - type: object + additionalProperties: false + required: + - clientId + - deviceId + - clientSecret + properties: + clientSecret: + type: string + format: password + description: Client secret for `client_secret_post` authentication. + + RefreshTokenRequest: + $ref: ../../../models/auth/client_refresh_token.yaml#/components/schemas/ClientRefreshToken diff --git a/backend/contracts/sendico/api/accounts/response/auth.yaml b/backend/contracts/sendico/api/accounts/response/auth.yaml new file mode 100644 index 0000000..dd32654 --- /dev/null +++ b/backend/contracts/sendico/api/accounts/response/auth.yaml @@ -0,0 +1,62 @@ +components: + schemas: + AccountAuthData: + description: Authentication response containing account profile and access token. + type: object + additionalProperties: false + required: + - accessToken + - account + properties: + accessToken: + description: Access token used for authenticated requests. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + account: + description: Authenticated account data. + $ref: ../../../models/account/account.yaml#/components/schemas/AccountData + + LoginData: + description: Full login response including access and refresh tokens. + type: object + additionalProperties: false + required: + - accessToken + - account + - refreshToken + properties: + accessToken: + description: Access token used for authenticated requests. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + account: + description: Authenticated account data. + $ref: ../../../models/account/account.yaml#/components/schemas/AccountData + refreshToken: + description: Refresh token used to obtain a new access token. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + + PendingLoginData: + description: Pending login response requiring additional verification. + type: object + additionalProperties: false + required: + - account + - pendingToken + - target + properties: + account: + description: Interim authentication payload prepared for verification completion. + type: object + additionalProperties: false + properties: + accessToken: + description: Temporary access token issued before verification is completed. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + account: + description: Account data associated with the pending login flow. + $ref: ../../../models/account/account.yaml#/components/schemas/AccountData + pendingToken: + description: Token proving the pending verification session. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + target: + description: Verification target destination (for example email or phone). + type: string diff --git a/backend/contracts/sendico/api/callbacks/archive.yaml b/backend/contracts/sendico/api/callbacks/archive.yaml new file mode 100644 index 0000000..dfc1cde --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/archive.yaml @@ -0,0 +1,37 @@ +get: + tags: [Callbacks] + summary: Archive/unarchive callback subscription + description: Sets callback archive state by `callbacks_ref` and required `archived` query parameter. + operationId: callbacksArchive + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + - name: archived + in: query + required: true + description: Target archive value to set on the callback. + schema: + type: boolean + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Archive state updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/bodies/callback.yaml b/backend/contracts/sendico/api/callbacks/bodies/callback.yaml new file mode 100644 index 0000000..850efd3 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/bodies/callback.yaml @@ -0,0 +1,17 @@ +components: + requestBodies: + CallbackCreateBody: + description: JSON body containing callback endpoint configuration for create. + required: true + content: + application/json: + schema: + $ref: ../request/callback.yaml#/components/schemas/CallbackCreateRequest + + CallbackUpdateBody: + description: JSON body containing callback endpoint configuration for update. + required: true + content: + application/json: + schema: + $ref: ../request/callback.yaml#/components/schemas/CallbackUpdateRequest diff --git a/backend/contracts/sendico/api/callbacks/create.yaml b/backend/contracts/sendico/api/callbacks/create.yaml new file mode 100644 index 0000000..92c5c32 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/create.yaml @@ -0,0 +1,33 @@ +post: + tags: [Callbacks] + summary: Create callback subscription + description: Creates callback subscription for the organization identified by `org_ref`. + operationId: callbacksCreate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + requestBody: + $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackCreateBody + responses: + '201': + description: Callback created + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/list.yaml b/backend/contracts/sendico/api/callbacks/list.yaml new file mode 100644 index 0000000..0072f45 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/list.yaml @@ -0,0 +1,35 @@ +get: + tags: [Callbacks] + summary: List callback subscriptions + description: | + Lists callbacks for the given organization context. + The route currently includes both `org_ref` and `organizations_ref`; the handler resolves organization context from `org_ref` and ignores `organizations_ref`. + operationId: callbacksList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + - $ref: ../parameters/archived.yaml#/components/parameters/Archived + responses: + '200': + description: Callback list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/object.yaml b/backend/contracts/sendico/api/callbacks/object.yaml new file mode 100644 index 0000000..e585c74 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/object.yaml @@ -0,0 +1,64 @@ +get: + tags: [Callbacks] + summary: Get callback subscription + description: Returns callback subscription by `callbacks_ref`. + operationId: callbacksGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + responses: + '200': + description: Callback data + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +delete: + tags: [Callbacks] + summary: Delete callback subscription + description: Deletes callback by reference. + operationId: callbacksDelete + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Callback deleted + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/request/callback.yaml b/backend/contracts/sendico/api/callbacks/request/callback.yaml new file mode 100644 index 0000000..a0535c3 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/request/callback.yaml @@ -0,0 +1,113 @@ +components: + schemas: + CallbackBackoffInput: + description: Optional retry backoff overrides. Non-positive values currently fall back to server defaults. + type: object + additionalProperties: false + properties: + minDelayMs: + type: integer + maxDelayMs: + type: integer + + CallbackRetryPolicyInput: + description: Optional retry and signing overrides. Omitted or non-positive numeric values currently fall back to server defaults. + type: object + additionalProperties: false + properties: + backoff: + $ref: '#/components/schemas/CallbackBackoffInput' + signingMode: + description: | + Optional signing mode. Canonical values are documented here; current handler also accepts legacy aliases such as `hmac`, `hmac-sha256`, `jws-eddsa-ed25519`, `jws+eddsa-ed25519`, `jws_eddsa`, and `eddsa_ed25519`. + type: string + enum: + - none + - hmac_sha256 + - jws_eddsa_ed25519 + headers: + type: object + additionalProperties: + type: string + maxAttempts: + type: integer + requestTimeoutMs: + type: integer + + CallbackCreateRequest: + description: | + Request payload used to create a callback configuration. + Only `url` is hard-required by current create logic. Other mutable fields are optional and defaulted by the handler when omitted. + type: object + additionalProperties: false + required: + - url + properties: + name: + description: Optional callback display name. When omitted, the handler defaults it to `url`. + type: string + description: + description: Optional callback description. + type: string + nullable: true + status: + description: | + Optional callback status. Canonical values are documented here; current handler also accepts `enabled` and `inactive` aliases. + type: string + enum: + - active + - disabled + url: + description: Absolute HTTPS callback endpoint URL. + type: string + format: uri + eventTypes: + description: Optional callback event types. When omitted or empty, the handler defaults this list. + type: array + items: + type: string + retryPolicy: + $ref: '#/components/schemas/CallbackRetryPolicyInput' + + CallbackUpdateRequest: + description: | + Request payload used to update a callback configuration. + The current PUT handler requires `id`, `organizationRef`, and `url`. Other mutable fields are optional, but omitted values may be reset to handler defaults during normalization. + type: object + additionalProperties: false + required: + - id + - organizationRef + - url + properties: + id: + description: Callback identifier to update. + $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId + organizationRef: + description: Organization identifier carried in the stored callback record and currently required by the update path. + $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId + name: + description: Optional callback display name. When omitted, the handler defaults it to `url`. + type: string + description: + description: Optional callback description. + type: string + nullable: true + status: + description: | + Optional callback status. Canonical values are documented here; current handler also accepts `enabled` and `inactive` aliases. + type: string + enum: + - active + - disabled + url: + description: Absolute HTTPS callback endpoint URL. + type: string + format: uri + eventTypes: + description: Optional callback event types. When omitted or empty, the handler defaults this list. + type: array + items: + type: string + retryPolicy: + $ref: '#/components/schemas/CallbackRetryPolicyInput' diff --git a/backend/contracts/sendico/api/callbacks/response/callback.yaml b/backend/contracts/sendico/api/callbacks/response/callback.yaml new file mode 100644 index 0000000..02e262b --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/response/callback.yaml @@ -0,0 +1,22 @@ +components: + schemas: + CallbacksAuthData: + description: Authenticated response payload containing callback configurations. + type: object + additionalProperties: false + required: + - accessToken + - callbacks + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + callbacks: + description: Collection of callbacks configured for the authenticated scope. JWS-signed callbacks expose read-only verification JWKs via `callbacks[].signingKeys`. + type: array + items: + $ref: ../../../models/callback/callback.yaml#/components/schemas/Callback + generatedSigningSecret: + description: Newly generated signing secret when HMAC signing material is created or rotated. JWS-signed callbacks do not populate this field. + type: string + nullable: true diff --git a/backend/contracts/sendico/api/callbacks/rotate_secret.yaml b/backend/contracts/sendico/api/callbacks/rotate_secret.yaml new file mode 100644 index 0000000..6296d25 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/rotate_secret.yaml @@ -0,0 +1,31 @@ +post: + tags: [Callbacks] + summary: Rotate callback signing material + description: Rotates the active signing material for the callback. HMAC callbacks generate and return a new shared secret once. `jws_eddsa_ed25519` callbacks generate a new Ed25519 keypair, expose the new active verification JWK on normal callback reads, and keep retiring verification keys visible for 24 hours. + operationId: callbacksRotateSecret + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + responses: + '200': + description: Callback signing material rotated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/update.yaml b/backend/contracts/sendico/api/callbacks/update.yaml new file mode 100644 index 0000000..e3f9932 --- /dev/null +++ b/backend/contracts/sendico/api/callbacks/update.yaml @@ -0,0 +1,31 @@ +put: + tags: [Callbacks] + summary: Update callback subscription + description: Updates callback subscription fields. The current handler requires `id`, `organizationRef`, and `url`; omitted mutable fields may be reset to defaults. + operationId: callbacksUpdate + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackUpdateBody + responses: + '200': + description: Callback updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/balance.yaml b/backend/contracts/sendico/api/ledger_accounts/balance.yaml new file mode 100644 index 0000000..7515651 --- /dev/null +++ b/backend/contracts/sendico/api/ledger_accounts/balance.yaml @@ -0,0 +1,58 @@ +get: + tags: [Ledger Accounts] + summary: Get ledger account balance + description: | + Returns the canonical balance for the requested ledger account. + + Without `as_of`, the response is the current materialized balance and includes + current metadata fields `version` and `lastUpdated`. + + With `as_of`, the response is an event-time accounting balance reconstructed + from posted statement-entry deltas where `eventTime <= as_of`. Entries exactly + equal to `as_of` are included. This is not a historical snapshot of materialized + balance state and not "what the system knew" at that clock time. Late or + backdated postings with older `eventTime` can change historical responses for + the same `as_of`. Historical responses include `ledgerAccountRef`, `balance`, + and canonical UTC `asOf`, and omit `version` and `lastUpdated` because no + historical snapshot/version semantics are defined. + + Historical balance is valid only if every account-affecting committed posting + writes matching statement entries in the same transaction as balance updates. + operationId: ledgerAccountBalanceGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef + - name: as_of + in: query + required: false + description: | + Optional RFC3339 event-time cutoff for historical balance reconstruction. + Timezone offsets are accepted and returned as canonical UTC `asOf`. + schema: + type: string + format: date-time + example: '2026-04-23T14:30:00+02:00' + responses: + '200': + description: Ledger account balance + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/balance.yaml#/components/schemas/LedgerAccountBalanceData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/operation.yaml b/backend/contracts/sendico/api/ledger_accounts/operation.yaml new file mode 100644 index 0000000..06fe2c5 --- /dev/null +++ b/backend/contracts/sendico/api/ledger_accounts/operation.yaml @@ -0,0 +1,36 @@ +get: + tags: [Ledger Accounts] + summary: Get ledger account operation detail + description: | + Returns the account-history projection for `entry_ref` within the requested ledger account. + This is not a general journal-entry read API. + The endpoint returns `404` when the entry is not part of the requested account's visible history. + operationId: ledgerAccountOperationsGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef + - $ref: ../parameters/entry_ref.yaml#/components/parameters/EntryRef + responses: + '200': + description: Ledger account operation detail + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/operation.yaml#/components/schemas/LedgerAccountOperationData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/operations.yaml b/backend/contracts/sendico/api/ledger_accounts/operations.yaml new file mode 100644 index 0000000..2910a7b --- /dev/null +++ b/backend/contracts/sendico/api/ledger_accounts/operations.yaml @@ -0,0 +1,36 @@ +get: + tags: [Ledger Accounts] + summary: List ledger account operations + description: | + Returns account-history summary projections for the requested ledger account. + Public HTTP pagination supports only `limit` and `offset`. + Results are ordered by `version` descending and `entry_ref` descending. + Unsupported query parameters, including `archived`, are rejected. + operationId: ledgerAccountOperationsList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + responses: + '200': + description: Ledger account operation summaries + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/operation.yaml#/components/schemas/LedgerAccountOperationsData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml b/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml new file mode 100644 index 0000000..254864b --- /dev/null +++ b/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml @@ -0,0 +1,15 @@ +components: + schemas: + LedgerAccountBalanceData: + description: Response payload containing a ledger account balance. + type: object + additionalProperties: false + required: + - accessToken + - balance + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + balance: + $ref: ../../../models/ledger/balance.yaml#/components/schemas/LedgerAccountBalance diff --git a/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml b/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml new file mode 100644 index 0000000..4492932 --- /dev/null +++ b/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml @@ -0,0 +1,33 @@ +components: + schemas: + LedgerAccountOperationsData: + description: Response payload containing ledger account history summaries. + type: object + additionalProperties: false + required: + - accessToken + - operations + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + operations: + description: Account-history summaries ordered by `version DESC, entryRef DESC`. + type: array + items: + $ref: ../../../models/ledger/operation.yaml#/components/schemas/LedgerAccountOperationSummary + + LedgerAccountOperationData: + description: Response payload containing a single ledger account history projection. + type: object + additionalProperties: false + required: + - accessToken + - operation + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + operation: + description: Account-history projection for the requested entry and ledger account. + $ref: ../../../models/ledger/operation.yaml#/components/schemas/LedgerAccountOperation diff --git a/backend/contracts/sendico/api/organizations/get.yaml b/backend/contracts/sendico/api/organizations/get.yaml new file mode 100644 index 0000000..c93ed7a --- /dev/null +++ b/backend/contracts/sendico/api/organizations/get.yaml @@ -0,0 +1,29 @@ +get: + tags: [Organizations] + summary: Get organization by reference + description: Fetches a single organization available to the authenticated account. + operationId: organizationsGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + responses: + '200': + description: Organization details + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/organization.yaml#/components/schemas/OrganizationsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/organizations/response/organization.yaml b/backend/contracts/sendico/api/organizations/response/organization.yaml new file mode 100644 index 0000000..56fc0f7 --- /dev/null +++ b/backend/contracts/sendico/api/organizations/response/organization.yaml @@ -0,0 +1,18 @@ +components: + schemas: + OrganizationsAuthData: + description: Authenticated response payload containing organizations. + type: object + additionalProperties: false + required: + - accessToken + - organizations + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + organizations: + description: Collection of organizations available to the authenticated account. + type: array + items: + $ref: ../../../models/organization/organization.yaml#/components/schemas/Organization diff --git a/backend/contracts/sendico/api/organizations/root.yaml b/backend/contracts/sendico/api/organizations/root.yaml new file mode 100644 index 0000000..7a00d19 --- /dev/null +++ b/backend/contracts/sendico/api/organizations/root.yaml @@ -0,0 +1,27 @@ +get: + tags: [Organizations] + summary: List organizations of current account + description: Returns organizations the authenticated account can access. + operationId: organizationsList + security: + - bearerAuth: [] + responses: + '200': + description: Organizations list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/organization.yaml#/components/schemas/OrganizationsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/parameters/accounts_ref.yaml b/backend/contracts/sendico/api/parameters/accounts_ref.yaml new file mode 100644 index 0000000..a72a17d --- /dev/null +++ b/backend/contracts/sendico/api/parameters/accounts_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + AccountsRef: + name: accounts_ref + in: path + required: true + description: Account reference, 24 hex chars. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/archived.yaml b/backend/contracts/sendico/api/parameters/archived.yaml new file mode 100644 index 0000000..fb8af8c --- /dev/null +++ b/backend/contracts/sendico/api/parameters/archived.yaml @@ -0,0 +1,9 @@ +components: + parameters: + Archived: + name: archived + in: query + required: false + description: Filter by archive flag. `true` returns archived records, `false` returns active records. + schema: + type: boolean diff --git a/backend/contracts/sendico/api/parameters/callbacks_ref.yaml b/backend/contracts/sendico/api/parameters/callbacks_ref.yaml new file mode 100644 index 0000000..c12cefa --- /dev/null +++ b/backend/contracts/sendico/api/parameters/callbacks_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + CallbacksRef: + name: callbacks_ref + in: path + required: true + description: Callback subscription reference, 24 hex chars. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/cascade.yaml b/backend/contracts/sendico/api/parameters/cascade.yaml new file mode 100644 index 0000000..4077d85 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/cascade.yaml @@ -0,0 +1,9 @@ +components: + parameters: + Cascade: + name: cascade + in: query + required: false + description: If `true`, apply operation to dependent objects where cascade is supported. + schema: + type: boolean diff --git a/backend/contracts/sendico/api/parameters/cursor.yaml b/backend/contracts/sendico/api/parameters/cursor.yaml new file mode 100644 index 0000000..15252a1 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/cursor.yaml @@ -0,0 +1,9 @@ +components: + parameters: + Cursor: + name: cursor + in: query + required: false + description: Opaque cursor from a previous page response (`page.next_cursor`). + schema: + type: string diff --git a/backend/contracts/sendico/api/parameters/entry_ref.yaml b/backend/contracts/sendico/api/parameters/entry_ref.yaml new file mode 100644 index 0000000..d035050 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/entry_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + EntryRef: + name: entry_ref + in: path + required: true + description: Statement entry reference within the requested ledger account history. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/filter_states.yaml b/backend/contracts/sendico/api/parameters/filter_states.yaml new file mode 100644 index 0000000..1c1d34f --- /dev/null +++ b/backend/contracts/sendico/api/parameters/filter_states.yaml @@ -0,0 +1,11 @@ +components: + parameters: + FilterStates: + name: filter_states + in: query + required: false + description: | + Additional payment state filter. Accepts one value or comma-separated values. + Same semantics as `state`/`states`. + schema: + type: string diff --git a/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml b/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml new file mode 100644 index 0000000..ac23150 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + LedgerAccountRef: + name: ledger_account_ref + in: path + required: true + description: Ledger account reference that scopes the account-history read. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/limit.yaml b/backend/contracts/sendico/api/parameters/limit.yaml new file mode 100644 index 0000000..71fa974 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/limit.yaml @@ -0,0 +1,11 @@ +components: + parameters: + Limit: + name: limit + in: query + description: Max number of items to return. Parsed as non-negative int64. + required: false + schema: + type: integer + format: int64 + minimum: 0 diff --git a/backend/contracts/sendico/api/parameters/offset.yaml b/backend/contracts/sendico/api/parameters/offset.yaml new file mode 100644 index 0000000..03b8b7c --- /dev/null +++ b/backend/contracts/sendico/api/parameters/offset.yaml @@ -0,0 +1,11 @@ +components: + parameters: + Offset: + name: offset + in: query + description: Number of items to skip. Parsed as non-negative int64. + required: false + schema: + type: integer + format: int64 + minimum: 0 diff --git a/backend/contracts/sendico/api/parameters/org_ref.yaml b/backend/contracts/sendico/api/parameters/org_ref.yaml new file mode 100644 index 0000000..3ef0c64 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/org_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + OrgRef: + name: org_ref + in: path + required: true + description: Organization reference in BFF route format, 24 hex chars. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/organizations_ref.yaml b/backend/contracts/sendico/api/parameters/organizations_ref.yaml new file mode 100644 index 0000000..668d322 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/organizations_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + OrganizationsRef: + name: organizations_ref + in: path + required: true + description: Organization reference used by payments/payment-methods/recipients routes. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml b/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml new file mode 100644 index 0000000..6690d53 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + Payment MethodsRef: + name: payment_methods_ref + in: path + required: true + description: Payment method reference, 24 hex chars. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/payments_ref.yaml b/backend/contracts/sendico/api/parameters/payments_ref.yaml new file mode 100644 index 0000000..28a015b --- /dev/null +++ b/backend/contracts/sendico/api/parameters/payments_ref.yaml @@ -0,0 +1,10 @@ +components: + parameters: + PaymentsRef: + name: payments_ref + in: path + required: true + description: Stable payment reference. + schema: + type: string + minLength: 1 diff --git a/backend/contracts/sendico/api/parameters/recipients_ref.yaml b/backend/contracts/sendico/api/parameters/recipients_ref.yaml new file mode 100644 index 0000000..945bca1 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/recipients_ref.yaml @@ -0,0 +1,9 @@ +components: + parameters: + RecipientsRef: + name: recipients_ref + in: path + required: true + description: Recipient reference, 24 hex chars. + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/state.yaml b/backend/contracts/sendico/api/parameters/state.yaml new file mode 100644 index 0000000..cb05776 --- /dev/null +++ b/backend/contracts/sendico/api/parameters/state.yaml @@ -0,0 +1,11 @@ +components: + parameters: + State: + name: state + in: query + required: false + description: | + Payment state filter. Accepts aliases like `accepted`, `settled`, `failed` + or canonical values like `orchestration_state_created`. + schema: + type: string diff --git a/backend/contracts/sendico/api/parameters/states.yaml b/backend/contracts/sendico/api/parameters/states.yaml new file mode 100644 index 0000000..96d672d --- /dev/null +++ b/backend/contracts/sendico/api/parameters/states.yaml @@ -0,0 +1,10 @@ +components: + parameters: + States: + name: states + in: query + required: false + description: | + Multi-state filter. Provide a comma-separated list with same accepted values as `state`. + schema: + type: string diff --git a/backend/contracts/sendico/api/payment_methods/accounts.yaml b/backend/contracts/sendico/api/payment_methods/accounts.yaml new file mode 100644 index 0000000..9c7fc85 --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/accounts.yaml @@ -0,0 +1,74 @@ +get: + tags: [Payment Methods] + summary: List account-owned payment methods + description: | + Returns payment methods owned by `accounts_ref` within `organizations_ref`. + Supports offset pagination and optional archive filtering. + operationId: paymentMethodsListAccountOwned + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/accounts_ref.yaml#/components/parameters/AccountsRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + - $ref: ../parameters/archived.yaml#/components/parameters/Archived + responses: + '200': + description: Payment methods list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +post: + tags: [Payment Methods] + summary: Create account-owned payment method + description: Creates a payment method owned by `accounts_ref` inside `organizations_ref`. + operationId: paymentMethodsCreateAccountOwned + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/accounts_ref.yaml#/components/parameters/AccountsRef + requestBody: + $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodCreateBody + responses: + '201': + description: Payment method created + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/archive.yaml b/backend/contracts/sendico/api/payment_methods/archive.yaml new file mode 100644 index 0000000..5577220 --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/archive.yaml @@ -0,0 +1,36 @@ +post: + tags: [Payment Methods] + summary: Archive/unarchive payment method + description: Sets payment method archive state by `payment_methods_ref` and required `archived` query parameter. + operationId: paymentMethodsArchive + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef + - name: archived + in: query + required: true + description: Target archive value to set on the payment method. + schema: + type: boolean + responses: + '200': + description: Archive state updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml new file mode 100644 index 0000000..56d488e --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml @@ -0,0 +1,17 @@ +components: + requestBodies: + PaymentMethodCreateBody: + description: JSON body containing a payment method definition for create. + required: true + content: + application/json: + schema: + $ref: ../request/payment_method.yaml#/components/schemas/PaymentMethodCreateRequest + + PaymentMethodUpdateBody: + description: JSON body containing a payment method definition for update. + required: true + content: + application/json: + schema: + $ref: ../request/payment_method.yaml#/components/schemas/PaymentMethodUpdateRequest diff --git a/backend/contracts/sendico/api/payment_methods/object.yaml b/backend/contracts/sendico/api/payment_methods/object.yaml new file mode 100644 index 0000000..a183d57 --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/object.yaml @@ -0,0 +1,102 @@ +get: + tags: [Payment Methods] + summary: Get payment method + description: Returns payment method by `payment_methods_ref` inside `organizations_ref`. + operationId: paymentMethodsGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef + responses: + '200': + description: Payment method data + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +patch: + tags: [Payment Methods] + summary: Update payment method + description: Updates mutable payment method fields for the record identified by `payment_methods_ref` inside `organizations_ref`. + operationId: paymentMethodsUpdate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef + requestBody: + $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodUpdateBody + responses: + '200': + description: Payment method updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +delete: + tags: [Payment Methods] + summary: Delete payment method + description: Deletes payment method by reference. + operationId: paymentMethodsDelete + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef + responses: + '200': + description: Payment method deleted + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/recipients.yaml b/backend/contracts/sendico/api/payment_methods/recipients.yaml new file mode 100644 index 0000000..414ab3f --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/recipients.yaml @@ -0,0 +1,74 @@ +get: + tags: [Payment Methods] + summary: List recipient-owned payment methods + description: | + Returns payment methods owned by `recipients_ref` within `organizations_ref`. + Supports offset pagination and optional archive filtering. + operationId: paymentMethodsListRecipientOwned + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + - $ref: ../parameters/archived.yaml#/components/parameters/Archived + responses: + '200': + description: Payment methods list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +post: + tags: [Payment Methods] + summary: Create recipient-owned payment method + description: Creates a payment method owned by `recipients_ref` inside `organizations_ref`. + operationId: paymentMethodsCreateRecipientOwned + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef + requestBody: + $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodCreateBody + responses: + '201': + description: Payment method created + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml new file mode 100644 index 0000000..6f768e7 --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml @@ -0,0 +1,42 @@ +components: + schemas: + PaymentMethodBaseRequest: + type: object + additionalProperties: false + properties: + name: + description: Optional human-readable payment method name. + type: string + description: + description: Optional free-form payment method description. + type: string + nullable: true + type: + description: Payment method type discriminator. + $ref: ../../../external/payment_method_type.yaml#/components/schemas/PaymentMethodType + data: + description: | + Optional payment-method-type-specific payload. + For `type: bankAccount`, this uses the Russian bank-account + destination shape documented as `BankAccountEndpoint`. + type: object + additionalProperties: true + isMain: + description: Optional primary-method flag. When omitted, current handler behavior falls back to `false`. + type: boolean + + PaymentMethodCreateRequest: + description: | + Request payload used to create a payment method. + Ownership comes from the route path. The body only carries mutable payment-method fields. + required: + - type + allOf: + - $ref: '#/components/schemas/PaymentMethodBaseRequest' + + PaymentMethodUpdateRequest: + description: | + Request payload used to update a payment method. + This is a PATCH contract. Only `name`, `description`, `data`, and `isMain` are mutable. + allOf: + - $ref: '#/components/schemas/PaymentMethodBaseRequest' diff --git a/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml new file mode 100644 index 0000000..40cb7aa --- /dev/null +++ b/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml @@ -0,0 +1,18 @@ +components: + schemas: + Payment MethodsAuthData: + description: Authenticated response payload containing payment methods. + type: object + additionalProperties: false + required: + - accessToken + - payment_methods + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + payment_methods: + description: Collection of payment methods available to the authenticated account. + type: array + items: + $ref: ../../../models/payment_method/payment_method.yaml#/components/schemas/PaymentMethod diff --git a/backend/contracts/sendico/api/payments/bodies/payment.yaml b/backend/contracts/sendico/api/payments/bodies/payment.yaml new file mode 100644 index 0000000..4dc0349 --- /dev/null +++ b/backend/contracts/sendico/api/payments/bodies/payment.yaml @@ -0,0 +1,33 @@ +components: + requestBodies: + QuotePaymentBody: + description: JSON payload used to request a quote for one payment intent. + required: true + content: + application/json: + schema: + $ref: ../request/payment.yaml#/components/schemas/QuotePaymentRequest + + QuotePaymentsBody: + description: JSON payload used to request quotes for multiple payment intents. + required: true + content: + application/json: + schema: + $ref: ../request/payment.yaml#/components/schemas/QuotePaymentsRequest + + InitiatePaymentBody: + description: JSON payload used to initiate a single payment. + required: true + content: + application/json: + schema: + $ref: ../request/payment.yaml#/components/schemas/InitiatePaymentRequest + + InitiatePaymentsBody: + description: JSON payload used to initiate multiple payments from a quote reference. + required: true + content: + application/json: + schema: + $ref: ../request/payment.yaml#/components/schemas/InitiatePaymentsRequest diff --git a/backend/contracts/sendico/api/payments/by_multiquote.yaml b/backend/contracts/sendico/api/payments/by_multiquote.yaml new file mode 100644 index 0000000..2c41a74 --- /dev/null +++ b/backend/contracts/sendico/api/payments/by_multiquote.yaml @@ -0,0 +1,37 @@ +post: + tags: [Payments] + summary: Initiate payment batch by quote reference + description: | + Executes batch payment from a previously created multi-quote reference. If any quoted item + uses a raw `card` destination, that item's original quote must already include `exp_month` + and `exp_year`. Card-recipient AML screening occurs during execution, not quotation. An + unchanged payment item reuses its persisted compliance decision on idempotent execution + replay, avoiding a duplicate AML call. A definitive denial terminates that payment with + public `failureCode: failure_restriction` and `failureReason: aml_denied`. + operationId: paymentsInitiateBatchByQuote + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + requestBody: + $ref: ./bodies/payment.yaml#/components/requestBodies/InitiatePaymentsBody + responses: + '200': + description: Payment batch initiated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentsData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/by_quote.yaml b/backend/contracts/sendico/api/payments/by_quote.yaml new file mode 100644 index 0000000..703a988 --- /dev/null +++ b/backend/contracts/sendico/api/payments/by_quote.yaml @@ -0,0 +1,41 @@ +post: + tags: [Payments] + summary: Initiate payment using quote reference + description: | + Executes one payment from an existing quote reference. If the quoted destination is raw + `card`, the original quote must already include `exp_month` and `exp_year`. Card-recipient + AML screening occurs during execution, not quotation. An unchanged payment reuses its + persisted compliance decision on idempotent execution replay, avoiding a duplicate AML call. + A definitive denial terminates the payment with public `failureCode: failure_restriction` + and `failureReason: aml_denied`. + operationId: paymentsInitiateByQuote + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + requestBody: + required: true + content: + application/json: + schema: + $ref: ./request/payment.yaml#/components/schemas/InitiatePaymentByQuoteRequest + responses: + '200': + description: Payment initiated by quote + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/documents_certificate.yaml b/backend/contracts/sendico/api/payments/documents_certificate.yaml new file mode 100644 index 0000000..e7a3378 --- /dev/null +++ b/backend/contracts/sendico/api/payments/documents_certificate.yaml @@ -0,0 +1,47 @@ +get: + tags: [Payments] + summary: Download payment certificate + description: | + Returns a generated payment certificate as binary content. + The payment must be settled; the BFF resolves certificate details from the payment reference. + operationId: paymentsGetPaymentCertificate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - name: payment_ref + in: query + required: true + description: Settled payment reference used to generate the payment certificate. + schema: + type: string + responses: + '200': + description: Payment certificate file + content: + application/pdf: + schema: + type: string + format: binary + application/octet-stream: + schema: + type: string + format: binary + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + '503': + description: Upstream service unavailable + content: + application/json: + schema: + $ref: ../response/response.yaml#/components/schemas/ErrorResponse diff --git a/backend/contracts/sendico/api/payments/fx_currencies.yaml b/backend/contracts/sendico/api/payments/fx_currencies.yaml new file mode 100644 index 0000000..f2ecf5a --- /dev/null +++ b/backend/contracts/sendico/api/payments/fx_currencies.yaml @@ -0,0 +1,40 @@ +get: + tags: [Payments] + summary: List available FX currencies + description: | + Returns the BFF startup-cached Treasury Currency Catalog projection for + client-visible FX currencies. `decimals`, `scale`, and `displayScale` are + presentation fields backed by Treasury `display_scale`. + `accountingScaleCandidate` is informational only and must not be used by + clients to determine executable payout precision, quote precision, or + guaranteed provider support. + The endpoint is authenticated and authorized by `organizations_ref`, but the + returned catalogue is global rather than organization-customized. If Treasury + was unavailable at BFF startup while this endpoint was enabled, BFF fails + startup; if Treasury becomes unavailable after startup, BFF continues serving + the cached catalog revision. + operationId: paymentsFXCurrencies + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + responses: + '200': + description: FX currency catalogue projected from Treasury + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/FXCurrenciesData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '503': + $ref: ../response/operation.yaml#/components/responses/ServiceUnavailable diff --git a/backend/contracts/sendico/api/payments/fx_directions.yaml b/backend/contracts/sendico/api/payments/fx_directions.yaml new file mode 100644 index 0000000..264f35d --- /dev/null +++ b/backend/contracts/sendico/api/payments/fx_directions.yaml @@ -0,0 +1,48 @@ +get: + tags: [Payments] + summary: List available FX directions + description: | + Returns organization-level FX direction availability. + + A returned direction means the organization has at least one eligible + direction-level route. It does not mean that every quote or payment for this + direction will be accepted. The actual route, gateway, limits, pricing, fees, + asset domain, and settlement details are resolved during quotation/payment + execution. + + If dependencies are healthy but no organization-eligible executable + directions match oracle quoteable pair sides, the response contains an empty + `directions` array. Oracle or gateway/route registry dependency failures are + returned as errors and are not converted to oracle-only or empty successful + responses. + operationId: paymentsFXDirections + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + responses: + '200': + description: Organization-level directed FX availability + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/FXDirectionsData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '412': + description: Dependency precondition failed + content: + application/json: + schema: + $ref: ../response/response.yaml#/components/schemas/ErrorResponse + '503': + $ref: ../response/operation.yaml#/components/responses/ServiceUnavailable diff --git a/backend/contracts/sendico/api/payments/immediate.yaml b/backend/contracts/sendico/api/payments/immediate.yaml new file mode 100644 index 0000000..2a7b885 --- /dev/null +++ b/backend/contracts/sendico/api/payments/immediate.yaml @@ -0,0 +1,48 @@ +post: + tags: [Payments] + summary: Initiate payment from immediate intent + description: | + Creates quote and executes a single payment in one call (request must include `intent`). For + raw `card` destinations, execution requires `exp_month` and `exp_year`; `cardToken` + destinations only require `token`. Card-recipient AML screening occurs during execution. + An unchanged payment reuses its persisted compliance decision on idempotent execution replay, + avoiding a duplicate AML call. A definitive denial terminates the payment with public + `failureCode: failure_restriction` and `failureReason: aml_denied`. + operationId: paymentsInitiateImmediate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: ./request/payment.yaml#/components/schemas/InitiatePaymentRequest + - type: object + required: + - intent + not: + required: + - quoteRef + responses: + '200': + description: Payment initiated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/list.yaml b/backend/contracts/sendico/api/payments/list.yaml new file mode 100644 index 0000000..e01a5c9 --- /dev/null +++ b/backend/contracts/sendico/api/payments/list.yaml @@ -0,0 +1,76 @@ +get: + tags: [Payments] + summary: List payments in organization + description: | + Returns organization payments with cursor pagination and optional filters. + State filters accept aliases (`accepted`, `settled`, `failed`, etc.) or canonical orchestration states. + operationId: paymentsList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/cursor.yaml#/components/parameters/Cursor + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - name: quotation_ref + in: query + required: false + description: Filter by quotation reference used for execution. + schema: + type: string + - name: quote_ref + in: query + required: false + description: Alias of `quotation_ref`. + schema: + type: string + - name: created_from + in: query + required: false + description: Include payments created at or after this RFC3339 timestamp. + schema: + type: string + format: date-time + - name: createdFrom + in: query + required: false + description: Alias of `created_from`. + schema: + type: string + format: date-time + - name: created_to + in: query + required: false + description: Include payments created before this RFC3339 timestamp (must be after `created_from`). + schema: + type: string + format: date-time + - name: createdTo + in: query + required: false + description: Alias of `created_to`. + schema: + type: string + format: date-time + - $ref: ../parameters/state.yaml#/components/parameters/State + - $ref: ../parameters/states.yaml#/components/parameters/States + - $ref: ../parameters/filter_states.yaml#/components/parameters/FilterStates + responses: + '200': + description: Payments list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentsData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/multiquote.yaml b/backend/contracts/sendico/api/payments/multiquote.yaml new file mode 100644 index 0000000..3ac6728 --- /dev/null +++ b/backend/contracts/sendico/api/payments/multiquote.yaml @@ -0,0 +1,37 @@ +post: + tags: [Payments] + summary: Quote multiple payment intents + description: | + Calculates a batch quote for multiple payment intents in one request. Each item includes + `executable`, `state`, and optional `blockReason` so callers can skip blocked items. Raw + `card` destinations can omit expiry while quoting, but quoted items intended for later + execution should include `exp_month` and `exp_year`. Card-recipient AML screening is not + performed during quotation; it runs during payment execution, so an executable quote does + not guarantee AML acceptance. + operationId: paymentsMultiQuote + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + requestBody: + $ref: ./bodies/payment.yaml#/components/requestBodies/QuotePaymentsBody + responses: + '200': + description: Quotes generated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentQuotesData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/object.yaml b/backend/contracts/sendico/api/payments/object.yaml new file mode 100644 index 0000000..0b71752 --- /dev/null +++ b/backend/contracts/sendico/api/payments/object.yaml @@ -0,0 +1,31 @@ +get: + tags: [Payments] + summary: Get payment by reference + description: Returns a single payment by `payments_ref`. + operationId: paymentsGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/payments_ref.yaml#/components/parameters/PaymentsRef + responses: + '200': + description: Payment data + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/quote.yaml b/backend/contracts/sendico/api/payments/quote.yaml new file mode 100644 index 0000000..d409554 --- /dev/null +++ b/backend/contracts/sendico/api/payments/quote.yaml @@ -0,0 +1,37 @@ +post: + tags: [Payments] + summary: Quote single payment intent + description: | + Calculates quote for one payment intent without executing payment. The response includes + `quote.executable`, `quote.state`, and optional `quote.blockReason` so clients can reject + non-executable quotes before attempting execution. Raw `card` destinations can omit expiry + while quoting, but quotes intended for later execution should include `exp_month` and + `exp_year`. Card-recipient AML screening is not performed during quotation; it runs during + payment execution, so an executable quote does not guarantee AML acceptance. + operationId: paymentsQuote + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + requestBody: + $ref: ./bodies/payment.yaml#/components/requestBodies/QuotePaymentBody + responses: + '200': + description: Quote generated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/payment.yaml#/components/schemas/PaymentQuoteData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/registry.yaml b/backend/contracts/sendico/api/payments/registry.yaml new file mode 100644 index 0000000..42d796d --- /dev/null +++ b/backend/contracts/sendico/api/payments/registry.yaml @@ -0,0 +1,30 @@ +get: + tags: [Payments] + summary: Get discovery registry snapshot for payments dependencies + description: Returns the latest discovery lookup payload used by payments integrations. + operationId: paymentsRegistryList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + responses: + '200': + description: Discovery registry payload + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + type: object + additionalProperties: true + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/registry_refresh.yaml b/backend/contracts/sendico/api/payments/registry_refresh.yaml new file mode 100644 index 0000000..4719af7 --- /dev/null +++ b/backend/contracts/sendico/api/payments/registry_refresh.yaml @@ -0,0 +1,32 @@ +get: + tags: [Payments] + summary: Get latest discovery refresh event for payments dependencies + description: Returns the latest refresh event consumed by the payments API. + operationId: paymentsRegistryRefreshGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + responses: + '200': + description: Discovery refresh payload + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + type: + - object + - 'null' + additionalProperties: true + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/request/payment.yaml b/backend/contracts/sendico/api/payments/request/payment.yaml new file mode 100644 index 0000000..3c425e3 --- /dev/null +++ b/backend/contracts/sendico/api/payments/request/payment.yaml @@ -0,0 +1,163 @@ +components: + schemas: + PaymentBase: + description: Base fields shared by payment initiation request payloads. + type: object + additionalProperties: false + required: + - idempotencyKey + properties: + idempotencyKey: + description: Client-supplied key used to safely deduplicate requests. + type: string + metadata: + description: Optional request metadata forwarded through payment processing. + type: object + additionalProperties: + type: string + + QuotePaymentRequest: + description: | + Request payload to quote a single payment intent. + For raw `card` destinations, quotation only requires `pan`. + If the quoted payment may later be executed, include `exp_month` and `exp_year` during quotation because execution reuses the quoted endpoint snapshot. + type: object + additionalProperties: false + required: + - intent + properties: + idempotencyKey: + description: Idempotency key used when persisting quote context. + type: string + metadata: + description: Optional metadata associated with the quote request. + type: object + additionalProperties: + type: string + clientPaymentRef: + description: Optional caller-side payment correlation reference to persist with the quote and reuse during execution when the execute request omits it. + type: string + intent: + description: Payment intent to be priced. See `PaymentIntent.destination` for raw-card quote versus execution requirements. + $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent + previewOnly: + description: If true, returns a preview quote without requiring idempotency. + type: boolean + allOf: + - if: + properties: + previewOnly: + const: true + required: + - previewOnly + then: + not: + required: + - idempotencyKey + else: + required: + - idempotencyKey + + QuotePaymentsRequest: + description: | + Request payload to quote multiple payment intents in a single call. + For raw `card` destinations, quotation only requires `pan`. + If quoted items may later be executed, include `exp_month` and `exp_year` during quotation because execution reuses the quoted endpoint snapshot. + type: object + additionalProperties: false + required: + - intents + properties: + idempotencyKey: + description: Idempotency key used when persisting batch quote context. + type: string + metadata: + description: Optional metadata associated with the quote request. + type: object + additionalProperties: + type: string + clientPaymentRef: + description: Optional caller-side batch payment correlation reference to persist with the quote and reuse during execution when the execute request omits it. + type: string + intents: + description: List of payment intents to be priced. See each intent destination for raw-card quote versus execution requirements. + type: array + minItems: 1 + items: + $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent + previewOnly: + description: If true, returns preview quotes without requiring idempotency. + type: boolean + allOf: + - if: + properties: + previewOnly: + const: true + required: + - previewOnly + then: + not: + required: + - idempotencyKey + else: + required: + - idempotencyKey + + InitiatePaymentRequest: + description: | + Request payload to initiate a single payment. + When executing a raw `card` destination from inline `intent`, `pan`, `exp_month`, and `exp_year` must be present. + When executing a `cardToken` destination, only `token` is required by current logic. + allOf: + - $ref: ./payment.yaml#/components/schemas/PaymentBase + - type: object + additionalProperties: false + oneOf: + - required: [intent] + - required: [quoteRef] + properties: + intent: + description: Payment intent to execute directly. Raw `card` destinations require expiry at execution time. + $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent + quoteRef: + description: Reference to a previously generated quote to execute. If that quote contains a raw `card` destination, the original quote must already include `exp_month` and `exp_year`. + type: string + clientPaymentRef: + description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. + type: string + + InitiatePaymentsRequest: + description: | + Request payload to initiate multiple payments from a multi-quote reference. + If any quoted item contains a raw `card` destination, that item's original quote must already include `exp_month` and `exp_year`. + allOf: + - $ref: ./payment.yaml#/components/schemas/PaymentBase + - type: object + additionalProperties: false + required: + - quoteRef + properties: + quoteRef: + description: Reference to a previously generated multi-quote. Raw-card items execute from the stored quoted endpoint snapshot. + type: string + clientPaymentRef: + description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. + type: string + + InitiatePaymentByQuoteRequest: + description: | + Request payload to initiate one payment from an existing quote reference. + If the quote contains a raw `card` destination, the original quote must already include `exp_month` and `exp_year`. + allOf: + - $ref: ./payment.yaml#/components/schemas/PaymentBase + - type: object + additionalProperties: false + required: + - quoteRef + properties: + quoteRef: + description: Reference to a previously generated quote to execute. Execution reuses the stored quoted endpoint snapshot. + type: string + clientPaymentRef: + description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. + type: string diff --git a/backend/contracts/sendico/api/payments/response/payment.yaml b/backend/contracts/sendico/api/payments/response/payment.yaml new file mode 100644 index 0000000..d4c2938 --- /dev/null +++ b/backend/contracts/sendico/api/payments/response/payment.yaml @@ -0,0 +1,114 @@ +components: + schemas: + PaymentQuoteData: + description: Response payload for a single payment quote. + type: object + additionalProperties: false + required: + - accessToken + - quote + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + idempotencyKey: + description: Idempotency key associated with the quote response. + type: string + clientPaymentRef: + description: Caller-supplied payment correlation reference echoed from the quote request, when provided. + type: string + quote: + description: Generated quote data for the requested payment intent. + $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentQuote + + PaymentQuotesData: + description: Response payload for a batch quote request. + type: object + additionalProperties: false + required: + - accessToken + - quote + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + quote: + description: Batch quote summary and quoted items. + type: object + additionalProperties: false + properties: + idempotencyKey: + description: Idempotency key associated with the batch quote response. + type: string + clientPaymentRef: + description: Caller-supplied payment correlation reference echoed from the batch quote request, when provided. + type: string + quoteRef: + description: Reference to the generated batch quote. + type: string + items: + description: Collection of quotes for each requested payment intent. + type: array + items: + $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentQuote + + PaymentsData: + description: Response payload containing a list of payments. + type: object + additionalProperties: false + required: + - accessToken + - payments + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + payments: + description: Collection of payment records. + type: array + items: + $ref: ../../../models/payment/payment.yaml#/components/schemas/Payment + page: + description: Pagination cursor metadata for payment listing endpoints. + $ref: ../../../models/common/pagination.yaml#/components/schemas/CursorPageResponse + + PaymentData: + description: Response payload containing a single payment record. + type: object + additionalProperties: false + required: + - accessToken + - payment + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + payment: + description: Requested payment record. + $ref: ../../../models/payment/payment.yaml#/components/schemas/Payment + + FXCurrenciesData: + description: Response payload containing the BFF startup-cached Treasury FX currency catalogue projection. + type: object + additionalProperties: false + required: + - currencies + properties: + currencies: + description: Client-visible currencies backed by Treasury Currency Catalog metadata. + type: array + items: + $ref: ../../../models/payment/fx_metadata.yaml#/components/schemas/FXCurrency + + FXDirectionsData: + description: Response payload containing organization-level directed FX availability. + type: object + additionalProperties: false + required: + - directions + properties: + directions: + description: Directions with at least one eligible direction-level route for the organization. + type: array + items: + $ref: ../../../models/payment/fx_metadata.yaml#/components/schemas/FXDirection diff --git a/backend/contracts/sendico/api/recipients/archive.yaml b/backend/contracts/sendico/api/recipients/archive.yaml new file mode 100644 index 0000000..4d3cb3f --- /dev/null +++ b/backend/contracts/sendico/api/recipients/archive.yaml @@ -0,0 +1,37 @@ +get: + tags: [Recipients] + summary: Archive/unarchive recipient + description: Sets recipient archive state by `recipients_ref` and required `archived` query parameter. + operationId: recipientsArchive + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef + - name: archived + in: query + required: true + description: Target archive value to set on the recipient. + schema: + type: boolean + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Archive state updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/bodies/recipient.yaml b/backend/contracts/sendico/api/recipients/bodies/recipient.yaml new file mode 100644 index 0000000..5785002 --- /dev/null +++ b/backend/contracts/sendico/api/recipients/bodies/recipient.yaml @@ -0,0 +1,17 @@ +components: + requestBodies: + RecipientCreateBody: + description: JSON body containing recipient details for create. + required: true + content: + application/json: + schema: + $ref: ../request/recipient.yaml#/components/schemas/RecipientCreateRequest + + RecipientUpdateBody: + description: JSON body containing recipient details for update. + required: true + content: + application/json: + schema: + $ref: ../request/recipient.yaml#/components/schemas/RecipientUpdateRequest diff --git a/backend/contracts/sendico/api/recipients/create.yaml b/backend/contracts/sendico/api/recipients/create.yaml new file mode 100644 index 0000000..10fc1ba --- /dev/null +++ b/backend/contracts/sendico/api/recipients/create.yaml @@ -0,0 +1,33 @@ +post: + tags: [Recipients] + summary: Create recipient + description: Creates a recipient inside the organization identified by `org_ref`. + operationId: recipientsCreate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + requestBody: + $ref: ./bodies/recipient.yaml#/components/requestBodies/RecipientCreateBody + responses: + '201': + description: Recipient created + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/list.yaml b/backend/contracts/sendico/api/recipients/list.yaml new file mode 100644 index 0000000..33ca97f --- /dev/null +++ b/backend/contracts/sendico/api/recipients/list.yaml @@ -0,0 +1,36 @@ +get: + tags: [Recipients] + summary: List recipients + description: | + Returns recipients for the specified organization context. + Supports offset pagination and optional archive filtering. + The route currently includes both `org_ref` and `organizations_ref`; the handler resolves organization context from `org_ref` and ignores `organizations_ref`. + operationId: recipientsList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + - $ref: ../parameters/archived.yaml#/components/parameters/Archived + responses: + '200': + description: Recipient list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/object.yaml b/backend/contracts/sendico/api/recipients/object.yaml new file mode 100644 index 0000000..091e5c7 --- /dev/null +++ b/backend/contracts/sendico/api/recipients/object.yaml @@ -0,0 +1,64 @@ +get: + tags: [Recipients] + summary: Get recipient by reference + description: Returns a recipient by `recipients_ref`. + operationId: recipientsGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef + responses: + '200': + description: Recipient data + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +delete: + tags: [Recipients] + summary: Delete recipient + description: Deletes recipient by reference. Use `cascade=true` to remove dependent objects when supported. + operationId: recipientsDelete + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Recipient deleted + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/request/recipient.yaml b/backend/contracts/sendico/api/recipients/request/recipient.yaml new file mode 100644 index 0000000..19656c4 --- /dev/null +++ b/backend/contracts/sendico/api/recipients/request/recipient.yaml @@ -0,0 +1,53 @@ +components: + schemas: + RecipientBaseRequest: + type: object + additionalProperties: false + properties: + id: + description: Optional recipient identifier. Required by the current update handler. + $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId + name: + description: Optional human-readable recipient name. + type: string + description: + description: Optional free-form recipient description. + type: string + nullable: true + email: + description: Optional recipient email address. + type: string + format: email + avatarUrl: + description: Optional avatar URL. + type: string + nullable: true + status: + description: Optional recipient status. When omitted, current handler behavior falls back to the model zero value (`ready`). + type: string + enum: + - ready + - registered + - notRegistered + type: + description: Optional recipient type. When omitted, current handler behavior falls back to the model zero value (`internal`). + type: string + enum: + - internal + - external + + RecipientCreateRequest: + description: | + Request payload used to create a recipient. + The current create handler does not require any JSON fields beyond a valid object payload; organization context comes from the `org_ref` path parameter. + allOf: + - $ref: '#/components/schemas/RecipientBaseRequest' + + RecipientUpdateRequest: + description: | + Request payload used to update a recipient. + The current PUT handler targets the record by `id` and then replaces it with the submitted values; omitted fields may therefore be reset to zero values. + required: + - id + allOf: + - $ref: '#/components/schemas/RecipientBaseRequest' diff --git a/backend/contracts/sendico/api/recipients/response/recipient.yaml b/backend/contracts/sendico/api/recipients/response/recipient.yaml new file mode 100644 index 0000000..337b850 --- /dev/null +++ b/backend/contracts/sendico/api/recipients/response/recipient.yaml @@ -0,0 +1,18 @@ +components: + schemas: + RecipientsAuthData: + description: Authenticated response payload containing recipients. + type: object + additionalProperties: false + required: + - accessToken + - recipients + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + recipients: + description: Collection of recipients available to the authenticated account. + type: array + items: + $ref: ../../../models/recipient/recipient.yaml#/components/schemas/Recipient diff --git a/backend/contracts/sendico/api/recipients/update.yaml b/backend/contracts/sendico/api/recipients/update.yaml new file mode 100644 index 0000000..f814f5b --- /dev/null +++ b/backend/contracts/sendico/api/recipients/update.yaml @@ -0,0 +1,31 @@ +put: + tags: [Recipients] + summary: Update recipient + description: Updates recipient fields from request payload. The current handler requires `id`; omitted fields may be replaced with zero values. + operationId: recipientsUpdate + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/recipient.yaml#/components/requestBodies/RecipientUpdateBody + responses: + '200': + description: Recipient updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/response/error.yaml b/backend/contracts/sendico/api/response/error.yaml new file mode 100644 index 0000000..c4a7014 --- /dev/null +++ b/backend/contracts/sendico/api/response/error.yaml @@ -0,0 +1,24 @@ +components: + schemas: + ApiError: + description: Standard API error envelope with optional debugging details. + type: object + additionalProperties: false + required: + - code + - error + - source + properties: + code: + description: Application-specific numeric error code. + type: integer + format: int32 + error: + description: Human-readable error summary. + type: string + source: + description: Component or subsystem that produced the error. + type: string + details: + description: Optional detailed message providing additional failure context. + type: string diff --git a/backend/contracts/sendico/api/response/operation.yaml b/backend/contracts/sendico/api/response/operation.yaml new file mode 100644 index 0000000..08c5a5f --- /dev/null +++ b/backend/contracts/sendico/api/response/operation.yaml @@ -0,0 +1,85 @@ +components: + responses: + Ok: + description: Successful response + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/BaseResponse + + Created: + description: Resource created + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/BaseResponse + + Accepted: + description: Request accepted for processing + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/BaseResponse + + BadRequest: + description: Bad request payload or query/path parameter + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + Gone: + description: Resource is no longer available + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + Conflict: + description: Conflict + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + TooManyRequests: + description: Too many requests + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + ServiceUnavailable: + description: Service unavailable + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse + + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: ./response.yaml#/components/schemas/ErrorResponse diff --git a/backend/contracts/sendico/api/response/response.yaml b/backend/contracts/sendico/api/response/response.yaml new file mode 100644 index 0000000..89589e5 --- /dev/null +++ b/backend/contracts/sendico/api/response/response.yaml @@ -0,0 +1,47 @@ +components: + schemas: + BaseResponse: + type: object + additionalProperties: false + required: + - status + - data + properties: + status: + $ref: ./status.yaml#/components/schemas/ApiStatus + data: + description: Payload for success or error responses. + + ErrorResponse: + allOf: + - $ref: ./response.yaml#/components/schemas/BaseResponse + - type: object + properties: + status: + type: string + enum: + - error + data: + $ref: ./error.yaml#/components/schemas/ApiError + + SuccessResultData: + type: object + additionalProperties: false + required: + - result + properties: + result: + type: boolean + + SuccessResultResponse: + allOf: + - $ref: ./response.yaml#/components/schemas/BaseResponse + - type: object + properties: + status: + type: string + enum: + - success + - processed + data: + $ref: ./response.yaml#/components/schemas/SuccessResultData diff --git a/backend/contracts/sendico/api/response/status.yaml b/backend/contracts/sendico/api/response/status.yaml new file mode 100644 index 0000000..1a7e123 --- /dev/null +++ b/backend/contracts/sendico/api/response/status.yaml @@ -0,0 +1,10 @@ +components: + schemas: + ApiStatus: + type: string + description: Unified response status used by backend wrappers. + enum: + - success + - processed + - error + - request diff --git a/backend/contracts/sendico/api/verification/bodies/verification.yaml b/backend/contracts/sendico/api/verification/bodies/verification.yaml new file mode 100644 index 0000000..da9dbd6 --- /dev/null +++ b/backend/contracts/sendico/api/verification/bodies/verification.yaml @@ -0,0 +1,17 @@ +components: + requestBodies: + VerificationCodeBody: + description: JSON payload used to request a new verification code. + required: true + content: + application/json: + schema: + $ref: ../request/verification.yaml#/components/schemas/VerificationCodeRequest + + VerifyCodeBody: + description: JSON payload containing a verification code to validate. + required: true + content: + application/json: + schema: + $ref: ../request/verification.yaml#/components/schemas/CodeVerificationRequest diff --git a/backend/contracts/sendico/api/verification/create.yaml b/backend/contracts/sendico/api/verification/create.yaml new file mode 100644 index 0000000..9dd4e97 --- /dev/null +++ b/backend/contracts/sendico/api/verification/create.yaml @@ -0,0 +1,37 @@ +post: + tags: [Verification, Auth] + summary: Request verification code + description: | + Creates a verification challenge for the specified purpose/target. + For `purpose=login`, request must be authorized with a pending account token. + operationId: verificationRequestCode + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/verification.yaml#/components/requestBodies/VerificationCodeBody + responses: + '202': + description: Verification code requested + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/verification.yaml#/components/schemas/VerificationResponseData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '429': + $ref: ../response/operation.yaml#/components/responses/TooManyRequests + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/verification/request/verification.yaml b/backend/contracts/sendico/api/verification/request/verification.yaml new file mode 100644 index 0000000..a005098 --- /dev/null +++ b/backend/contracts/sendico/api/verification/request/verification.yaml @@ -0,0 +1,73 @@ +components: + schemas: + VerificationCodeRequest: + type: object + additionalProperties: false + required: + - purpose + properties: + purpose: + type: string + description: Verification purpose, e.g. login. + target: + type: string + format: email + description: Optional destination override. When omitted, current handler falls back to the authenticated account email/login. + idempotencyKey: + type: string + quoteRef: + description: Required when `purpose` is `payout`; omitted for login and other verification purposes. + type: string + allOf: + - if: + properties: + purpose: + const: payout + required: + - purpose + then: + required: + - quoteRef + + CodeVerificationRequest: + type: object + additionalProperties: false + required: + - purpose + - code + properties: + purpose: + type: string + description: Verification purpose, e.g. login. + target: + type: string + format: email + description: Optional destination override. When omitted, current handler falls back to the authenticated account email/login. + idempotencyKey: + type: string + quoteRef: + description: Required when `purpose` is `payout`; omitted for login and other verification purposes. + type: string + code: + type: string + sessionIdentifier: + $ref: ../../../models/auth/session_identifier.yaml#/components/schemas/SessionIdentifier + allOf: + - if: + properties: + purpose: + const: login + required: + - purpose + then: + required: + - sessionIdentifier + - if: + properties: + purpose: + const: payout + required: + - purpose + then: + required: + - quoteRef diff --git a/backend/contracts/sendico/api/verification/resend.yaml b/backend/contracts/sendico/api/verification/resend.yaml new file mode 100644 index 0000000..d2ee0fc --- /dev/null +++ b/backend/contracts/sendico/api/verification/resend.yaml @@ -0,0 +1,37 @@ +post: + tags: [Verification, Auth] + summary: Resend verification code + description: | + Re-issues a verification challenge using the same request shape as `/verification`. + Cooldown and retry limits are enforced by backend verification storage policy. + operationId: verificationResendCode + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/verification.yaml#/components/requestBodies/VerificationCodeBody + responses: + '202': + description: Verification code resent + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/verification.yaml#/components/schemas/VerificationResponseData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '429': + $ref: ../response/operation.yaml#/components/responses/TooManyRequests + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/verification/response/verification.yaml b/backend/contracts/sendico/api/verification/response/verification.yaml new file mode 100644 index 0000000..f169ab7 --- /dev/null +++ b/backend/contracts/sendico/api/verification/response/verification.yaml @@ -0,0 +1,35 @@ +components: + schemas: + VerificationResponseData: + description: Response data returned after requesting a verification code. + type: object + additionalProperties: false + required: + - idempotencyKey + - ttl_seconds + - cooldown_seconds + - target + properties: + idempotencyKey: + description: Idempotency key associated with the verification request. + type: string + ttl_seconds: + description: Verification code validity period in seconds. + type: integer + format: int32 + cooldown_seconds: + description: Cooldown period before another code can be requested. + type: integer + format: int32 + target: + description: Destination where the verification code was sent. + type: string + quoteRef: + description: Payment quote reference echoed for payout verification requests. + type: string + + VerifyResultData: + description: Result payload returned after code verification succeeds. + oneOf: + - $ref: ../../accounts/response/auth.yaml#/components/schemas/LoginData + - $ref: ../../response/response.yaml#/components/schemas/SuccessResultData diff --git a/backend/contracts/sendico/api/verification/verify.yaml b/backend/contracts/sendico/api/verification/verify.yaml new file mode 100644 index 0000000..cf26d7b --- /dev/null +++ b/backend/contracts/sendico/api/verification/verify.yaml @@ -0,0 +1,37 @@ +post: + tags: [Verification, Auth] + summary: Verify code and complete pending flow + description: | + Verifies submitted code and consumes verification token. + For login purpose, returns full auth payload (access + refresh tokens); for other purposes returns operation result. + operationId: verificationVerifyCode + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/verification.yaml#/components/requestBodies/VerifyCodeBody + responses: + '200': + description: Verification successful + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/verification.yaml#/components/schemas/VerifyResultData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '410': + $ref: ../response/operation.yaml#/components/responses/Gone + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/wallets/assets.yaml b/backend/contracts/sendico/api/wallets/assets.yaml new file mode 100644 index 0000000..17c8b22 --- /dev/null +++ b/backend/contracts/sendico/api/wallets/assets.yaml @@ -0,0 +1,35 @@ +get: + tags: [Wallets] + summary: List live managed-wallet assets + description: | + Returns the live wallet asset catalogue currently announced by healthy CRYPTO + gateways in discovery. The endpoint is authenticated and authorized by + `organizations_ref`, but the returned asset list is gateway-driven rather than + organization-customized. Malformed gateway asset announcements are skipped. + If discovery is available but no valid wallet assets are currently announced, + the response contains an empty `assets` array. + operationId: walletAssetsList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + responses: + '200': + description: Live managed-wallet asset catalogue + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/wallet.yaml#/components/schemas/WalletAssetsAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/wallets/response/wallet.yaml b/backend/contracts/sendico/api/wallets/response/wallet.yaml new file mode 100644 index 0000000..b390b85 --- /dev/null +++ b/backend/contracts/sendico/api/wallets/response/wallet.yaml @@ -0,0 +1,18 @@ +components: + schemas: + WalletAssetsAuthData: + description: Authenticated response payload containing live wallet assets. + type: object + additionalProperties: false + required: + - accessToken + - assets + properties: + accessToken: + description: Refreshed access token to be used in subsequent API calls. + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + assets: + description: Managed-wallet assets currently announced by healthy CRYPTO gateways. + type: array + items: + $ref: ../../../models/wallet/wallet_asset.yaml#/components/schemas/WalletAsset diff --git a/backend/contracts/sendico/external/chain_network.yaml b/backend/contracts/sendico/external/chain_network.yaml new file mode 100644 index 0000000..c577011 --- /dev/null +++ b/backend/contracts/sendico/external/chain_network.yaml @@ -0,0 +1,10 @@ +components: + schemas: + ChainNetwork: + type: string + enum: + - unspecified + - ethereum_mainnet + - arbitrum_one + - tron_mainnet + - tron_nile diff --git a/backend/contracts/sendico/external/endpoint_type.yaml b/backend/contracts/sendico/external/endpoint_type.yaml new file mode 100644 index 0000000..3a23e42 --- /dev/null +++ b/backend/contracts/sendico/external/endpoint_type.yaml @@ -0,0 +1,15 @@ +components: + schemas: + EndpointType: + type: string + enum: + - paymentMethod + - account + - recipient + - ledger + - managedWallet + - cryptoAddress + - card + - cardToken + - bankAccount + - iban diff --git a/backend/contracts/sendico/external/fx_side.yaml b/backend/contracts/sendico/external/fx_side.yaml new file mode 100644 index 0000000..639e6f2 --- /dev/null +++ b/backend/contracts/sendico/external/fx_side.yaml @@ -0,0 +1,7 @@ +components: + schemas: + FxSide: + type: string + enum: + - buy_base_sell_quote + - sell_base_buy_quote diff --git a/backend/contracts/sendico/external/payment_kind.yaml b/backend/contracts/sendico/external/payment_kind.yaml new file mode 100644 index 0000000..06f68ef --- /dev/null +++ b/backend/contracts/sendico/external/payment_kind.yaml @@ -0,0 +1,8 @@ +components: + schemas: + PaymentKind: + type: string + enum: + - payout + - internal_transfer + - fx_conversion diff --git a/backend/contracts/sendico/external/payment_method_type.yaml b/backend/contracts/sendico/external/payment_method_type.yaml new file mode 100644 index 0000000..c8c4e84 --- /dev/null +++ b/backend/contracts/sendico/external/payment_method_type.yaml @@ -0,0 +1,12 @@ +components: + schemas: + PaymentMethodType: + type: string + enum: + - iban + - card + - cardToken + - bankAccount + - wallet + - cryptoAddress + - ledger diff --git a/backend/contracts/sendico/external/payment_state.yaml b/backend/contracts/sendico/external/payment_state.yaml new file mode 100644 index 0000000..9685cd8 --- /dev/null +++ b/backend/contracts/sendico/external/payment_state.yaml @@ -0,0 +1,11 @@ +components: + schemas: + PaymentState: + type: string + enum: + - orchestration_state_unspecified + - orchestration_state_created + - orchestration_state_executing + - orchestration_state_needs_attention + - orchestration_state_settled + - orchestration_state_failed diff --git a/backend/contracts/sendico/external/settlement_mode.yaml b/backend/contracts/sendico/external/settlement_mode.yaml new file mode 100644 index 0000000..e5531c9 --- /dev/null +++ b/backend/contracts/sendico/external/settlement_mode.yaml @@ -0,0 +1,8 @@ +components: + schemas: + SettlementMode: + type: string + enum: + - unspecified + - fix_source + - fix_received diff --git a/backend/contracts/sendico/models/account/account.yaml b/backend/contracts/sendico/models/account/account.yaml new file mode 100644 index 0000000..b47ae25 --- /dev/null +++ b/backend/contracts/sendico/models/account/account.yaml @@ -0,0 +1,15 @@ +components: + schemas: + AccountData: + description: Extended account payload including visibility-specific metadata. + allOf: + - $ref: ./account_public.yaml#/components/schemas/AccountPublic + - type: object + description: Account attributes that are not part of the public profile. + additionalProperties: false + required: + - isAnonymous + properties: + isAnonymous: + description: Indicates whether the account is marked as anonymous. + type: boolean diff --git a/backend/contracts/sendico/models/account/account_public.yaml b/backend/contracts/sendico/models/account/account_public.yaml new file mode 100644 index 0000000..3f80789 --- /dev/null +++ b/backend/contracts/sendico/models/account/account_public.yaml @@ -0,0 +1,33 @@ +components: + schemas: + AccountPublic: + description: Public account profile data exposed in API responses. + allOf: + - $ref: ../storable.yaml#/components/schemas/Storable + - $ref: ../common/describable.yaml#/components/schemas/Describable + - type: object + description: Account-specific public attributes. + additionalProperties: false + required: + - login + - locale + - lastName + - isArchived + properties: + login: + description: Account login identifier represented as an email address. + type: string + format: email + locale: + description: Preferred locale used for account-facing content. + type: string + lastName: + description: Account holder last name. + type: string + avatarUrl: + description: Optional URL of the account avatar image. + type: string + nullable: true + isArchived: + description: Indicates whether the account is archived and inactive. + type: boolean diff --git a/backend/contracts/sendico/models/auth/client_refresh_token.yaml b/backend/contracts/sendico/models/auth/client_refresh_token.yaml new file mode 100644 index 0000000..43408e3 --- /dev/null +++ b/backend/contracts/sendico/models/auth/client_refresh_token.yaml @@ -0,0 +1,15 @@ +components: + schemas: + ClientRefreshToken: + description: Refresh token bound to a specific client and device session. + allOf: + - $ref: ./session_identifier.yaml#/components/schemas/SessionIdentifier + - type: object + description: Refresh-token payload associated with the session identifier. + additionalProperties: false + required: + - token + properties: + token: + description: Long-lived token used to obtain a new access token. + type: string diff --git a/backend/contracts/sendico/models/auth/login_data.yaml b/backend/contracts/sendico/models/auth/login_data.yaml new file mode 100644 index 0000000..480b3d2 --- /dev/null +++ b/backend/contracts/sendico/models/auth/login_data.yaml @@ -0,0 +1,21 @@ +components: + schemas: + LoginData: + description: Credentials and optional locale used during user sign-in. + type: object + additionalProperties: false + required: + - login + - password + properties: + login: + description: User login identifier, represented as an email address. + type: string + format: email + password: + description: User password submitted for authentication. + type: string + format: password + locale: + description: Optional locale preference provided at login time. + type: string diff --git a/backend/contracts/sendico/models/auth/session_identifier.yaml b/backend/contracts/sendico/models/auth/session_identifier.yaml new file mode 100644 index 0000000..fba55b0 --- /dev/null +++ b/backend/contracts/sendico/models/auth/session_identifier.yaml @@ -0,0 +1,16 @@ +components: + schemas: + SessionIdentifier: + description: Identifies an authentication session by client and device. + type: object + additionalProperties: false + required: + - clientId + - deviceId + properties: + clientId: + description: Client application identifier associated with the session. + type: string + deviceId: + description: Device/server identifier associated with the client session. + type: string diff --git a/backend/contracts/sendico/models/auth/token_data.yaml b/backend/contracts/sendico/models/auth/token_data.yaml new file mode 100644 index 0000000..5e6264d --- /dev/null +++ b/backend/contracts/sendico/models/auth/token_data.yaml @@ -0,0 +1,17 @@ +components: + schemas: + TokenData: + description: Authentication token payload with expiration metadata. + type: object + additionalProperties: false + required: + - token + - expiration + properties: + token: + description: Issued authentication token value. + type: string + expiration: + description: RFC 3339 timestamp when the token expires. + type: string + format: date-time diff --git a/backend/contracts/sendico/models/callback/callback.yaml b/backend/contracts/sendico/models/callback/callback.yaml new file mode 100644 index 0000000..f555a05 --- /dev/null +++ b/backend/contracts/sendico/models/callback/callback.yaml @@ -0,0 +1,152 @@ +components: + schemas: + CallbackBackoff: + description: Backoff window used between callback retry attempts. + type: object + additionalProperties: false + required: + - minDelayMs + - maxDelayMs + properties: + minDelayMs: + description: Minimum delay in milliseconds before scheduling a retry attempt. + type: integer + minimum: 1 + maxDelayMs: + description: Maximum delay in milliseconds before scheduling a retry attempt. + type: integer + minimum: 1 + + CallbackRetryPolicy: + description: Retry and delivery behavior for callback requests. + type: object + additionalProperties: false + required: + - backoff + - signingMode + - maxAttempts + - requestTimeoutMs + properties: + backoff: + description: Delay boundaries used to compute retry backoff between attempts. + $ref: '#/components/schemas/CallbackBackoff' + signingMode: + description: Request-signing strategy applied when dispatching callback payloads. `jws_eddsa_ed25519` sends the raw JSON body unchanged and includes a detached compact JWS in `X-Callback-JWS`. + type: string + enum: + - none + - hmac_sha256 + - jws_eddsa_ed25519 + headers: + description: Additional HTTP headers included with every callback request. + type: object + additionalProperties: + type: string + maxAttempts: + description: Maximum number of delivery attempts before marking dispatch as failed. + type: integer + minimum: 1 + requestTimeoutMs: + description: Per-request timeout in milliseconds for callback HTTP calls. + type: integer + minimum: 1 + + CallbackSigningJWK: + description: Ed25519 verification key published for callbacks that use detached JWS signing. + type: object + additionalProperties: false + required: + - kty + - crv + - alg + - use + - kid + - x + properties: + kty: + type: string + enum: [OKP] + crv: + type: string + enum: [Ed25519] + alg: + type: string + enum: [EdDSA] + use: + type: string + enum: [sig] + kid: + description: Opaque key identifier mirrored into the protected JWS header and `X-Callback-Key-Id`. + type: string + x: + description: Base64url-encoded Ed25519 public key bytes used to verify the detached JWS signature. + type: string + + CallbackSigningKey: + description: Verification material exposed for callbacks that use `jws_eddsa_ed25519`. Receivers verify `X-Callback-JWS` against the raw request body with the matching `kid`. Retiring keys remain available for 24 hours after rotation so queued deliveries remain verifiable. + type: object + additionalProperties: false + required: + - status + - jwk + properties: + status: + description: Indicates whether the key is the current signing key or a still-valid retiring key from a recent rotation. + type: string + enum: + - active + - retiring + expiresAt: + description: Timestamp after which a retiring key is no longer returned in callback reads. + type: string + format: date-time + nullable: true + jwk: + $ref: '#/components/schemas/CallbackSigningJWK' + + CallbackContent: + description: Callback endpoint configuration and delivery parameters. + allOf: + - type: object + additionalProperties: false + required: + - status + - url + - eventTypes + - retryPolicy + properties: + status: + description: Operational state controlling whether callback delivery is enabled. + type: string + enum: + - active + - disabled + url: + description: Absolute HTTPS endpoint URL that receives callback events. + type: string + format: uri + eventTypes: + description: Event type names that trigger callback delivery to this endpoint. + type: array + items: + type: string + retryPolicy: + description: Retry and timeout parameters used when dispatching callback events. + $ref: '#/components/schemas/CallbackRetryPolicy' + + + Callback: + description: Stored callback configuration bound to an organization permission scope. + allOf: + - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound + - $ref: ../common/describable.yaml#/components/schemas/Describable + - $ref: '#/components/schemas/CallbackContent' + - type: object + additionalProperties: false + properties: + signingKeys: + description: Read-only verification keys for callbacks that use detached JWS signing. The callback JSON body is not wrapped; receivers verify `X-Callback-JWS` against the raw body bytes and use the key matching the protected-header `kid`. + type: array + readOnly: true + items: + $ref: '#/components/schemas/CallbackSigningKey' diff --git a/backend/contracts/sendico/models/common/describable.yaml b/backend/contracts/sendico/models/common/describable.yaml new file mode 100644 index 0000000..1bf6d5b --- /dev/null +++ b/backend/contracts/sendico/models/common/describable.yaml @@ -0,0 +1,16 @@ +components: + schemas: + Describable: + description: Common naming metadata for user-defined entities. + type: object + additionalProperties: false + required: + - name + properties: + name: + description: Human-readable name of the entity. + type: string + description: + description: Optional free-form description of the entity. + type: string + nullable: true diff --git a/backend/contracts/sendico/models/common/money.yaml b/backend/contracts/sendico/models/common/money.yaml new file mode 100644 index 0000000..b130b4c --- /dev/null +++ b/backend/contracts/sendico/models/common/money.yaml @@ -0,0 +1,18 @@ +components: + schemas: + Money: + description: Monetary value represented by a decimal amount and ISO currency code. + type: object + additionalProperties: false + required: + - amount + - currency + properties: + amount: + type: string + description: Decimal string amount. + example: '125.50' + currency: + description: ISO 4217 currency code for the provided amount. + type: string + example: USD diff --git a/backend/contracts/sendico/models/common/pagination.yaml b/backend/contracts/sendico/models/common/pagination.yaml new file mode 100644 index 0000000..2a923c3 --- /dev/null +++ b/backend/contracts/sendico/models/common/pagination.yaml @@ -0,0 +1,10 @@ +components: + schemas: + CursorPageResponse: + description: Cursor-based pagination metadata returned with list responses. + type: object + additionalProperties: false + properties: + next_cursor: + description: Opaque cursor to request the next page; omitted when no more items exist. + type: string diff --git a/backend/contracts/sendico/models/ledger/balance.yaml b/backend/contracts/sendico/models/ledger/balance.yaml new file mode 100644 index 0000000..df4423b --- /dev/null +++ b/backend/contracts/sendico/models/ledger/balance.yaml @@ -0,0 +1,53 @@ +components: + schemas: + LedgerAccountBalance: + description: Canonical ledger account balance. Current and historical modes have distinct field-presence semantics. + oneOf: + - $ref: ./balance.yaml#/components/schemas/CurrentLedgerAccountBalance + - $ref: ./balance.yaml#/components/schemas/HistoricalLedgerAccountBalance + + CurrentLedgerAccountBalance: + description: Current materialized ledger account balance. + type: object + additionalProperties: false + required: + - ledgerAccountRef + - balance + - version + - lastUpdated + properties: + ledgerAccountRef: + description: Ledger account reference. + $ref: ../objectid.yaml#/components/schemas/ObjectId + balance: + $ref: ../common/money.yaml#/components/schemas/Money + version: + description: Materialized account-balance version. + type: integer + format: int64 + lastUpdated: + description: Materialized balance update timestamp. + type: string + format: date-time + + HistoricalLedgerAccountBalance: + description: | + Event-time accounting balance reconstructed from posted statement-entry deltas + where `eventTime <= asOf`. This is not a materialized historical snapshot. + type: object + additionalProperties: false + required: + - ledgerAccountRef + - balance + - asOf + properties: + ledgerAccountRef: + description: Ledger account reference. + $ref: ../objectid.yaml#/components/schemas/ObjectId + balance: + $ref: ../common/money.yaml#/components/schemas/Money + asOf: + description: Canonical UTC RFC3339 event-time cutoff used for reconstruction. + type: string + format: date-time + example: '2026-04-23T12:30:00Z' diff --git a/backend/contracts/sendico/models/ledger/operation.yaml b/backend/contracts/sendico/models/ledger/operation.yaml new file mode 100644 index 0000000..60cb2bb --- /dev/null +++ b/backend/contracts/sendico/models/ledger/operation.yaml @@ -0,0 +1,124 @@ +components: + schemas: + LedgerStatementVisibility: + description: Visibility of the account-history projection returned for the requested ledger account. + type: string + enum: + - full + - partial + + LedgerEntryType: + description: Lowercase ledger entry type string from the BFF response. + type: string + example: transfer + + LedgerLineType: + description: Lowercase posting-line type string from the BFF response. + type: string + example: main + + LedgerAccountOperationSummary: + description: | + Account-history summary projection for a ledger statement entry. + `accountAmount` is the exact decimal sum of visible posting-line amounts whose `ledgerAccountRef` + equals the requested statement account. Positive means net credit, negative means net debit, + zero means net neutral, and the currency matches the requested ledger account currency. + type: object + additionalProperties: false + required: + - entryRef + - entryType + - accountAmount + - eventTime + - version + - visibility + properties: + entryRef: + description: Statement entry reference. + $ref: ../objectid.yaml#/components/schemas/ObjectId + entryType: + $ref: ./operation.yaml#/components/schemas/LedgerEntryType + accountAmount: + $ref: ../common/money.yaml#/components/schemas/Money + description: + description: Human-readable entry description when present. + type: string + eventTime: + description: Event timestamp carried by the canonical statement entry. + type: string + format: date-time + version: + description: Immutable ledger history version used for deterministic ordering. + type: integer + format: int64 + visibility: + $ref: ./operation.yaml#/components/schemas/LedgerStatementVisibility + + LedgerAccountOperationLine: + description: Visible posting line included in the account-history projection. + type: object + additionalProperties: false + required: + - ledgerAccountRef + - money + - lineType + properties: + ledgerAccountRef: + description: Ledger account reference for the visible posting line. + $ref: ../objectid.yaml#/components/schemas/ObjectId + money: + $ref: ../common/money.yaml#/components/schemas/Money + lineType: + $ref: ./operation.yaml#/components/schemas/LedgerLineType + organizationRef: + description: Owning organization reference when visible to the caller. + $ref: ../objectid.yaml#/components/schemas/ObjectId + + LedgerAccountOperation: + description: | + Detailed account-history projection for a statement entry. + This payload is scoped to the requested ledger account and does not expose general journal-entry data outside that visibility model. + type: object + additionalProperties: false + required: + - entryRef + - entryType + - accountAmount + - eventTime + - version + - visibility + - lines + properties: + entryRef: + description: Statement entry reference. + $ref: ../objectid.yaml#/components/schemas/ObjectId + entryType: + $ref: ./operation.yaml#/components/schemas/LedgerEntryType + accountAmount: + $ref: ../common/money.yaml#/components/schemas/Money + description: + description: Human-readable entry description when present. + type: string + eventTime: + description: Event timestamp carried by the canonical statement entry. + type: string + format: date-time + version: + description: Immutable ledger history version used for deterministic ordering. + type: integer + format: int64 + visibility: + $ref: ./operation.yaml#/components/schemas/LedgerStatementVisibility + lines: + description: Visible posting lines included in the account-history projection. + type: array + items: + $ref: ./operation.yaml#/components/schemas/LedgerAccountOperationLine + metadata: + description: Entry metadata when visible to the caller. + type: object + additionalProperties: + type: string + organizationRef: + description: Entry organization reference when visible to the caller. + $ref: ../objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/models/objectid.yaml b/backend/contracts/sendico/models/objectid.yaml new file mode 100644 index 0000000..01f1c3d --- /dev/null +++ b/backend/contracts/sendico/models/objectid.yaml @@ -0,0 +1,9 @@ +components: + schemas: + ObjectId: + description: Object identifier represented as a 24-character hexadecimal string. + type: string + format: objectid + pattern: '^[a-fA-F0-9]{24}$' + examples: + - 64f85f5f4c7dbf7cfb8f3f10 diff --git a/backend/contracts/sendico/models/organization/organization.yaml b/backend/contracts/sendico/models/organization/organization.yaml new file mode 100644 index 0000000..8b6dec1 --- /dev/null +++ b/backend/contracts/sendico/models/organization/organization.yaml @@ -0,0 +1,39 @@ +components: + schemas: + OrganizationBase: + allOf: + - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound + - $ref: ../common/describable.yaml#/components/schemas/Describable + - type: object + additionalProperties: false + required: + - timeZone + properties: + tenantRef: + description: Tenant identifier that owns the organization. + $ref: ../objectid.yaml#/components/schemas/ObjectId + timeZone: + type: string + logoUrl: + type: string + nullable: true + signupState: + description: Signup/provisioning lifecycle state for the organization. + type: string + enum: + - pending_signup_provisioning + - blocked_signup_provisioning + - active + + Organization: + allOf: + - $ref: ./organization.yaml#/components/schemas/OrganizationBase + - type: object + additionalProperties: false + required: + - members + properties: + members: + type: array + items: + $ref: ../objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/models/payment/fx_metadata.yaml b/backend/contracts/sendico/models/payment/fx_metadata.yaml new file mode 100644 index 0000000..857a2a1 --- /dev/null +++ b/backend/contracts/sendico/models/payment/fx_metadata.yaml @@ -0,0 +1,78 @@ +components: + schemas: + FXCurrency: + description: Display-facing currency metadata sourced from the Treasury Currency Catalog startup snapshot cached by the BFF. + type: object + additionalProperties: false + required: + - code + - kind + properties: + code: + description: Treasury canonical asset code. + type: string + kind: + description: Compatibility asset kind projection. Use `assetKind` for the Treasury-owned exact asset kind. + type: string + enum: + - fiat + - crypto + - internal + assetKind: + description: Treasury-owned asset kind. Network-specific support, provider asset codes, token contracts, and provider rails are not represented here. + type: string + enum: + - fiat + - crypto_token + - crypto_native + - internal_platform + decimals: + description: Display-facing compatibility field backed by Treasury `displayScale`. Backend services and clients must not use it as accounting, quote, or payout execution precision authority. + type: integer + format: int32 + minimum: 0 + scale: + description: Display-facing compatibility field backed by Treasury `displayScale`; always equal to `decimals` in this response. + type: integer + format: int32 + minimum: 0 + displayScale: + description: Treasury UI/display precision. + type: integer + format: int32 + minimum: 0 + accountingScaleCandidate: + description: Informational Treasury accounting scale candidate. This is not provider execution precision, not FX rate precision, and not guaranteed executable payout precision or provider support. + type: integer + format: int32 + minimum: 0 + rounding: + description: Preferred rounding mode for the currency, when supplied by the oracle. + type: string + enum: + - round_half_even + - round_half_up + - round_down + + FXDirection: + description: Direction-level FX availability for an organization. + type: object + additionalProperties: false + required: + - fromCurrency + - toCurrency + - pair + - side + properties: + fromCurrency: + description: Source currency of the directed conversion. + type: string + toCurrency: + description: Target currency of the directed conversion. + type: string + pair: + description: Oracle canonical base/quote pair from which the direction was derived. This pair does not follow display from/to order. + $ref: ./payment.yaml#/components/schemas/CurrencyPair + side: + description: FX side interpreted against the canonical pair. `sell_base_buy_quote` means the customer sells base and receives quote; `buy_base_sell_quote` means the customer buys base and pays quote. + $ref: ../../external/fx_side.yaml#/components/schemas/FxSide diff --git a/backend/contracts/sendico/models/payment/payment.yaml b/backend/contracts/sendico/models/payment/payment.yaml new file mode 100644 index 0000000..e970feb --- /dev/null +++ b/backend/contracts/sendico/models/payment/payment.yaml @@ -0,0 +1,879 @@ +components: + schemas: + Asset: + description: Blockchain asset identifier used for on-chain transfers. + type: object + additionalProperties: false + properties: + chain: + description: Chain/network where the asset exists. + $ref: ../../external/chain_network.yaml#/components/schemas/ChainNetwork + token_symbol: + description: Symbol of the token on the selected chain. + type: string + contract_address: + description: Smart-contract address for tokenized assets. + type: string + + ExternalChainAsset: + description: Blockchain asset identifier used for external-chain endpoints. `token_symbol` is required by current quote/initiate logic; `chain` remains optional. + required: + - token_symbol + allOf: + - $ref: ./payment.yaml#/components/schemas/Asset + + LedgerEndpoint: + description: | + Internal ledger-based endpoint used as payment source or destination. + Current request handling accepts exactly one of `account_ref` or `account_code`. + `ledger_account_ref` is accepted as a legacy alias for `account_ref`. + type: object + additionalProperties: false + properties: + account_ref: + description: Reference of the primary ledger account. + type: string + ledger_account_ref: + description: Legacy alias for `account_ref`. + deprecated: true + type: string + account_code: + description: Account code of the primary ledger account. + type: string + contra_account_ref: + description: Optional contra ledger account reference for balancing entries. + type: string + contra_account_code: + description: Optional contra ledger account code for balancing entries. + type: string + + PaymentMethodEndpoint: + description: Stored payment method selector. Sent with endpoint type `paymentMethod` and `data.ref`. + type: object + additionalProperties: false + required: + - ref + properties: + ref: + description: Stored payment method reference. + type: string + + AccountEndpoint: + description: Account-owned main payment method selector. Sent with endpoint type `account` and `data.ref`. + type: object + additionalProperties: false + required: + - ref + properties: + ref: + description: Account reference whose main payment method should be used. + type: string + + RecipientEndpoint: + description: Recipient-owned main payment method selector. Sent with endpoint type `recipient` and `data.ref`. + type: object + additionalProperties: false + required: + - ref + properties: + ref: + description: Recipient reference whose main payment method should be used. + type: string + + ManagedWalletEndpoint: + description: Endpoint referencing a managed wallet within the platform. + type: object + additionalProperties: false + required: + - ref + properties: + ref: + description: Reference of the managed wallet. + type: string + asset: + description: Asset details for the managed wallet transfer. + $ref: ./payment.yaml#/components/schemas/Asset + + ExternalChainEndpoint: + description: Endpoint representing an external blockchain address. + type: object + additionalProperties: false + required: + - asset + - address + properties: + asset: + description: Asset to be transferred to or from the external address. + $ref: ./payment.yaml#/components/schemas/ExternalChainAsset + address: + description: Target blockchain address. + type: string + memo: + description: Optional destination memo or tag required by some chains. + type: string + + CardEndpoint: + description: | + Raw card payout endpoint. + Current quotation logic only requires `pan`; `exp_month` and `exp_year` are accepted but optional while pricing. + Raw-card payment execution requires `pan`, `exp_month`, and `exp_year`. + That execution rule also applies when a payment is later created from `quoteRef`, because the quoted endpoint snapshot is reused. + type: object + additionalProperties: false + required: + - pan + properties: + pan: + description: Primary account number of the card. Required for both quotation and raw-card payment execution. + type: string + firstName: + description: Optional cardholder first name hint. Current quote and execution logic do not require it. + type: string + lastName: + description: Optional cardholder last name hint. Current quote and execution logic do not require it. + type: string + exp_month: + description: Card expiration month. Optional for quotation, but required for raw-card payment execution, including payments executed later from `quoteRef`. + type: integer + format: int32 + minimum: 0 + exp_year: + description: Card expiration year. Optional for quotation, but required for raw-card payment execution, including payments executed later from `quoteRef`. + type: integer + format: int32 + minimum: 0 + country: + description: Optional card country hint. Current quote and execution logic do not require it. + type: string + + CardTokenEndpoint: + description: | + Tokenized card payout endpoint used instead of raw PAN details. + Current quotation and payment-execution logic require only `token`. + `masked_pan` remains optional and is mainly used for display, auditing, and provider-specific context. + type: object + additionalProperties: false + required: + - token + properties: + token: + description: Provider-issued card token. Required for both quotation and tokenized-card payment execution. + type: string + masked_pan: + description: Optional masked PAN representation for display and auditing. Current quote and execution logic do not require it. + type: string + + BankAccountEndpoint: + description: | + Russian domestic bank-account payout endpoint. + The recipient name is grouped under `recipient` with `first_name` + and `last_name`. + type: object + additionalProperties: false + required: + - recipient + - kpp + - bankName + - bik + - accountNumber + - correspondentAccount + properties: + recipient: + description: Legal recipient name for the bank-account payout. + type: object + additionalProperties: false + required: + - first_name + - last_name + properties: + first_name: + description: Recipient first name. + type: string + last_name: + description: Recipient last name. + type: string + inn: + description: Optional Russian INN tax identifier for the recipient. If present, it must be valid. Bank payout execution uses this value before falling back to the customer or saved owner tax identifier. + type: string + kpp: + description: Russian KPP tax registration reason code. + type: string + bankName: + description: Recipient bank name. + type: string + bik: + description: 9-digit Russian bank identification code. + type: string + accountNumber: + description: 20-digit Russian bank account number. + type: string + correspondentAccount: + description: 20-digit correspondent account number. + type: string + example: + recipient: + first_name: Ivan + last_name: Ivanov + inn: '7707083893' + kpp: '770701001' + bankName: Test Bank + bik: '044525225' + accountNumber: '40702810900000000001' + correspondentAccount: '30101810400000000225' + + IBANEndpoint: + description: International bank-account payout endpoint. + type: object + additionalProperties: false + required: + - iban + - accountHolder + properties: + iban: + type: string + accountHolder: + type: string + bic: + type: string + bankName: + type: string + + Endpoint: + description: | + Polymorphic payment endpoint definition. Every endpoint is expressed with the same + `type` plus `data` shape. Stored references are typed endpoints too: a stored payment + method uses `type: paymentMethod`, a recipient main method uses `type: recipient`, and + an account main method uses `type: account`, each with a `ref` inside `data`. + Flat top-level selector fields (`paymentMethodRef`, `recipientRef`, `accountRef`) are + not accepted; the request handler rejects them. + type: object + additionalProperties: false + required: + - type + - data + properties: + type: + description: Endpoint type determining the shape of the `data` field. + $ref: ../../external/endpoint_type.yaml#/components/schemas/EndpointType + data: + description: Endpoint payload corresponding to the selected endpoint type. For raw `card` payloads, `pan` is always required and expiry becomes required once the intent is executed. + anyOf: + - $ref: ./payment.yaml#/components/schemas/PaymentMethodEndpoint + - $ref: ./payment.yaml#/components/schemas/AccountEndpoint + - $ref: ./payment.yaml#/components/schemas/RecipientEndpoint + - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint + - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint + - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint + - $ref: ./payment.yaml#/components/schemas/CardEndpoint + - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint + - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint + - $ref: ./payment.yaml#/components/schemas/IBANEndpoint + metadata: + description: Optional key-value metadata attached to the endpoint. + type: object + additionalProperties: + type: string + + PaymentResponseEndpoint: + description: | + Endpoint snapshot attached to a persisted payment. Uses the same `type` plus `data` + shape as request endpoints. Stored-reference snapshots are emitted as typed endpoints + (`paymentMethod`, `account`, or `recipient`) with a `ref` inside `data`. + type: object + additionalProperties: false + required: + - type + - data + properties: + type: + description: Endpoint type for the endpoint snapshot. + $ref: ../../external/endpoint_type.yaml#/components/schemas/EndpointType + data: + description: Endpoint payload snapshot; shape depends on `type`. + anyOf: + - $ref: ./payment.yaml#/components/schemas/PaymentMethodEndpoint + - $ref: ./payment.yaml#/components/schemas/AccountEndpoint + - $ref: ./payment.yaml#/components/schemas/RecipientEndpoint + - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint + - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint + - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint + - $ref: ./payment.yaml#/components/schemas/CardEndpoint + - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint + - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint + - $ref: ./payment.yaml#/components/schemas/IBANEndpoint + + Customer: + description: Customer identity and address attributes for compliance and routing. + type: object + additionalProperties: false + properties: + id: + description: External or internal customer identifier. + type: string + first_name: + description: Customer first name. + type: string + middle_name: + description: Customer middle name. + type: string + last_name: + description: Customer last name. + type: string + ip: + description: Source IP address associated with the payment request. + type: string + zip: + description: Postal or ZIP code. + type: string + country: + description: Country code of customer residence or billing address. + type: string + state: + description: State or region. + type: string + city: + description: City name. + type: string + address: + description: Street address line. + type: string + tax_identifier: + description: Customer tax identifier. Russian bank-account payouts interpret this value as INN. + type: string + + CurrencyPair: + description: Foreign-exchange currency pair. + type: object + additionalProperties: false + required: + - base + - quote + properties: + base: + description: Base currency code. + type: string + quote: + description: Quote currency code. + type: string + + FxIntent: + description: FX execution preferences associated with a payment. + type: object + additionalProperties: false + required: + - pair + - side + properties: + pair: + description: Currency pair for FX conversion. + $ref: ./payment.yaml#/components/schemas/CurrencyPair + side: + description: Side of the FX trade intent. + $ref: ../../external/fx_side.yaml#/components/schemas/FxSide + firm: + description: Whether a firm quote is required. + type: boolean + ttl_ms: + description: Desired quote time-to-live in milliseconds. + type: integer + format: int64 + preferred_provider: + description: Preferred FX liquidity provider identifier. + type: string + max_age_ms: + description: Maximum accepted quote age in milliseconds. + type: integer + format: int32 + + PaymentIntent: + description: Requested payment execution parameters before processing. For raw `card` destinations, quotation accepts missing expiry but payment execution requires `exp_month` and `exp_year`. + type: object + additionalProperties: false + required: + - kind + - source + - destination + - amount + properties: + kind: + description: High-level payment kind that drives orchestration behavior. + $ref: ../../external/payment_kind.yaml#/components/schemas/PaymentKind + source: + description: Source endpoint from which funds are debited. + $ref: ./payment.yaml#/components/schemas/Endpoint + destination: + description: Destination endpoint to which funds are credited. If `type=card`, `exp_month` and `exp_year` are optional while quoting but required when the payment is executed. + $ref: ./payment.yaml#/components/schemas/Endpoint + amount: + description: Requested payment amount. + $ref: ../common/money.yaml#/components/schemas/Money + fx: + description: Optional FX preferences when conversion is needed. + $ref: ./payment.yaml#/components/schemas/FxIntent + settlement_mode: + description: Settlement strategy for the transfer. + $ref: ../../external/settlement_mode.yaml#/components/schemas/SettlementMode + fee_treatment: + description: How fees should be applied to source/destination amounts. + type: string + enum: + - unspecified + - add_to_source + - deduct_from_destination + attributes: + description: Additional key-value attributes used by downstream processors. + type: object + additionalProperties: + type: string + comment: + description: Optional free-form comment attached to the payment intent. + type: string + customer: + description: Optional customer information attached to the payment intent. + $ref: ./payment.yaml#/components/schemas/Customer + + FeeLine: + description: Single fee component included in a payment quote. + type: object + additionalProperties: false + properties: + ledgerAccountRef: + description: Ledger account reference receiving or charging this fee. + type: string + amount: + description: Monetary amount of the fee line. + $ref: ../common/money.yaml#/components/schemas/Money + lineType: + description: Fee line classification. + type: string + side: + description: Indicates whether the fee applies on debit or credit side. + type: string + meta: + description: Optional metadata for fee calculation traceability. + type: object + additionalProperties: + type: string + + QuoteFees: + description: Collection of fee lines for a quoted payment. + type: object + additionalProperties: false + properties: + lines: + description: List of individual quoted fee lines. + type: array + items: + $ref: ./payment.yaml#/components/schemas/FeeLine + + QuoteAmounts: + description: Amount breakdown returned with a payment quote. + type: object + additionalProperties: false + properties: + sourcePrincipal: + description: Principal amount debited from the source before fees. + $ref: ../common/money.yaml#/components/schemas/Money + sourceDebitTotal: + description: Total amount debited from the source including adjustments. + $ref: ../common/money.yaml#/components/schemas/Money + destinationSettlement: + description: Net amount expected to settle at the destination. + $ref: ../common/money.yaml#/components/schemas/Money + + FxQuote: + description: FX quote details used to price converted payment amounts. + type: object + additionalProperties: false + properties: + quoteRef: + description: Identifier of the FX quote. + type: string + baseCurrency: + description: Base currency of the quote pair. + type: string + quoteCurrency: + description: Quote currency of the quote pair. + type: string + side: + description: FX side used for pricing. + type: string + price: + description: Quoted FX price as a decimal string. + type: string + baseAmount: + description: Base currency amount covered by this quote. + $ref: ../common/money.yaml#/components/schemas/Money + quoteAmount: + description: Quote currency amount corresponding to the base amount. + $ref: ../common/money.yaml#/components/schemas/Money + expiresAtUnixMs: + description: Quote expiration timestamp in Unix milliseconds. + type: integer + format: int64 + pricedAtUnixMs: + description: Quote pricing timestamp in Unix milliseconds. + type: integer + format: int64 + provider: + description: Provider that supplied the FX quote. + type: string + rateRef: + description: Provider-specific rate reference identifier. + type: string + firm: + description: Indicates whether the FX quote is firm. + type: boolean + + FxRateCalculation: + description: Exchange-rate calculation used by an FX quote. + type: object + additionalProperties: false + required: + - requestedPair + - requestedSide + - pathKind + - marketRate + - effectiveRate + - overrideScope + - specialRateApplied + - legs + properties: + requestedPair: + description: Requested currency pair for the FX quote. + $ref: ./payment.yaml#/components/schemas/CurrencyPair + requestedSide: + description: FX side interpreted against the requested pair. + $ref: ../../external/fx_side.yaml#/components/schemas/FxSide + pathKind: + description: Whether the applied rate was obtained from a direct pair or a cross-rate path. + type: string + enum: + - direct + - cross + marketRate: + description: Market-path rate before quote-level overrides, as a decimal string. + type: string + effectiveRate: + description: Final rate applied to the quote after leg-level or whole-rate overrides, as a decimal string. + type: string + overrideScope: + description: Location of the rate override. `none` means no special rate was applied. + type: string + enum: + - none + - leg + - total + specialRateApplied: + description: Derived convenience flag equal to `overrideScope != none`. + type: boolean + overrideRef: + description: Opaque support/audit reference for whole-rate overrides. Clients must not parse it. + type: string + asOfUnixMs: + description: Calculation/path decision timestamp in Unix milliseconds. + type: integer + format: int64 + legs: + description: Resolved market path legs used to obtain the rate. + type: array + items: + $ref: ./payment.yaml#/components/schemas/FxRateCalculationLeg + + FxRateCalculationLeg: + description: One market-path leg used in an FX quote rate calculation. + type: object + additionalProperties: false + required: + - pair + - sourcePair + - invertedFromSource + - marketRate + - effectiveRate + - overrideApplied + properties: + pair: + description: Oriented pair multiplied in path order. + $ref: ./payment.yaml#/components/schemas/CurrencyPair + sourcePair: + description: Stored/provider pair before orientation adjustments. + $ref: ./payment.yaml#/components/schemas/CurrencyPair + invertedFromSource: + description: Whether the source pair was inverted to produce this oriented leg. + type: boolean + marketRate: + description: Market executable rate for this leg, as a decimal string. + type: string + effectiveRate: + description: Effective rate for this leg after leg-level override, as a decimal string. + type: string + provider: + description: Rate provider identifier. + type: string + rateRef: + description: Opaque support/audit reference for the source rate. Clients must not parse it. + type: string + asOfUnixMs: + description: Source rate timestamp for this leg in Unix milliseconds. + type: integer + format: int64 + overrideApplied: + description: Whether a leg-level override was applied to this leg. + type: boolean + overrideRef: + description: Opaque support/audit reference for the leg-level override. Clients must not parse it. + type: string + + SpecialRateInfo: + description: Indicates whether quotation pricing policy applied a non-market FX rate to the quote. + type: object + additionalProperties: false + required: + - applied + properties: + applied: + description: Derived from `fxRateCalculation.overrideScope != none` when `fxRateCalculation` is present. + type: boolean + + PaymentQuote: + description: Pricing result for a specific payment intent. + type: object + additionalProperties: false + required: + - executable + - state + properties: + quoteRef: + description: Unique identifier of the payment quote. + type: string + intentRef: + description: Reference to the payment intent used for quoting. + type: string + state: + description: Backend quotation execution state. `executable` means the quote can be submitted for execution. `blocked`, `indicative`, `expired`, and `unspecified` must not be executed as-is. + type: string + enum: + - unspecified + - indicative + - executable + - blocked + - expired + executable: + description: Explicit execution flag. `false` means the quote must not be sent to `/payments/by-quote` or `/payments/by-multiquote`. + type: boolean + blockReason: + description: Reason the quote is not executable when `state` is `blocked`. Omitted for executable, indicative, and expired quotes. + type: string + enum: + - route_unavailable + - limit_blocked + - risk_blocked + - insufficient_liquidity + - price_stale + - amount_too_small + - amount_too_large + amounts: + description: Amount breakdown for source and destination values. + $ref: ./payment.yaml#/components/schemas/QuoteAmounts + fees: + description: Fee breakdown included in the quote. + $ref: ./payment.yaml#/components/schemas/QuoteFees + fxQuote: + description: Associated FX quote when conversion is part of the payment. + $ref: ./payment.yaml#/components/schemas/FxQuote + fxRateCalculation: + description: Explanation of how the FX quote rate was obtained. + $ref: ./payment.yaml#/components/schemas/FxRateCalculation + specialRate: + description: Indicates whether quotation pricing policy applied a special FX rate while pricing this quote. + $ref: ./payment.yaml#/components/schemas/SpecialRateInfo + + PaymentFailureCode: + description: | + Broad machine-readable category for a payment or operation that reports a failure + condition. This field is a public category, not the orchestration service's + canonical failure code. A card payout terminated after a definitive AML denial + uses `failure_restriction`; its canonical orchestration code is `card.aml_denied`. + + The enumeration is closed and safe to use for failure handling after checking the + payment state. The state remains the lifecycle authority: `failure_restriction` + can accompany non-terminal `needs_attention` while manual review or additional + checks are pending. `failure_policy` is reserved and is not currently returned by + the API; policy blocks are reported as `failure_restriction`. + + The published cause-to-category-to-reason list is at + https://app.sendico.io/docs/en/payment-failures/ + type: string + enum: + - failure_unspecified + - failure_balance + - failure_ledger + - failure_fx + - failure_chain + - failure_fees + - failure_policy + - failure_validation + - failure_restriction + example: failure_restriction + + PaymentFailureReason: + description: | + Stable lowercase machine-readable reason token intended for client handling. A + card payout terminated after a definitive AML denial uses `aml_denied`. Detailed + AML evidence, including the matched blacklist reason, and raw gateway, provider, + or RPC messages are not exposed here. + + Unlike `failureCode`, this token set is open: compliance policy reason codes and + provider-derived reasons can add values without a contract change. Treat unknown + tokens as the category-level failure and keep a fallback message. + + Default reasons per category: `insufficient_funds`; `ledger_operation_failed`, + `ledger_operation_cancelled`, `ledger_access_violation`, + `ledger_validation_failed`; `fee_fx_conversion_failed`; `fee_posting_failed`; + `gateway_execution_failed`, `gateway_execution_rejected`, `gateway_execution_cancelled`, + `gateway_transfer_failed`, `gateway_transfer_cancelled`, `card_payout_failed`, + `card_payout_cancelled`, `provider_declined`, `provider_limit`, + `provider_unavailable`, `provider_status_unknown`, `provider_unknown`, + `velocity_limit_exceeded`, `limits_rejected`; `validation_failed`, + `quote_precision_identity_missing`, `quote_precision_asset_identity_mismatch`, + `quote_precision_asset_scale_missing`, `quote_precision_amount_scale_exceeded`, + `card_validation_failed`, `card_scheme_unsupported`, `card_expired`, + `amount_below_minimum`, `amount_above_maximum`, `duplicate_transaction`, + `currency_unsupported`; `card_scheme_disabled`, `aml_denied`, `policy_blocked`, + `review_required`, `more_checks_required`, `risk_restricted`, + `operation_restricted`, `card_product_unsupported`; `assessment_failed`; + `failure_unspecified` for an unclassified cause. + + Additional currently known override reasons are `do_not_honor`, `invalid_card`, + `invalid_card_number`, `invalid_cardholder_name`, `cardholder_name_mismatch`, + `card_details_invalid`, `card_expiry_required`, `invalid_amount`, + `provider_cancelled`, `disabled_card_scheme`, + `unsupported_gateway_card_scheme`, and `recipient_tax_identifier_required`. + Structured bank validation can add normalized reasons ending in `_required` or + `_invalid`; compliance policy reason codes are also an open set. + + The published cause-to-category-to-reason list, including the additional + provider, submit-validation, and bank-validation tokens, is at + https://app.sendico.io/docs/en/payment-failures/ + type: string + pattern: '^[a-z0-9_]+$' + example: aml_denied + + PaymentOperation: + description: | + Execution step record captured during payment processing. For a card payout + terminated by AML screening, `failureCode` is `failure_restriction` and + `failureReason` is `aml_denied`. + type: object + additionalProperties: false + properties: + stepRef: + description: Identifier of the orchestration step. + type: string + code: + description: Operation code representing the executed action. + type: string + state: + description: Current state of the operation. + type: string + label: + description: Human-readable operation label. + type: string + comment: + description: Optional comment attached to the execution step. + type: string + money: + description: Planned and executed monetary snapshots for the operation. + $ref: ./payment.yaml#/components/schemas/PaymentOperationMoney + operationRef: + description: External operation reference identifier reported by the gateway. + type: string + gateway: + description: Gateway microservice type handling the operation. + type: string + failureCode: + $ref: ./payment.yaml#/components/schemas/PaymentFailureCode + failureReason: + $ref: ./payment.yaml#/components/schemas/PaymentFailureReason + startedAt: + description: RFC 3339 timestamp when execution of the operation started. + type: string + format: date-time + completedAt: + description: RFC 3339 timestamp when execution of the operation completed. + type: string + format: date-time + + PaymentOperationMoney: + description: Planned and executed monetary snapshots associated with the operation. + type: object + additionalProperties: false + properties: + planned: + description: Monetary values planned for the operation before execution. + $ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot + executed: + description: Monetary values observed after the operation was executed. + $ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot + + PaymentOperationMoneySnapshot: + description: Monetary snapshot containing base and converted amounts for an operation state. + type: object + additionalProperties: false + properties: + amount: + description: Primary money amount associated with the operation snapshot. + $ref: ../common/money.yaml#/components/schemas/Money + convertedAmount: + description: Secondary amount for conversion operations in the same snapshot. + $ref: ../common/money.yaml#/components/schemas/Money + + Payment: + description: | + Persisted payment aggregate with status, quote, and operation history. A definitive + card-payout AML denial terminates the payment in the failed state and is reported as + `failureCode: failure_restriction` with `failureReason: aml_denied` at both payment + and failed-operation level. + type: object + additionalProperties: false + properties: + paymentRef: + description: Unique payment reference identifier. + type: string + state: + description: Current lifecycle state of the payment. + $ref: ../../external/payment_state.yaml#/components/schemas/PaymentState + comment: + description: Optional comment copied from the original payment intent. + type: string + source: + description: Source endpoint snapshot captured from intent. + $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint + destination: + description: Destination endpoint snapshot captured from intent. + $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint + failureCode: + $ref: ./payment.yaml#/components/schemas/PaymentFailureCode + failureReason: + $ref: ./payment.yaml#/components/schemas/PaymentFailureReason + operations: + description: Chronological list of operations executed for this payment. + type: array + items: + $ref: ./payment.yaml#/components/schemas/PaymentOperation + lastQuote: + description: Most recent payment quote used or generated for the payment. + $ref: ./payment.yaml#/components/schemas/PaymentQuote + createdAt: + description: RFC 3339 timestamp when the payment was created. + type: string + format: date-time + completedAt: + description: RFC 3339 timestamp when the payment reached a terminal state. Omitted while the payment is still in progress. + type: string + format: date-time + meta: + description: Additional metadata captured for the payment. + type: object + additionalProperties: + type: string diff --git a/backend/contracts/sendico/models/payment_method/payment_method.yaml b/backend/contracts/sendico/models/payment_method/payment_method.yaml new file mode 100644 index 0000000..7b475f0 --- /dev/null +++ b/backend/contracts/sendico/models/payment_method/payment_method.yaml @@ -0,0 +1,29 @@ +components: + schemas: + PaymentMethod: + allOf: + - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound + - $ref: ../common/describable.yaml#/components/schemas/Describable + - type: object + additionalProperties: false + required: + - ownerKind + - ownerRef + - type + - isMain + properties: + ownerKind: + type: string + enum: + - ACCOUNT + - RECIPIENT + ownerRef: + $ref: ../objectid.yaml#/components/schemas/ObjectId + type: + $ref: ../../external/payment_method_type.yaml#/components/schemas/PaymentMethodType + data: + description: Payment-method-type-specific payload. `bankAccount` methods use the same Russian bank-account data shape as `BankAccountEndpoint`. + type: object + additionalProperties: true + isMain: + type: boolean diff --git a/backend/contracts/sendico/models/permission_bound.yaml b/backend/contracts/sendico/models/permission_bound.yaml new file mode 100644 index 0000000..c574a13 --- /dev/null +++ b/backend/contracts/sendico/models/permission_bound.yaml @@ -0,0 +1,23 @@ +components: + schemas: + PermissionBound: + description: Association between an organization and a permission, including archival state. + allOf: + - $ref: ./storable.yaml#/components/schemas/Storable + - type: object + description: Permission binding details scoped to an organization. + additionalProperties: false + required: + - organizationRef + - permissionRef + - isArchived + properties: + organizationRef: + description: Identifier of the organization that owns this permission binding. + $ref: ./objectid.yaml#/components/schemas/ObjectId + permissionRef: + description: Identifier of the permission granted to the organization. + $ref: ./objectid.yaml#/components/schemas/ObjectId + isArchived: + description: Indicates whether this object is archived and inactive. + type: boolean diff --git a/backend/contracts/sendico/models/recipient/recipient.yaml b/backend/contracts/sendico/models/recipient/recipient.yaml new file mode 100644 index 0000000..5ab3d56 --- /dev/null +++ b/backend/contracts/sendico/models/recipient/recipient.yaml @@ -0,0 +1,30 @@ +components: + schemas: + Recipient: + allOf: + - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound + - $ref: ../common/describable.yaml#/components/schemas/Describable + - type: object + additionalProperties: false + required: + - email + - status + - type + properties: + email: + type: string + format: email + avatarUrl: + type: string + nullable: true + status: + type: string + enum: + - ready + - registered + - notRegistered + type: + type: string + enum: + - internal + - external diff --git a/backend/contracts/sendico/models/storable.yaml b/backend/contracts/sendico/models/storable.yaml new file mode 100644 index 0000000..325b291 --- /dev/null +++ b/backend/contracts/sendico/models/storable.yaml @@ -0,0 +1,22 @@ +components: + schemas: + Storable: + description: Common persistence metadata included on stored entities. + type: object + additionalProperties: false + required: + - id + - createdAt + - updatedAt + properties: + id: + description: Unique identifier assigned to the stored entity. + $ref: ./objectid.yaml#/components/schemas/ObjectId + createdAt: + description: RFC 3339 timestamp indicating when the entity was created. + type: string + format: date-time + updatedAt: + description: RFC 3339 timestamp indicating the most recent entity update. + type: string + format: date-time diff --git a/backend/contracts/sendico/models/wallet/wallet_asset.yaml b/backend/contracts/sendico/models/wallet/wallet_asset.yaml new file mode 100644 index 0000000..6d1a1a6 --- /dev/null +++ b/backend/contracts/sendico/models/wallet/wallet_asset.yaml @@ -0,0 +1,19 @@ +components: + schemas: + WalletAsset: + description: Canonical managed-wallet asset identified by chain, token symbol, and optional contract address. + type: object + additionalProperties: false + required: + - chain + - tokenSymbol + properties: + chain: + description: Canonical blockchain network for the asset. + $ref: ../../external/chain_network.yaml#/components/schemas/ChainNetwork + tokenSymbol: + description: Upper-case asset symbol. + type: string + contractAddress: + description: Normalized contract address for token assets. Omitted for native assets. + type: string diff --git a/backend/edge/product/interface/accountservice/internal/join_organization_test.go b/backend/edge/product/interface/accountservice/internal/join_organization_test.go new file mode 100644 index 0000000..70728e0 --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/join_organization_test.go @@ -0,0 +1,119 @@ +package accountserviceimp + +import ( + "context" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// fakeMembershipOrgDB answers membership from its own state, standing in for +// the transaction-scoped reads a real repository would serve. +type fakeMembershipOrgDB struct { + organization.DB + member bool + isMemberCalls int + addMemberCalls int + addedMember bson.ObjectID +} + +func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { + db.isMemberCalls++ + return db.member, nil +} + +func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) { + db.addMemberCalls++ + db.addedMember = accountRef + db.member = true + return true, nil +} + +type recordingRoleManager struct { + assigned []*model.Role +} + +func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) { + description := &model.RoleDescription{} + description.SetID(bson.NewObjectID()) + description.OrganizationRef = orgRef + return description, nil +} + +func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil } + +func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error { + m.assigned = append(m.assigned, role) + return nil +} + +func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil } + +func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) { + return nil, nil +} + +func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service { + return &service{ + logger: zap.NewNop(), + orgDB: orgDB, + roleManager: roles, + } +} + +func joinTestOrganization(members ...bson.ObjectID) *model.Organization { + org := &model.Organization{Members: members} + org.SetID(bson.NewObjectID()) + return org +} + +func joinTestAccount() *model.Account { + account := &model.Account{} + account.SetID(bson.NewObjectID()) + return account +} + +// A transaction replay rolls back the database but leaves marks on the shared +// in-memory organization. Membership decisions must come from the database, +// otherwise the replay skips the role assignment and the membership write and +// commits an organization without its owner. +func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) { + account := joinTestAccount() + // The in-memory list already carries the account: exactly the state a + // first, aborted transaction attempt leaves behind. + org := joinTestOrganization(account.ID) + orgDB := &fakeMembershipOrgDB{member: false} + roles := &recordingRoleManager{} + svc := joinTestService(orgDB, roles) + + if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil { + t.Fatalf("join failed: %v", err) + } + if len(roles.assigned) != 1 { + t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned)) + } + if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID { + t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex()) + } + if len(org.Members) != 1 { + t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members) + } +} + +func TestJoinOrganizationSkipsExistingMember(t *testing.T) { + account := joinTestAccount() + org := joinTestOrganization() + orgDB := &fakeMembershipOrgDB{member: true} + roles := &recordingRoleManager{} + svc := joinTestService(orgDB, roles) + + if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil { + t.Fatalf("join failed: %v", err) + } + if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 { + t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls) + } +} diff --git a/backend/edge/product/interface/accountservice/internal/service.go b/backend/edge/product/interface/accountservice/internal/service.go new file mode 100644 index 0000000..588bf49 --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/service.go @@ -0,0 +1,450 @@ +package accountserviceimp + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + "unicode" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type service struct { + logger mlogger.Logger + accountDB account.DB + orgDB organization.DB + enforcer auth.Enforcer + roleManager management.Role + config *middleware.PasswordConfig + + policyDB policy.DB + vdb verification.DB +} + +func validateUserRequest(u *model.Account) error { + if u.Name == "" { + return merrors.InvalidArgument("Name must not be empty") + } + if u.Login == "" { + return merrors.InvalidArgument("Login must not be empty") + } + if u.Password == "" { + return merrors.InvalidArgument("Password must not be empty") + } + return nil +} + +func (s *service) ValidatePassword( + password string, + oldPassword *string, +) error { + var hasDigit, hasUpper, hasLower, hasSpecial bool + + if oldPassword != nil { + if *oldPassword == password { + return merrors.InvalidArgument("New password cannot be the same as the old password") + } + } + + if len(password) < s.config.Check.MinLength { + return merrors.InvalidArgument(fmt.Sprintf("Password must be at least %d characters long", s.config.Check.MinLength)) + } + + // Check for digit, uppercase, lowercase, and special character + for _, char := range password { + switch { + case unicode.IsDigit(char): + hasDigit = true + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSpecial = true + } + } + + if s.config.Check.Digit && !hasDigit { + return merrors.InvalidArgument("Password must contain at least one digit") + } + if s.config.Check.Upper && !hasUpper { + return merrors.InvalidArgument("Password must contain at least one uppercase letter") + } + if s.config.Check.Lower && !hasLower { + return merrors.InvalidArgument("Password must contain at least one lowercase letter") + } + if s.config.Check.Special && !hasSpecial { + return merrors.InvalidArgument("Password must contain at least one special character") + } + + // If all checks pass, return nil (no error) + return nil +} + +func (s *service) ValidateAccount(acct *model.Account) error { + if err := validateUserRequest(acct); err != nil { + s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + if err := s.ValidatePassword(acct.Password, nil); err != nil { + s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + if err := acct.HashPassword(); err != nil { + s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + return nil +} + +func (s *service) CreateAccount( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID bson.ObjectID, +) (string, error) { + if org == nil { + return "", merrors.InvalidArgument("Organization must not be nil") + } + if acct == nil || len(acct.Login) == 0 { + return "", merrors.InvalidArgument("Account must have a non-empty login") + } + if roleDescID == bson.NilObjectID { + return "", merrors.InvalidArgument("Role description must be provided") + } + + // 1) Create the account + acct.Status = model.AccountPendingVerification + if err := s.accountDB.Create(ctx, acct); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + s.logger.Info("Username is already taken", zap.String("login", acct.Login)) + } else { + s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login)) + } + return "", err + } + + // 2) Add to organization + if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil { + s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct)) + return "", err + } + + // 3) Issue verification token + return s.VerifyAccount(ctx, acct) +} + +func (s *service) VerifyAccount( + ctx context.Context, + acct *model.Account, +) (string, error) { + token, err := s.vdb.Create( + ctx, + verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, ""). + WithTTL(time.Hour*24), + ) + if err != nil { + s.logger.Warn("Failed to create verification token for new account", zap.Error(err), mzap.StorableRef(acct)) + return "", err + } + + return token, nil + +} + +func (s *service) DeleteAccount( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, +) error { + // Check if this is the only member in the organization + if len(org.Members) <= 1 { + s.logger.Warn("Cannot delete account - it's the only member in the organization", + mzap.AccRef(accountRef), mzap.StorableRef(org)) + return merrors.InvalidArgument("Cannot delete the only member of an organization") + } + + // 1) Remove from organization + if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil { + s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // 2) Delete the account document + if err := s.accountDB.Delete(ctx, accountRef); err != nil { + s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + return nil +} + +func (s *service) RemoveAccountFromOrganization( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, +) error { + if org == nil { + return merrors.InvalidArgument("Organization must not be nil") + } + roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID) + if err != nil { + s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org), + mzap.AccRef(accountRef)) + return err + } + for _, role := range roles { + if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil { + s.logger.Warn("Failed to revoke account role", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef)) + return err + } + } + for i, member := range org.Members { + if member == accountRef { + // Remove the member by slicing it out + org.Members = append(org.Members[:i], org.Members[i+1:]...) + if err := s.orgDB.Update(ctx, accountRef, org); err != nil { + s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + break + } + } + return nil +} + +func (s *service) ResetPassword( + ctx context.Context, + acct *model.Account, +) (string, error) { + return s.vdb.Create( + ctx, + verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, ""). + WithTTL(time.Hour), + ) +} + +func (s *service) UpdateLogin( + ctx context.Context, + acct *model.Account, + newLogin string, +) (string, error) { + return s.vdb.Create( + ctx, + verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin). + WithTTL(time.Hour), + ) +} + +func (s *service) JoinOrganization( + ctx context.Context, + org *model.Organization, + account *model.Account, + roleDescID bson.ObjectID, +) error { + // Membership is decided by the database, not the in-memory copy: this method + // runs inside provisioning transactions whose retries replay the callback, + // and a stale in-memory list would silently skip the role assignment. + member, err := s.orgDB.IsMember(ctx, org.ID, account.ID) + if err != nil { + return err + } + if member { + s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account)) + return nil + } + + role := &model.Role{ + DescriptionRef: roleDescID, + OrganizationRef: org.ID, + AccountRef: account.ID, + } + if err := s.roleManager.Assign(ctx, role); err != nil { + s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account), + mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID)) + return err + } + + // A point patch on purpose: replacing the whole document would clobber + // concurrent writes, most notably the provisioning lease that guards + // finalization against attempts whose lease was taken over. + if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil { + s.logger.Warn("Failed to update organization members list", + zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org)) + return err + } + if !slices.Contains(org.Members, account.ID) { + org.Members = append(org.Members, account.ID) + } + return nil +} + +func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error { + return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) { + roles, err := s.roleManager.List(ctx, orgRef) + if err != nil { + return nil, err + } + roleIDs := make([]bson.ObjectID, 0, len(roles)) + for _, role := range roles { + roleIDs = append(roleIDs, role.ID) + } + return roleIDs, nil + }, s.roleManager.Delete) +} + +func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error { + return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) { + policies, err := s.policyDB.All(ctx, orgRef) + if err != nil { + return nil, err + } + policyIDs := make([]bson.ObjectID, 0, len(policies)) + for _, policy := range policies { + policyIDs = append(policyIDs, policy.ID) + } + return policyIDs, nil + }, s.policyDB.Delete) +} + +func (s *service) deleteOrganizationEntities( + ctx context.Context, + orgRef bson.ObjectID, + entityName string, + entityLabel string, + entityRefField string, + load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error), + deleteFn func(context.Context, bson.ObjectID) error, +) error { + s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef)) + + entityIDs, err := load(ctx, orgRef) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef)) + return err + } + + for _, entityID := range entityIDs { + if err := deleteFn(ctx, entityID); err != nil { + s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID)) + return err + } + } + + s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef)) + return nil +} + +func (s *service) DeleteOrganization( + ctx context.Context, + org *model.Organization, +) error { + s.logger.Info("Starting organization deletion", mzap.StorableRef(org)) + + // Use transaction to ensure atomicity + // 8. Delete all roles and role descriptions in the organization + if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil { + return err + } + + // 9. Delete all policies in the organization + if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil { + return err + } + + // 10. Finally, delete the organization itself + if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil { + s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org)) + return err + } + + s.logger.Info("Organization deleted successfully", mzap.StorableRef(org)) + return nil +} + +func (s *service) DeleteAll( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, +) error { + s.logger.Info("Starting complete deletion (organization + account)", + mzap.StorableRef(org), mzap.AccRef(accountRef)) + + // 1. First delete the organization and all its data + if err := s.DeleteOrganization(ctx, org); err != nil { + return err + } + + // 2. Then delete the account + if err := s.accountDB.Delete(ctx, accountRef); err != nil { + s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef)) + return nil +} + +// NewAccountService wires in your logger plus the three dependencies. +func NewAccountService( + l mlogger.Logger, + dbf db.Factory, + enforcer auth.Enforcer, + ra management.Role, + config *middleware.PasswordConfig, +) (*service, error) { + logger := l.Named("account_service") + + if config == nil { + return nil, merrors.Internal("Invalid account service configuration provides") + } + + res := &service{ + logger: logger, + enforcer: enforcer, + roleManager: ra, + config: config, + } + deps, err := edgestorage.ResolveDependencies(dbf) + if err != nil { + return nil, err + } + if res.accountDB, err = dbf.NewAccountDB(); err != nil { + logger.Error("Failed to create accounts database", zap.Error(err)) + return nil, err + } + if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil { + logger.Error("Failed to create organizations database", zap.Error(err)) + return nil, err + } + + // Initialize database dependencies for cascade deletion + if res.policyDB, err = dbf.NewPoliciesDB(); err != nil { + logger.Error("Failed to create policies database", zap.Error(err)) + return nil, err + } + if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil { + logger.Error("Failed to create verification database", zap.Error(err)) + return nil, err + } + return res, nil +} diff --git a/backend/edge/product/interface/accountservice/internal/service_deletion_test.go b/backend/edge/product/interface/accountservice/internal/service_deletion_test.go new file mode 100644 index 0000000..57aec6f --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/service_deletion_test.go @@ -0,0 +1,156 @@ +package accountserviceimp + +import ( + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestDeleteAccount_Validation(t *testing.T) { + t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) { + orgID := bson.NewObjectID() + accountID := bson.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Single Member Org"}, + }, + Members: []bson.ObjectID{accountID}, // Only one member + } + org.ID = orgID + + // This should fail because it's the only member + err := validateDeleteAccount(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot delete the only member") + }) + + t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) { + orgID := bson.NewObjectID() + accountID := bson.NewObjectID() + otherAccountID := bson.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Multi Member Org"}, + }, + Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members + } + org.ID = orgID + + // This should succeed because there are multiple members + err := validateDeleteAccount(org) + require.NoError(t, err) + }) + + t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) { + orgID := bson.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Empty Org"}, + }, + Members: []bson.ObjectID{}, // No members + } + org.ID = orgID + + // This should fail because there are no members + err := validateDeleteAccount(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot delete the only member") + }) +} + +func TestDeleteOrganization_Validation(t *testing.T) { + t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) { + err := validateDeleteOrganization(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization cannot be nil") + }) + + t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) { + org := &model.Organization{} + err := validateDeleteOrganization(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization ID cannot be empty") + }) + + t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = bson.NewObjectID() + + err := validateDeleteOrganization(org) + require.NoError(t, err) + }) +} + +func TestDeleteAll_Validation(t *testing.T) { + t.Run("DeleteAll_NilOrganization", func(t *testing.T) { + accountID := bson.NewObjectID() + err := validateDeleteAll(nil, accountID) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization cannot be nil") + }) + + t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = bson.NewObjectID() + + err := validateDeleteAll(org, bson.NilObjectID) + require.Error(t, err) + assert.Contains(t, err.Error(), "account ID cannot be empty") + }) + + t.Run("DeleteAll_ValidInput", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = bson.NewObjectID() + accountID := bson.NewObjectID() + + err := validateDeleteAll(org, accountID) + require.NoError(t, err) + }) +} + +// Helper functions that implement the validation logic from the service +func validateDeleteAccount(org *model.Organization) error { + if len(org.Members) <= 1 { + return merrors.InvalidArgument("Cannot delete the only member of an organization") + } + return nil +} + +func validateDeleteOrganization(org *model.Organization) error { + if org == nil { + return merrors.InvalidArgument("organization cannot be nil") + } + if org.ID == bson.NilObjectID { + return merrors.InvalidArgument("organization ID cannot be empty") + } + return nil +} + +func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error { + if org == nil { + return merrors.InvalidArgument("organization cannot be nil") + } + if accountRef == bson.NilObjectID { + return merrors.InvalidArgument("account ID cannot be empty") + } + return nil +} diff --git a/backend/edge/product/interface/accountservice/internal/service_test.go b/backend/edge/product/interface/accountservice/internal/service_test.go new file mode 100644 index 0000000..467981d --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/service_test.go @@ -0,0 +1,244 @@ +package accountserviceimp + +import ( + "testing" + + apiconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// TestValidatePassword tests the password validation logic directly +func TestValidatePassword(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 8, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 32, + } + + // Create a minimal service for testing password validation + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + t.Run("ValidPassword", func(t *testing.T) { + err := service.ValidatePassword("TestPassword123!", nil) + assert.NoError(t, err) + }) + + t.Run("PasswordTooShort", func(t *testing.T) { + err := service.ValidatePassword("Test1!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least 8 characters") + }) + + t.Run("PasswordMissingDigit", func(t *testing.T) { + err := service.ValidatePassword("TestPassword!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one digit") + }) + + t.Run("PasswordMissingUppercase", func(t *testing.T) { + err := service.ValidatePassword("testpassword123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one uppercase") + }) + + t.Run("PasswordMissingLowercase", func(t *testing.T) { + err := service.ValidatePassword("TESTPASSWORD123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one lowercase") + }) + + t.Run("PasswordMissingSpecialCharacter", func(t *testing.T) { + err := service.ValidatePassword("TestPassword123", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one special character") + }) + + t.Run("PasswordSameAsOld", func(t *testing.T) { + oldPassword := "TestPassword123!" + err := service.ValidatePassword("TestPassword123!", &oldPassword) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be the same as the old password") + }) +} + +// TestValidateAccount tests the account validation logic directly +func TestValidateAccount(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 8, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 32, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + t.Run("ValidAccount", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "TestPassword123!", + } + + originalPassword := account.Password + err := service.ValidateAccount(account) + require.NoError(t, err) + + // Password should be hashed after validation + assert.NotEqual(t, originalPassword, account.Password) + }) + + t.Run("AccountMissingName", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "TestPassword123!", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name must not be empty") + }) + + t.Run("AccountMissingLogin", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + }, + Password: "TestPassword123!", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Login must not be empty") + }) + + t.Run("AccountMissingPassword", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Password must not be empty") + }) + + t.Run("AccountInvalidPassword", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "weak", // Should fail validation + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + // Should fail on password validation + assert.Contains(t, err.Error(), "at least 8 characters") + }) +} + +// TestPasswordConfiguration verifies different password rule configurations +func TestPasswordConfiguration(t *testing.T) { + t.Run("MinimalRequirements", func(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 4, + Digit: false, + Upper: false, + Lower: false, + Special: false, + }, + TokenLength: 16, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + // Should pass with minimal requirements + err := service.ValidatePassword("test", nil) + assert.NoError(t, err) + }) + + t.Run("StrictRequirements", func(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 12, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 64, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + // Should fail with shorter password + err := service.ValidatePassword("Test123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least 12 characters") + + // Should pass with longer password + err = service.ValidatePassword("TestPassword123!", nil) + assert.NoError(t, err) + }) +} diff --git a/backend/edge/product/interface/accountservice/types.go b/backend/edge/product/interface/accountservice/types.go new file mode 100644 index 0000000..146a45c --- /dev/null +++ b/backend/edge/product/interface/accountservice/types.go @@ -0,0 +1,104 @@ +package accountservice + +import ( + "context" + + accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AccountService defines all account-related workflows. +type AccountService interface { + // ValidateAccount will: + // 1) check it's completeness + // 2) hash password + // 3) prepare verification token + ValidateAccount( + acct *model.Account, + ) error + + // ValidatePassword will: + // 1) check passsword conformance + ValidatePassword( + password string, + oldPassword *string, + ) error + + // ResetPassword will: + // 1) generate reset password token + ResetPassword( + ctx context.Context, + acct *model.Account, + ) (verificationToken string, err error) + + // CreateAccount will: + // 1) create the account + // 2) add it to the org’s member list + // 3) assign the given role description to it + CreateAccount( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID bson.ObjectID, + ) (verificationToken string, err error) + + VerifyAccount( + ctx context.Context, + acct *model.Account, + ) (verificationToken string, err error) + + JoinOrganization( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID bson.ObjectID, + ) error + + UpdateLogin( + ctx context.Context, + acct *model.Account, + newLogin string, + ) (verificationToken string, err error) + + // DeleteAccount deletes the account and removes it from the org. + DeleteAccount( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, + ) error + + // RemoveAccountFromOrganization just drops it from the member slice. + RemoveAccountFromOrganization( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, + ) error + + DeleteOrganization( + ctx context.Context, + org *model.Organization, + ) error + + // DeleteAll deletes both the organization and the account. + DeleteAll( + ctx context.Context, + org *model.Organization, + accountRef bson.ObjectID, + ) error +} + +func NewAccountService( + logger mlogger.Logger, + dbf db.Factory, + enforcer auth.Enforcer, + roleManeger management.Role, + config *middleware.PasswordConfig, +) (AccountService, error) { + return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config) +} diff --git a/backend/edge/shared/appversion/version.go b/backend/edge/shared/appversion/version.go new file mode 100644 index 0000000..6fcdbb3 --- /dev/null +++ b/backend/edge/shared/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/version" + vf "git.sendico.io/tech/sxchnge/bff/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + vi := version.Info{ + Program: "SXCHNGE BFF", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&vi) +} diff --git a/backend/edge/shared/auth/browser/browser.go b/backend/edge/shared/auth/browser/browser.go new file mode 100644 index 0000000..14bc11d --- /dev/null +++ b/backend/edge/shared/auth/browser/browser.go @@ -0,0 +1,121 @@ +package browser + +import ( + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +const ( + TransportHeader = "X-Auth-Transport" + TransportCookie = "cookie" + CSRFHeader = "X-CSRF-Protection" + CSRFValue = "1" + RefreshCookie = "sxchnge_refresh" +) + +func UsesCookie(r *http.Request) bool { + return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie) +} + +func RefreshCredential(r *http.Request, fallback string) (string, error) { + if !UsesCookie(r) { + return strings.TrimSpace(fallback), nil + } + cookie, err := r.Cookie(RefreshCookie) + if err != nil || strings.TrimSpace(cookie.Value) == "" { + return "", merrors.Unauthorized("browser session is missing") + } + return cookie.Value, nil +} + +func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { + if !UsesCookie(r) { + return + } + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(w, &http.Cookie{ + Name: RefreshCookie, + Value: token, + Path: cookiePath(r), + Expires: expiresAt.UTC(), + MaxAge: maxAge, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) { + if !UsesCookie(r) { + return + } + http.SetCookie(w, &http.Cookie{ + Name: RefreshCookie, + Value: "", + Path: cookiePath(r), + Expires: time.Unix(1, 0).UTC(), + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func ValidateCSRF(r *http.Request, allowedOrigins []string) error { + if !UsesCookie(r) || isSafeMethod(r.Method) { + return nil + } + if r.Header.Get(CSRFHeader) != CSRFValue { + return merrors.PermissionDenied("browser CSRF protection header is missing") + } + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" || !originAllowed(origin, allowedOrigins) { + return merrors.PermissionDenied("browser request origin is not allowed") + } + return nil +} + +func cookiePath(r *http.Request) string { + const accountsSegment = "/accounts" + if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" { + return path.Join("/", apiEndpoint, accountsSegment) + } + if r != nil && r.URL != nil { + if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 { + return r.URL.Path[:index+len(accountsSegment)] + } + } + return accountsSegment +} + +func isSafeMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + return true + default: + return false + } +} + +func originAllowed(origin string, allowedOrigins []string) bool { + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" { + return false + } + normalized := strings.TrimSuffix(strings.ToLower(origin), "/") + for _, allowed := range allowedOrigins { + if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized { + return true + } + } + return false +} diff --git a/backend/edge/shared/auth/browser/browser_test.go b/backend/edge/shared/auth/browser/browser_test.go new file mode 100644 index 0000000..d376b28 --- /dev/null +++ b/backend/edge/shared/auth/browser/browser_test.go @@ -0,0 +1,122 @@ +package browser + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) { + t.Setenv("API_ENDPOINT", "/api/v1") + req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil) + req.Header.Set(TransportHeader, TransportCookie) + req.Header.Set("X-Forwarded-Proto", "https") + recorder := httptest.NewRecorder() + + SetRefreshCookie(recorder, req, "secret-refresh-token", time.Now().Add(time.Hour)) + + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + require.Equal(t, RefreshCookie, cookie.Name) + require.Equal(t, "secret-refresh-token", cookie.Value) + require.Equal(t, "/api/v1/accounts", cookie.Path) + require.Empty(t, cookie.Domain, "an omitted Domain attribute keeps the cookie host-only") + require.True(t, cookie.HttpOnly) + require.True(t, cookie.Secure) + require.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + require.Positive(t, cookie.MaxAge) +} + +func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) { + t.Setenv("API_ENDPOINT", "/gateway/v2/") + req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil) + req.Header.Set(TransportHeader, TransportCookie) + recorder := httptest.NewRecorder() + + SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour)) + + require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path) +} + +func TestRefreshCookieIsAlwaysSecure(t *testing.T) { + t.Setenv("API_ENDPOINT", "/api/v1") + tests := []struct { + name string + target string + forwardedProto string + }{ + {name: "public hostname", target: "http://app.sendico.io/api/v1/accounts/login"}, + {name: "localhost", target: "http://localhost:4000/api/v1/accounts/login"}, + {name: "loopback IP", target: "http://127.0.0.1:4000/api/v1/accounts/login"}, + {name: "private IP", target: "http://192.168.1.20:4000/api/v1/accounts/login"}, + {name: "forwarded HTTP", target: "http://app.sendico.io/api/v1/accounts/login", forwardedProto: "http"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, test.target, nil) + req.Header.Set(TransportHeader, TransportCookie) + if test.forwardedProto != "" { + req.Header.Set("X-Forwarded-Proto", test.forwardedProto) + } + recorder := httptest.NewRecorder() + + SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour)) + + require.True(t, recorder.Result().Cookies()[0].Secure) + }) + } +} + +func TestClearRefreshCookieUsesHardenedPolicy(t *testing.T) { + t.Setenv("API_ENDPOINT", "/api/v1") + req := httptest.NewRequest(http.MethodPost, "http://192.168.1.20:4000/api/v1/accounts/logout", nil) + req.Header.Set(TransportHeader, TransportCookie) + recorder := httptest.NewRecorder() + + ClearRefreshCookie(recorder, req) + + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + require.Equal(t, RefreshCookie, cookie.Name) + require.Empty(t, cookie.Value) + require.Equal(t, -1, cookie.MaxAge) + require.True(t, cookie.HttpOnly) + require.True(t, cookie.Secure) + require.Equal(t, http.SameSiteStrictMode, cookie.SameSite) +} + +func TestValidateCSRFRejectsCrossSiteAndMissingHeaderRequests(t *testing.T) { + allowed := []string{"https://app.sendico.io"} + + missingHeader := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil) + missingHeader.Header.Set(TransportHeader, TransportCookie) + missingHeader.Header.Set("Origin", allowed[0]) + require.Error(t, ValidateCSRF(missingHeader, allowed)) + + crossSite := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil) + crossSite.Header.Set(TransportHeader, TransportCookie) + crossSite.Header.Set(CSRFHeader, CSRFValue) + crossSite.Header.Set("Origin", "https://attacker.example") + require.Error(t, ValidateCSRF(crossSite, allowed)) + + valid := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil) + valid.Header.Set(TransportHeader, TransportCookie) + valid.Header.Set(CSRFHeader, CSRFValue) + valid.Header.Set("Origin", allowed[0]) + require.NoError(t, ValidateCSRF(valid, allowed)) +} + +func TestRefreshCredentialNeverFallsBackForCookieTransport(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", nil) + req.Header.Set(TransportHeader, TransportCookie) + + credential, err := RefreshCredential(req, "body-token") + require.Error(t, err) + require.Empty(t, credential) +} diff --git a/backend/edge/shared/auth/client/client.go b/backend/edge/shared/auth/client/client.go new file mode 100644 index 0000000..7b7f395 --- /dev/null +++ b/backend/edge/shared/auth/client/client.go @@ -0,0 +1,29 @@ +package client + +import ( + "context" + "errors" + "strings" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +var ErrUnknownClientID = merrors.Define("unknown client id") + +func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*model.Client, error) { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return nil, ErrUnknownClientID + } + + client, err := rtdb.GetClient(ctx, clientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + return nil, ErrUnknownClientID + } + if err != nil { + return nil, err + } + return client, nil +} diff --git a/backend/edge/shared/auth/client/client_test.go b/backend/edge/shared/auth/client/client_test.go new file mode 100644 index 0000000..dfdc8bb --- /dev/null +++ b/backend/edge/shared/auth/client/client_test.go @@ -0,0 +1,53 @@ +package client + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type resolveClientDB struct { + refreshtokens.DB + calls int + client *model.Client + err error +} + +func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { + db.calls++ + return db.client, db.err +} + +func TestResolveRejectsBlankClientIDWithoutStorageLookup(t *testing.T) { + db := &resolveClientDB{} + + client, err := Resolve(context.Background(), db, " ") + if !errors.Is(err, ErrUnknownClientID) { + t.Fatalf("expected unknown client id, got %v", err) + } + if client != nil { + t.Fatalf("expected nil client, got %#v", client) + } + if db.calls != 0 { + t.Fatalf("expected no storage lookup for blank client id, got %d", db.calls) + } +} + +func TestResolveTrimsClientID(t *testing.T) { + want := &model.Client{ClientID: "client-1"} + db := &resolveClientDB{client: want} + + client, err := Resolve(context.Background(), db, " client-1 ") + if err != nil { + t.Fatalf("expected client resolution to succeed, got %v", err) + } + if client != want { + t.Fatalf("expected resolved client %#v, got %#v", want, client) + } + if db.calls != 1 { + t.Fatalf("expected one storage lookup, got %d", db.calls) + } +} diff --git a/backend/edge/shared/auth/sessionmetrics/metrics.go b/backend/edge/shared/auth/sessionmetrics/metrics.go new file mode 100644 index 0000000..8b7917d --- /dev/null +++ b/backend/edge/shared/auth/sessionmetrics/metrics.go @@ -0,0 +1,35 @@ +package sessionmetrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type Operation string + +const ( + Validation Operation = "validation" + CurrentLogout Operation = "current_logout" + AllLogout Operation = "all_logout" +) + +type Outcome string + +const ( + Active Outcome = "active" + Succeeded Outcome = "succeeded" + AlreadyInactive Outcome = "already_inactive" + MissingIdentity Outcome = "missing_identity" + StorageError Outcome = "storage_error" +) + +var lifecycle = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "auth", + Name: "session_lifecycle_total", + Help: "Session validation and logout outcomes.", +}, []string{"operation", "outcome"}) + +func Observe(operation Operation, outcome Outcome) { + lifecycle.WithLabelValues(string(operation), string(outcome)).Inc() +} diff --git a/backend/edge/shared/auth/storage/stores.go b/backend/edge/shared/auth/storage/stores.go new file mode 100644 index 0000000..30edf34 --- /dev/null +++ b/backend/edge/shared/auth/storage/stores.go @@ -0,0 +1,148 @@ +package storage + +import ( + edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.uber.org/zap" +) + +type Stores struct { + Accounts account.DB + Verification verification.DB + RefreshTokens refreshtokens.DB + LoginAttempts ratelimits.Store +} + +type TokenStores struct { + Verification verification.DB + RefreshTokens refreshtokens.DB +} + +type resolvedConfig struct { + deps *edgemongo.Dependencies + storage edgeapi.AuthStorageConfig +} + +func OpenStores( + logger mlogger.Logger, + dbf db.Factory, + config *edgeapi.AuthStorageConfig, +) (*Stores, error) { + resolved, err := resolve(logger, dbf, config) + if err != nil { + return nil, err + } + logger.Info( + "Auth storage configured", + zap.String("accounts_collection", resolved.storage.AccountsCollection), + zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection), + zap.String("clients_collection", resolved.storage.ClientsCollection), + zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection), + zap.String("login_attempts_collection", resolved.storage.LoginAttemptsCollection), + zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection), + ) + + adb, err := dbf.NewAccountDBWithCollection(resolved.storage.AccountsCollection) + if err != nil { + logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", resolved.storage.AccountsCollection)) + return nil, err + } + + tokens, err := openTokenStores(logger, resolved) + if err != nil { + return nil, err + } + + attempts, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.LoginAttemptsCollection) + if err != nil { + logger.Error("Failed to create login attempt database", zap.Error(err), zap.String("collection", resolved.storage.LoginAttemptsCollection)) + return nil, err + } + + return &Stores{ + Accounts: adb, + Verification: tokens.Verification, + RefreshTokens: tokens.RefreshTokens, + LoginAttempts: attempts, + }, nil +} + +func OpenTokenStores( + logger mlogger.Logger, + dbf db.Factory, + config *edgeapi.AuthStorageConfig, +) (*TokenStores, error) { + resolved, err := resolve(logger, dbf, config) + if err != nil { + return nil, err + } + logger.Info( + "Auth token storage configured", + zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection), + zap.String("clients_collection", resolved.storage.ClientsCollection), + zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection), + zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection), + ) + return openTokenStores(logger, resolved) +} + +func resolve( + logger mlogger.Logger, + dbf db.Factory, + config *edgeapi.AuthStorageConfig, +) (*resolvedConfig, error) { + deps, err := edgemongo.ResolveDependencies(dbf) + if err != nil { + logger.Error("Failed to resolve local auth storage dependencies", zap.Error(err)) + return nil, err + } + + return &resolvedConfig{ + deps: deps, + storage: config.WithDefaults(), + }, nil +} + +func openTokenStores(logger mlogger.Logger, resolved *resolvedConfig) (*TokenStores, error) { + rtdb, err := refreshtokens.CreateWithCollections(logger, resolved.deps.Database, refreshtokens.Collections{ + RefreshTokens: resolved.storage.RefreshTokensCollection, + Clients: resolved.storage.ClientsCollection, + }) + if err != nil { + logger.Error( + "Failed to create refresh token management database", + zap.Error(err), + zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection), + zap.String("clients_collection", resolved.storage.ClientsCollection), + ) + return nil, err + } + verificationLimits, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.VerificationRateLimitsCollection) + if err != nil { + logger.Error("Failed to create verification rate-limit database", zap.Error(err), zap.String("collection", resolved.storage.VerificationRateLimitsCollection)) + return nil, err + } + + cdb, err := verification.CreateWithCollection( + logger, + resolved.deps.Database, + resolved.deps.Transactions, + resolved.storage.VerificationTokensCollection, + ) + if err != nil { + logger.Error("Failed to create verification token database", zap.Error(err), zap.String("collection", resolved.storage.VerificationTokensCollection)) + return nil, err + } + cdb.WithIssuanceLimits(verificationLimits) + + return &TokenStores{ + Verification: cdb, + RefreshTokens: rtdb, + }, nil +} diff --git a/backend/edge/shared/auth/tokens/prepare_test.go b/backend/edge/shared/auth/tokens/prepare_test.go new file mode 100644 index 0000000..524778e --- /dev/null +++ b/backend/edge/shared/auth/tokens/prepare_test.go @@ -0,0 +1,102 @@ +package tokens + +import ( + "context" + "net/http" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type recordingRefreshTokenDB struct { + refreshtokens.DB + token *model.RefreshToken +} + +func (db *recordingRefreshTokenDB) Create(_ context.Context, token *model.RefreshToken) error { + db.token = token + return nil +} + +func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + realIP string + want string + }{ + { + name: "proxied IPv4", + remoteAddr: "172.19.0.37:45706", + realIP: "203.0.113.10", + want: "203.0.113.10", + }, + { + name: "direct IPv4", + remoteAddr: "203.0.113.11:45706", + want: "203.0.113.11", + }, + { + name: "proxied IPv6", + remoteAddr: "172.19.0.37:45706", + realIP: "2001:0db8:0000:0000:0000:0000:0000:0012", + want: "2001:db8::12", + }, + { + name: "direct IPv6", + remoteAddr: "[2001:db8::13]:45706", + want: "2001:db8::13", + }, + { + name: "missing address", + want: "", + }, + { + name: "invalid address", + remoteAddr: "not-an-address", + realIP: "also-not-an-address", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &http.Request{ + RemoteAddr: tt.remoteAddr, + Header: make(http.Header), + } + if tt.realIP != "" { + request.Header.Set("X-Real-IP", tt.realIP) + } + + account := &model.Account{} + account.SetID(bson.NewObjectID()) + db := &recordingRefreshTokenDB{} + _, err := PrepareRefreshToken( + context.Background(), + request, + db, + 32, + 24, + &model.SessionIdentifier{ClientID: "client", DeviceID: "device"}, + account, + zap.NewNop(), + ) + if err != nil { + t.Fatalf("prepare refresh token: %v", err) + } + if db.token == nil { + t.Fatal("expected refresh token to be stored") + } + if db.token.IPAddress != tt.want { + t.Fatalf("unexpected stored IP address: got %q, want %q", db.token.IPAddress, tt.want) + } + if db.token.SessionVersion == "" { + t.Fatal("expected a server-generated session version") + } + }) + } +} diff --git a/backend/edge/shared/auth/tokens/tokens.go b/backend/edge/shared/auth/tokens/tokens.go new file mode 100644 index 0000000..9b7037c --- /dev/null +++ b/backend/edge/shared/auth/tokens/tokens.go @@ -0,0 +1,77 @@ +package tokens + +import ( + "context" + "crypto/rand" + "encoding/base64" + "io" + "net/http" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.uber.org/zap" +) + +func generateRefreshTokenData(length int) (string, error) { + randomBytes := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil { + return "", merrors.InternalWrap(err, "failed to generate secure random bytes") + } + + return base64.URLEncoding.EncodeToString(randomBytes), nil +} + +func PrepareRefreshToken( + ctx context.Context, + r *http.Request, + rtdb refreshtokens.DB, + length int, + refreshExpiration int, + session *model.SessionIdentifier, + account *model.Account, + logger mlogger.Logger, +) (*model.RefreshToken, error) { + refreshToken, err := generateRefreshTokenData(length) + if err != nil { + logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account)) + return nil, err + } + sessionVersion, err := generateRefreshTokenData(16) + if err != nil { + logger.Warn("Failed to generate session version", zap.Error(err), mzap.StorableRef(account)) + return nil, err + } + versionedSession := *session + versionedSession.SessionVersion = sessionVersion + + clientIP := ipguard.ClientIP(r) + ipAddress := "" + if clientIP != nil { + ipAddress = clientIP.String() + } + + token := &model.RefreshToken{ + AccountRef: account.GetID(), + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: versionedSession, + RefreshToken: refreshToken, + }, + ExpiresAt: time.Now().Add(time.Duration(refreshExpiration) * time.Hour), + IsRevoked: false, + UserAgent: r.UserAgent(), + IPAddress: ipAddress, + } + + if err = rtdb.Create(ctx, token); err != nil { + logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account), + zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID)) + return nil, err + } + + return token, nil +} diff --git a/backend/edge/shared/http/ipguard/ipguard.go b/backend/edge/shared/http/ipguard/ipguard.go new file mode 100644 index 0000000..6a4221d --- /dev/null +++ b/backend/edge/shared/http/ipguard/ipguard.go @@ -0,0 +1,73 @@ +package ipguard + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP resolves the real caller IP from the request. +// When the request arrives via the Caddy reverse proxy, the real client IP is +// forwarded in the X-Real-IP header (set via "header_up X-Real-IP {remote_host}" +// in the Caddyfile). RemoteAddr is used as a fallback for direct connections +// (local development, health checks, etc.). +func ClientIP(r *http.Request) net.IP { + if r == nil { + return nil + } + // Prefer X-Real-IP set by the trusted Caddy reverse proxy. + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + if ip := net.ParseIP(xri); ip != nil { + return ip + } + } + raw := strings.TrimSpace(r.RemoteAddr) + if raw == "" { + return nil + } + if ip := net.ParseIP(raw); ip != nil { + return ip + } + host, _, err := net.SplitHostPort(raw) + if err != nil { + return nil + } + return net.ParseIP(host) +} + +func parseCIDRs(raw []string) ([]*net.IPNet, error) { + blocks := make([]*net.IPNet, 0, len(raw)) + for _, item := range raw { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + _, block, err := net.ParseCIDR(clean) + if err != nil { + return nil, err + } + blocks = append(blocks, block) + } + return blocks, nil +} + +// Allowed reports whether clientIP is allowed by configured CIDRs. +// Empty CIDR list means unrestricted access. +func Allowed(clientIP net.IP, cidrs []string) (bool, error) { + blocks, err := parseCIDRs(cidrs) + if err != nil { + return false, err + } + if len(blocks) == 0 { + return true, nil + } + if clientIP == nil { + return false, nil + } + for _, block := range blocks { + if block.Contains(clientIP) { + return true, nil + } + } + return false, nil +} diff --git a/backend/edge/shared/http/ipguard/ipguard_test.go b/backend/edge/shared/http/ipguard/ipguard_test.go new file mode 100644 index 0000000..a2b7f31 --- /dev/null +++ b/backend/edge/shared/http/ipguard/ipguard_test.go @@ -0,0 +1,116 @@ +package ipguard + +import ( + "net" + "net/http" + "testing" +) + +func TestClientIP(t *testing.T) { + t.Run("extracts host from remote addr", func(t *testing.T) { + req := &http.Request{RemoteAddr: "10.1.2.3:1234"} + ip := ClientIP(req) + if ip == nil || ip.String() != "10.1.2.3" { + t.Fatalf("unexpected ip: %v", ip) + } + }) + + t.Run("supports plain ip", func(t *testing.T) { + req := &http.Request{RemoteAddr: "8.8.8.8"} + ip := ClientIP(req) + if ip == nil || ip.String() != "8.8.8.8" { + t.Fatalf("unexpected ip: %v", ip) + } + }) + + t.Run("invalid remote addr", func(t *testing.T) { + req := &http.Request{RemoteAddr: "invalid"} + if ip := ClientIP(req); ip != nil { + t.Fatalf("expected nil ip, got %v", ip) + } + }) + + t.Run("prefers X-Real-IP over remote addr", func(t *testing.T) { + req := &http.Request{ + RemoteAddr: "172.19.0.31:5000", + Header: http.Header{"X-Real-Ip": []string{"178.57.67.154"}}, + } + ip := ClientIP(req) + if ip == nil || ip.String() != "178.57.67.154" { + t.Fatalf("expected real client ip, got %v", ip) + } + }) + + t.Run("falls back to remote addr when X-Real-IP absent", func(t *testing.T) { + req := &http.Request{RemoteAddr: "10.0.0.1:9000"} + ip := ClientIP(req) + if ip == nil || ip.String() != "10.0.0.1" { + t.Fatalf("expected remote addr ip, got %v", ip) + } + }) + + t.Run("falls back to remote addr when X-Real-IP invalid", func(t *testing.T) { + req := &http.Request{ + RemoteAddr: "10.0.0.1:9000", + Header: http.Header{"X-Real-Ip": []string{"not-an-ip"}}, + } + ip := ClientIP(req) + if ip == nil || ip.String() != "10.0.0.1" { + t.Fatalf("expected fallback to remote addr, got %v", ip) + } + }) +} + +func TestAllowed(t *testing.T) { + clientIP := net.ParseIP("10.1.2.3") + if clientIP == nil { + t.Fatal("failed to parse test ip") + } + + t.Run("allows when cidr matches", func(t *testing.T) { + allowed, err := Allowed(clientIP, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !allowed { + t.Fatal("expected allowed") + } + }) + + t.Run("denies when cidr does not match", func(t *testing.T) { + allowed, err := Allowed(clientIP, []string{"192.168.0.0/16"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if allowed { + t.Fatal("expected denied") + } + }) + + t.Run("allows when cidr list is empty", func(t *testing.T) { + allowed, err := Allowed(clientIP, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !allowed { + t.Fatal("expected allowed") + } + }) + + t.Run("invalid cidr fails", func(t *testing.T) { + _, err := Allowed(clientIP, []string{"not-a-cidr"}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("nil client ip denied when cidrs configured", func(t *testing.T) { + allowed, err := Allowed(nil, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if allowed { + t.Fatal("expected denied") + } + }) +} diff --git a/backend/edge/shared/interface/api/api.go b/backend/edge/shared/interface/api/api.go new file mode 100644 index 0000000..f68cf6d --- /dev/null +++ b/backend/edge/shared/interface/api/api.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type API interface { + LifecycleContext() context.Context + Logger() mlogger.Logger + DomainProvider() domainprovider.DomainProvider + Config() *Config + ProfileConfig() ConfigProvider + DBFactory() db.Factory + Permissions() auth.Provider + RuntimeComponent(name string) any + Register() Register +} + +type MicroServiceFactoryT = func(API) (mservice.MicroService, error) diff --git a/backend/edge/shared/interface/api/auth_config.go b/backend/edge/shared/interface/api/auth_config.go new file mode 100644 index 0000000..e155092 --- /dev/null +++ b/backend/edge/shared/interface/api/auth_config.go @@ -0,0 +1,41 @@ +package api + +type AuthStorageConfig struct { + AccountsCollection string `yaml:"accounts_collection"` + RefreshTokensCollection string `yaml:"refresh_tokens_collection"` + ClientsCollection string `yaml:"clients_collection"` + VerificationTokensCollection string `yaml:"verification_tokens_collection"` + LoginAttemptsCollection string `yaml:"login_attempts_collection"` + VerificationRateLimitsCollection string `yaml:"verification_rate_limits_collection"` +} + +func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig { + if c == nil { + c = &AuthStorageConfig{} + } + res := *c + if res.AccountsCollection == "" { + res.AccountsCollection = "accounts" + } + if res.RefreshTokensCollection == "" { + res.RefreshTokensCollection = "refresh_tokens" + } + if res.ClientsCollection == "" { + res.ClientsCollection = "clients" + } + if res.VerificationTokensCollection == "" { + res.VerificationTokensCollection = "verification_tokens" + } + if res.LoginAttemptsCollection == "" { + res.LoginAttemptsCollection = "login_attempts" + } + if res.VerificationRateLimitsCollection == "" { + res.VerificationRateLimitsCollection = "verification_rate_limits" + } + return res +} + +type VerificationConfig struct { + AllowedPurposes []string `yaml:"allowed_purposes"` + RateLimits *VerificationRateLimitsConfig `yaml:"rate_limits"` +} diff --git a/backend/edge/shared/interface/api/config.go b/backend/edge/shared/interface/api/config.go new file mode 100644 index 0000000..9b1c500 --- /dev/null +++ b/backend/edge/shared/interface/api/config.go @@ -0,0 +1,17 @@ +package api + +import ( + mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage" +) + +type Config struct { + Mw *mwa.Config `yaml:"middleware"` + AuthStorage *AuthStorageConfig `yaml:"auth_storage"` + Verification *VerificationConfig `yaml:"verification"` + Storage *storageconfig.Config `yaml:"storage"` +} + +func (c *Config) SharedConfig() *Config { + return c +} diff --git a/backend/edge/shared/interface/api/register.go b/backend/edge/shared/interface/api/register.go new file mode 100644 index 0000000..a412717 --- /dev/null +++ b/backend/edge/shared/interface/api/register.go @@ -0,0 +1,15 @@ +package api + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +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) + WSHandler(messageType string, handler ws.HandlerFunc) +} diff --git a/backend/edge/shared/interface/api/runtime.go b/backend/edge/shared/interface/api/runtime.go new file mode 100644 index 0000000..a75aa5c --- /dev/null +++ b/backend/edge/shared/interface/api/runtime.go @@ -0,0 +1,40 @@ +package api + +import "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + +type ConfigProvider interface { + SharedConfig() *Config +} + +type ConfigFactory func() ConfigProvider +type RuntimeComponents map[string]any +type RuntimeBootstrapFunc func(API) (RuntimeComponents, error) +type RuntimeFinishFunc func(API) error + +type RuntimeConfig struct { + Name string + Services []ServiceFactory + EnableWebSocket bool + ConfigFactory ConfigFactory + Bootstrap RuntimeBootstrapFunc + Finish RuntimeFinishFunc +} + +type ServiceFactory struct { + Name mservice.Type + Factory MicroServiceFactoryT +} + +func (cfg RuntimeConfig) RuntimeName() string { + if cfg.Name == "" { + return "edge" + } + return cfg.Name +} + +func (cfg RuntimeConfig) NewConfig() ConfigProvider { + if cfg.ConfigFactory != nil { + return cfg.ConfigFactory() + } + return &Config{} +} diff --git a/backend/edge/shared/interface/api/srequest/changepolicies.go b/backend/edge/shared/interface/api/srequest/changepolicies.go new file mode 100644 index 0000000..7b12476 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/changepolicies.go @@ -0,0 +1,12 @@ +package srequest + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ChangePolicies struct { + RoleRef bson.ObjectID `json:"roleRef"` + Add *[]model.RolePolicy `json:"add,omitempty"` + Remove *[]model.RolePolicy `json:"remove,omitempty"` +} diff --git a/backend/edge/shared/interface/api/srequest/changerole.go b/backend/edge/shared/interface/api/srequest/changerole.go new file mode 100644 index 0000000..ec2b351 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/changerole.go @@ -0,0 +1,8 @@ +package srequest + +import "go.mongodb.org/mongo-driver/v2/bson" + +type ChangeRole struct { + AccountRef bson.ObjectID `json:"accountRef"` + NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"` +} diff --git a/backend/edge/shared/interface/api/srequest/login.go b/backend/edge/shared/interface/api/srequest/login.go new file mode 100644 index 0000000..9ae8534 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/login.go @@ -0,0 +1,9 @@ +package srequest + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type Login struct { + model.SessionIdentifier `json:",inline"` + model.LoginData `json:"login"` + ClientSecret string `json:"clientSecret,omitempty"` +} diff --git a/backend/edge/shared/interface/api/srequest/refresh.go b/backend/edge/shared/interface/api/srequest/refresh.go new file mode 100644 index 0000000..cfe5d60 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/refresh.go @@ -0,0 +1,5 @@ +package srequest + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type AccessTokenRefresh = model.ClientRefreshToken diff --git a/backend/edge/shared/interface/api/srequest/rotate.go b/backend/edge/shared/interface/api/srequest/rotate.go new file mode 100644 index 0000000..0b98fd5 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/rotate.go @@ -0,0 +1,5 @@ +package srequest + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type TokenRefreshRotate = model.ClientRefreshToken diff --git a/backend/edge/shared/interface/api/srequest/validateable.go b/backend/edge/shared/interface/api/srequest/validateable.go new file mode 100644 index 0000000..b4a5c12 --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/validateable.go @@ -0,0 +1,5 @@ +package srequest + +type Validatable interface { + Validate() error +} diff --git a/backend/edge/shared/interface/api/srequest/verification.go b/backend/edge/shared/interface/api/srequest/verification.go new file mode 100644 index 0000000..551e5ca --- /dev/null +++ b/backend/edge/shared/interface/api/srequest/verification.go @@ -0,0 +1,15 @@ +package srequest + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type VerificationCodeRequest struct { + Purpose string `json:"purpose"` + Target string `json:"target,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type CodeVerificationRequest struct { + VerificationCodeRequest `json:",inline"` + Code string `json:"code"` + SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"` +} diff --git a/backend/edge/shared/interface/api/sresponse/account.go b/backend/edge/shared/interface/api/sresponse/account.go new file mode 100644 index 0000000..1e7ee59 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/account.go @@ -0,0 +1,60 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AccountData struct { + model.AccountPublic `json:",inline"` + IsAnonymous bool `json:"isAnonymous"` +} + +type accountResponse struct { + authResponse `json:",inline"` + Account AccountData `json:"account"` +} + +type accountsResponse struct { + authResponse `json:",inline"` + Accounts []AccountData `json:"accounts"` +} + +func NewAccountData(account *model.Account, isAnonymous bool) *AccountData { + return &AccountData{ + AccountPublic: account.AccountPublic, + IsAnonymous: isAnonymous, + } +} + +func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc { + return response.Ok( + logger, + &accountResponse{ + Account: *NewAccountData(account, false), + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} + +func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc { + publicAccounts := make([]AccountData, len(accounts)) + for i := range accounts { + publicAccounts[i] = *NewAccountData( + &accounts[i], + model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef), + ) + } + + return response.Ok( + logger, + &accountsResponse{ + Accounts: publicAccounts, + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} diff --git a/backend/edge/shared/interface/api/sresponse/authresp.go b/backend/edge/shared/interface/api/sresponse/authresp.go new file mode 100644 index 0000000..c148e9b --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/authresp.go @@ -0,0 +1,7 @@ +package sresponse + +type AuthResponse struct { + AccessToken TokenData `json:"accessToken"` +} + +type authResponse = AuthResponse diff --git a/backend/edge/shared/interface/api/sresponse/file.go b/backend/edge/shared/interface/api/sresponse/file.go new file mode 100644 index 0000000..bd07d78 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/file.go @@ -0,0 +1,16 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +type fileUploaded struct { + URL string `json:"url"` +} + +func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc { + return response.Ok(logger, &fileUploaded{URL: url}) +} diff --git a/backend/edge/shared/interface/api/sresponse/login.go b/backend/edge/shared/interface/api/sresponse/login.go new file mode 100644 index 0000000..ae2f7da --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/login.go @@ -0,0 +1,35 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type loginResponse struct { + accountResponse + RefreshToken TokenData `json:"refreshToken"` +} + +func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + responseRefreshToken := *refreshToken + if browser.UsesCookie(r) { + browser.SetRefreshCookie(w, r, refreshToken.Token, refreshToken.Expiration) + responseRefreshToken.Token = "" + } + response.Ok( + logger, + &loginResponse{ + accountResponse: accountResponse{ + Account: *NewAccountData(account, false), + authResponse: authResponse{AccessToken: *accessToken}, + }, + RefreshToken: responseRefreshToken, + }, + ).ServeHTTP(w, r) + } +} diff --git a/backend/edge/shared/interface/api/sresponse/login_pending.go b/backend/edge/shared/interface/api/sresponse/login_pending.go new file mode 100644 index 0000000..8e054ad --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/login_pending.go @@ -0,0 +1,29 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type pendingLoginResponse struct { + Account accountResponse `json:"account"` + PendingToken TokenData `json:"pendingToken"` + Target string `json:"target"` +} + +func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc { + return response.Accepted( + logger, + &pendingLoginResponse{ + Account: accountResponse{ + Account: *NewAccountData(account, false), + authResponse: authResponse{}, + }, + PendingToken: *pendingToken, + Target: target, + }, + ) +} diff --git a/backend/edge/shared/interface/api/sresponse/login_test.go b/backend/edge/shared/interface/api/sresponse/login_test.go new file mode 100644 index 0000000..d0e4f84 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/login_test.go @@ -0,0 +1,46 @@ +package sresponse + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestBrowserLoginMovesRefreshCredentialToHttpOnlyCookie(t *testing.T) { + refresh := &TokenData{Token: "refresh-secret", Expiration: time.Now().Add(time.Hour)} + access := &TokenData{Token: "access-secret", Expiration: time.Now().Add(time.Minute)} + req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil) + req.Header.Set(browser.TransportHeader, browser.TransportCookie) + req.Header.Set("X-Forwarded-Proto", "https") + recorder := httptest.NewRecorder() + + Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + require.NotContains(t, recorder.Body.String(), refresh.Token) + require.Contains(t, recorder.Body.String(), access.Token) + setCookie := recorder.Header().Get("Set-Cookie") + require.True(t, strings.HasPrefix(setCookie, browser.RefreshCookie+"=")) + require.Contains(t, setCookie, "HttpOnly") + require.Contains(t, setCookie, "Secure") + require.Contains(t, setCookie, "SameSite=Strict") +} + +func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) { + refresh := &TokenData{Token: "native-refresh", Expiration: time.Now().Add(time.Hour)} + access := &TokenData{Token: "native-access", Expiration: time.Now().Add(time.Minute)} + req := httptest.NewRequest(http.MethodPost, "http://native.test/api/v1/accounts/login", nil) + recorder := httptest.NewRecorder() + + Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req) + + require.Contains(t, recorder.Body.String(), refresh.Token) + require.Empty(t, recorder.Header().Get("Set-Cookie")) +} diff --git a/backend/edge/shared/interface/api/sresponse/organization.go b/backend/edge/shared/interface/api/sresponse/organization.go new file mode 100644 index 0000000..e632a31 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/organization.go @@ -0,0 +1,35 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type organizationsResponse struct { + authResponse `json:",inline"` + Organizations []model.Organization `json:"organizations"` +} + +func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc { + return Organizations(logger, []model.Organization{*organization}, accessToken) +} + +func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, organizationsResponse{ + Organizations: organizations, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +type organizationPublicResponse struct { + Organizations []model.OrganizationBase `json:"organizations"` +} + +func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc { + return response.Ok(logger, organizationPublicResponse{ + Organizations: []model.OrganizationBase{*organization}, + }) +} diff --git a/backend/edge/shared/interface/api/sresponse/permissions.go b/backend/edge/shared/interface/api/sresponse/permissions.go new file mode 100644 index 0000000..f1ca7f2 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/permissions.go @@ -0,0 +1,45 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type permissionsDescription struct { + Roles []model.RoleDescription `json:"roles"` + Policies []model.PolicyDescription `json:"policies"` +} + +type permissionsData struct { + Roles []model.Role `json:"roles"` + Policies []model.RolePolicy `json:"policies"` + Permissions []model.Permission `json:"permissions"` +} + +type permissionsResponse struct { + authResponse `json:",inline"` + Descriptions permissionsDescription `json:"descriptions"` + Permissions permissionsData `json:"permissions"` +} + +func Permissions(logger mlogger.Logger, + rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription, + roles []model.Role, policies []model.RolePolicy, permissions []model.Permission, + accessToken *TokenData, +) http.HandlerFunc { + return response.Ok(logger, permissionsResponse{ + Descriptions: permissionsDescription{ + Roles: rolesDescs, + Policies: policiesDescs, + }, + Permissions: permissionsData{ + Roles: roles, + Policies: policies, + Permissions: permissions, + }, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} diff --git a/backend/edge/shared/interface/api/sresponse/response.go b/backend/edge/shared/interface/api/sresponse/response.go new file mode 100644 index 0000000..849b790 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/response.go @@ -0,0 +1,14 @@ +package sresponse + +import ( + "net/http" + + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type ( + HandlerFunc = func(r *http.Request) http.HandlerFunc + AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc + PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc +) diff --git a/backend/edge/shared/interface/api/sresponse/result.go b/backend/edge/shared/interface/api/sresponse/result.go new file mode 100644 index 0000000..3ce4b12 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/result.go @@ -0,0 +1,27 @@ +package sresponse + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +type resultAuth struct { + authResponse `json:",inline"` + response.Result `json:",inline"` +} + +func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, &resultAuth{ + Result: response.Result{Result: true}, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc { + return response.Accepted(logger, &resultAuth{ + Result: response.Result{Result: false}, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} diff --git a/backend/edge/shared/interface/api/sresponse/token.go b/backend/edge/shared/interface/api/sresponse/token.go new file mode 100644 index 0000000..3bbf9df --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/token.go @@ -0,0 +1,8 @@ +package sresponse + +import "time" + +type TokenData struct { + Token string `json:"token"` + Expiration time.Time `json:"expiration"` +} diff --git a/backend/edge/shared/interface/api/sresponse/verification.go b/backend/edge/shared/interface/api/sresponse/verification.go new file mode 100644 index 0000000..bdd1571 --- /dev/null +++ b/backend/edge/shared/interface/api/sresponse/verification.go @@ -0,0 +1,8 @@ +package sresponse + +type VerificationResponse struct { + IdempotencyKey string `json:"idempotencyKey"` + TTLSeconds int `json:"ttl_seconds"` + CooldownSeconds int `json:"cooldown_seconds"` + Target string `json:"target"` +} diff --git a/backend/edge/shared/interface/api/verification_rate_limit.go b/backend/edge/shared/interface/api/verification_rate_limit.go new file mode 100644 index 0000000..f19bf11 --- /dev/null +++ b/backend/edge/shared/interface/api/verification_rate_limit.go @@ -0,0 +1,39 @@ +package api + +import ( + "time" + + sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" +) + +type VerificationRateLimitsConfig struct { + PerDestination sharedlimit.WindowConfig `yaml:"per_destination"` + PerIP sharedlimit.WindowConfig `yaml:"per_ip"` +} + +type VerificationRateLimits struct { + PerDestination sharedlimit.Window + PerIP sharedlimit.Window +} + +func DefaultVerificationRateLimits() VerificationRateLimits { + return VerificationRateLimits{ + PerDestination: sharedlimit.Window{Limit: 5, Window: time.Hour}, + PerIP: sharedlimit.Window{Limit: 20, Window: time.Hour}, + } +} + +func (c *VerificationConfig) ResolveRateLimits() (VerificationRateLimits, error) { + if c == nil || c.RateLimits == nil { + return DefaultVerificationRateLimits(), nil + } + destination, err := sharedlimit.ResolveWindow(c.RateLimits.PerDestination, "verification.rate_limits.per_destination") + if err != nil { + return VerificationRateLimits{}, err + } + ip, err := sharedlimit.ResolveWindow(c.RateLimits.PerIP, "verification.rate_limits.per_ip") + if err != nil { + return VerificationRateLimits{}, err + } + return VerificationRateLimits{PerDestination: destination, PerIP: ip}, nil +} diff --git a/backend/edge/shared/interface/api/verification_rate_limit_test.go b/backend/edge/shared/interface/api/verification_rate_limit_test.go new file mode 100644 index 0000000..1b7180b --- /dev/null +++ b/backend/edge/shared/interface/api/verification_rate_limit_test.go @@ -0,0 +1,32 @@ +package api + +import ( + "errors" + "testing" + "time" + + sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +func TestVerificationRateLimitsDefaults(t *testing.T) { + got, err := (*VerificationConfig)(nil).ResolveRateLimits() + if err != nil { + t.Fatalf("resolve defaults: %v", err) + } + if got.PerDestination.Limit != 5 || got.PerDestination.Window != time.Hour || + got.PerIP.Limit != 20 || got.PerIP.Window != time.Hour { + t.Fatalf("unexpected defaults: %+v", got) + } +} + +func TestVerificationRateLimitsRejectInvalidPolicy(t *testing.T) { + cfg := &VerificationConfig{RateLimits: &VerificationRateLimitsConfig{ + PerDestination: sharedlimit.WindowConfig{Limit: 5, Window: "1h"}, + PerIP: sharedlimit.WindowConfig{Limit: 0, Window: "1h"}, + }} + _, err := cfg.ResolveRateLimits() + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} diff --git a/backend/edge/shared/interface/api/ws/config.go b/backend/edge/shared/interface/api/ws/config.go new file mode 100644 index 0000000..b37a5a7 --- /dev/null +++ b/backend/edge/shared/interface/api/ws/config.go @@ -0,0 +1,9 @@ +package ws + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" +) + +type ( + Config = middleware.WebSocketConfig +) diff --git a/backend/edge/shared/interface/api/ws/handler.go b/backend/edge/shared/interface/api/ws/handler.go new file mode 100644 index 0000000..34979bb --- /dev/null +++ b/backend/edge/shared/interface/api/ws/handler.go @@ -0,0 +1,12 @@ +package ws + +import ( + "context" + + "golang.org/x/net/websocket" +) + +type ( + ResponseHandler func(messageType string, conn *websocket.Conn) + HandlerFunc func(ctx context.Context, msg Message) ResponseHandler +) diff --git a/backend/edge/shared/interface/api/ws/message.go b/backend/edge/shared/interface/api/ws/message.go new file mode 100644 index 0000000..e4bb5d6 --- /dev/null +++ b/backend/edge/shared/interface/api/ws/message.go @@ -0,0 +1,9 @@ +package ws + +import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + +type Message struct { + response.BaseResponse + ID string `json:"id"` + MessageType string `json:"messageType"` +} diff --git a/backend/edge/shared/interface/middleware/cors_test.go b/backend/edge/shared/interface/middleware/cors_test.go new file mode 100644 index 0000000..613ab83 --- /dev/null +++ b/backend/edge/shared/interface/middleware/cors_test.go @@ -0,0 +1,49 @@ +package middleware + +import "testing" + +func TestCredentialedCORSRequiresExplicitOrigins(t *testing.T) { + tests := []struct { + name string + config CORSSettings + wantErr bool + }{ + { + name: "explicit origin", + config: CORSSettings{ + AllowCredentials: true, + AllowedOrigins: []string{"https://app.sendico.io"}, + }, + }, + { + name: "wildcard", + config: CORSSettings{ + AllowCredentials: true, + AllowedOrigins: []string{"*"}, + }, + wantErr: true, + }, + { + name: "missing origin", + config: CORSSettings{ + AllowCredentials: true, + }, + wantErr: true, + }, + { + name: "non-credentialed compatibility", + config: CORSSettings{ + AllowedOrigins: []string{"*"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr) + } + }) + } +} diff --git a/backend/edge/shared/interface/middleware/login_rate_limit.go b/backend/edge/shared/interface/middleware/login_rate_limit.go new file mode 100644 index 0000000..705958a --- /dev/null +++ b/backend/edge/shared/interface/middleware/login_rate_limit.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "time" + + sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" +) + +const ( + defaultLoginAccountLimit = 5 + defaultLoginAccountWindow = 15 * time.Minute + defaultLoginIPLimit = 50 + defaultLoginIPWindow = 15 * time.Minute +) + +type LoginRateLimitWindowConfig = sharedlimit.WindowConfig + +type LoginRateLimitsConfig struct { + PerAccount LoginRateLimitWindowConfig `yaml:"per_account"` + PerIP LoginRateLimitWindowConfig `yaml:"per_ip"` +} + +type LoginRateLimitWindow = sharedlimit.Window + +type LoginRateLimits struct { + PerAccount LoginRateLimitWindow + PerIP LoginRateLimitWindow +} + +func (c *Config) ResolveLoginRateLimits() (LoginRateLimits, error) { + if c == nil || c.LoginRateLimits == nil { + return LoginRateLimits{ + PerAccount: LoginRateLimitWindow{Limit: defaultLoginAccountLimit, Window: defaultLoginAccountWindow}, + PerIP: LoginRateLimitWindow{Limit: defaultLoginIPLimit, Window: defaultLoginIPWindow}, + }, nil + } + + account, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerAccount, "middleware.login_rate_limits.per_account") + if err != nil { + return LoginRateLimits{}, err + } + ip, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerIP, "middleware.login_rate_limits.per_ip") + if err != nil { + return LoginRateLimits{}, err + } + return LoginRateLimits{PerAccount: account, PerIP: ip}, nil +} diff --git a/backend/edge/shared/interface/middleware/login_rate_limit_test.go b/backend/edge/shared/interface/middleware/login_rate_limit_test.go new file mode 100644 index 0000000..85ac262 --- /dev/null +++ b/backend/edge/shared/interface/middleware/login_rate_limit_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) { + got, err := (&Config{}).ResolveLoginRateLimits() + if err != nil { + t.Fatalf("resolve defaults: %v", err) + } + if got.PerAccount.Limit != 5 || got.PerAccount.Window != 15*time.Minute { + t.Fatalf("unexpected account defaults: %+v", got.PerAccount) + } + if got.PerIP.Limit != 50 || got.PerIP.Window != 15*time.Minute { + t.Fatalf("unexpected IP defaults: %+v", got.PerIP) + } +} + +func TestResolveLoginRateLimitsRejectsUnsafeConfiguration(t *testing.T) { + tests := []struct { + name string + cfg *LoginRateLimitsConfig + }{ + { + name: "zero account threshold", + cfg: &LoginRateLimitsConfig{ + PerAccount: LoginRateLimitWindowConfig{Window: "15m"}, + PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"}, + }, + }, + { + name: "invalid IP window", + cfg: &LoginRateLimitsConfig{ + PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "15m"}, + PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "forever"}, + }, + }, + { + name: "excessive account window", + cfg: &LoginRateLimitsConfig{ + PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "25h"}, + PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := (&Config{LoginRateLimits: test.cfg}).ResolveLoginRateLimits() + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } + }) + } +} diff --git a/backend/edge/shared/interface/middleware/middleware.go b/backend/edge/shared/interface/middleware/middleware.go new file mode 100644 index 0000000..7a3ebb8 --- /dev/null +++ b/backend/edge/shared/interface/middleware/middleware.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "os" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +type CORSSettings struct { + MaxAge int `yaml:"max_age"` + AllowedOrigins []string `yaml:"allowed_origins"` + AllowedMethods []string `yaml:"allowed_methods"` + AllowedHeaders []string `yaml:"allowed_headers"` + ExposedHeaders []string `yaml:"exposed_headers"` + AllowCredentials bool `yaml:"allow_credentials"` +} + +func (c CORSSettings) Validate() error { + if !c.AllowCredentials { + return nil + } + if len(c.AllowedOrigins) == 0 { + return merrors.InvalidArgument("credentialed CORS requires explicit allowed origins", "middleware.CORS.allowed_origins") + } + for _, origin := range c.AllowedOrigins { + if strings.TrimSpace(origin) == "*" { + return merrors.InvalidArgument("credentialed CORS does not allow wildcard origins", "middleware.CORS.allowed_origins") + } + } + return nil +} + +type Signature struct { + PublicKey any + PrivateKey []byte + Algorithm string +} + +type SignatureConfig struct { + PublicKeyEnv string `yaml:"public_key_env,omitempty"` + PrivateKeyEnv string `yaml:"secret_key_env"` + Algorithm string `yaml:"algorithm"` +} + +type TokenExpiration struct { + Account int `yaml:"account"` + Refresh int `yaml:"refresh"` +} + +type TokenConfig struct { + Expiration TokenExpiration `yaml:"expiration_hours"` + Length int `yaml:"length"` +} + +const MaxAccessTokenTTLHours = 1 + +func (c TokenConfig) Validate() error { + if c.Expiration.Account <= 0 || c.Expiration.Account > MaxAccessTokenTTLHours { + return merrors.InvalidArgument("access token expiration must be between 1 and 1 hour", "middleware.token.expiration_hours.account") + } + if c.Expiration.Refresh <= c.Expiration.Account { + return merrors.InvalidArgument("refresh token expiration must exceed access token expiration", "middleware.token.expiration_hours.refresh") + } + if c.Length < 32 { + return merrors.InvalidArgument("refresh token length must be at least 32 bytes", "middleware.token.length") + } + return nil +} + +type WebSocketConfig struct { + EndpointEnv string `yaml:"endpoint_env"` + Timeout int `yaml:"timeout"` +} + +type PasswordChecks struct { + Digit bool `yaml:"digit"` + Upper bool `yaml:"upper"` + Lower bool `yaml:"lower"` + Special bool `yaml:"special"` + MinLength int `yaml:"min_length"` +} + +type PasswordConfig struct { + TokenLength int `yaml:"token_length"` + Check PasswordChecks `yaml:"check"` +} + +type Config struct { + DomainEnv string `yaml:"domain_env"` + EndPointEnv string `yaml:"api_endpoint_env"` + APIProtocolEnv string `yaml:"api_protocol_env"` + Signature SignatureConfig `yaml:"signature"` + CORS CORSSettings `yaml:"CORS"` + WebSocket WebSocketConfig `yaml:"websocket"` + Token TokenConfig `yaml:"token"` + Password PasswordConfig `yaml:"password"` + LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"` +} + +type MapClaims = map[string]any + +func getKey(osEnv string) any { + if len(osEnv) == 0 { + return nil + } + return []byte(os.Getenv(osEnv)) +} + +func SignatureConf(conf *Config) Signature { + return Signature{ + PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)), + PublicKey: getKey(conf.Signature.PublicKeyEnv), + Algorithm: conf.Signature.Algorithm, + } +} diff --git a/backend/edge/shared/interface/middleware/token_test.go b/backend/edge/shared/interface/middleware/token_test.go new file mode 100644 index 0000000..090f6fe --- /dev/null +++ b/backend/edge/shared/interface/middleware/token_test.go @@ -0,0 +1,52 @@ +package middleware + +import "testing" + +func TestTokenConfigValidate(t *testing.T) { + tests := []struct { + name string + config TokenConfig + wantErr bool + }{ + { + name: "secure explicit TTLs", + config: TokenConfig{ + Expiration: TokenExpiration{Account: 1, Refresh: 720}, + Length: 32, + }, + }, + { + name: "rejects legacy 24 hour access token", + config: TokenConfig{ + Expiration: TokenExpiration{Account: 24, Refresh: 720}, + Length: 32, + }, + wantErr: true, + }, + { + name: "rejects missing access TTL", + config: TokenConfig{ + Expiration: TokenExpiration{Refresh: 720}, + Length: 32, + }, + wantErr: true, + }, + { + name: "rejects weak refresh token", + config: TokenConfig{ + Expiration: TokenExpiration{Account: 1, Refresh: 720}, + Length: 16, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr) + } + }) + } +} diff --git a/backend/edge/shared/interface/model/token.go b/backend/edge/shared/interface/model/token.go new file mode 100644 index 0000000..ac94199 --- /dev/null +++ b/backend/edge/shared/interface/model/token.go @@ -0,0 +1,140 @@ +package model + +import ( + "fmt" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AccountToken struct { + AccountRef bson.ObjectID + Login string + Name string + Locale string + model.SessionIdentifier + Expiration time.Time + Pending bool +} + +func createAccountToken(a *model.Account, expiration int, session model.SessionIdentifier) AccountToken { + return AccountToken{ + AccountRef: *a.GetID(), + Login: a.Login, + Name: a.Name, + Locale: a.Locale, + SessionIdentifier: session, + Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)), + Pending: false, + } +} + +func getTokenParam(claims middleware.MapClaims, param string) (string, error) { + id, ok := claims[param].(string) + if !ok { + return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param)) + } + + return id, nil +} + +const ( + paramNameID = "id" + paramNameName = "name" + paramNameLocale = "locale" + paramNameLogin = "login" + paramNameClientID = "clientId" + paramNameDeviceID = "deviceId" + paramNameSessionVersion = "sessionVersion" + paramNameExpiration = "exp" + paramNamePending = "pending" +) + +func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) { + var at AccountToken + var err error + var account string + if account, err = getTokenParam(claims, paramNameID); err != nil { + return nil, err + } + if at.AccountRef, err = bson.ObjectIDFromHex(account); err != nil { + return nil, err + } + if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil { + return nil, err + } + if at.Name, err = getTokenParam(claims, paramNameName); err != nil { + return nil, err + } + if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil { + return nil, err + } + if clientID, ok := claims[paramNameClientID]; ok { + if clientIDText, ok := clientID.(string); ok { + at.ClientID = clientIDText + } + } + if deviceID, ok := claims[paramNameDeviceID]; ok { + if deviceIDText, ok := deviceID.(string); ok { + at.DeviceID = deviceIDText + } + } + if version, ok := claims[paramNameSessionVersion]; ok { + if versionText, ok := version.(string); ok { + at.SessionVersion = versionText + } + } + if pending, ok := claims[paramNamePending]; ok { + if pbool, ok := pending.(bool); ok { + at.Pending = pbool + } + } + if expValue, ok := claims[paramNameExpiration]; ok { + switch exp := expValue.(type) { + case time.Time: + at.Expiration = exp + case float64: + at.Expiration = time.Unix(int64(exp), 0) + case int64: + at.Expiration = time.Unix(exp, 0) + default: + return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue)) + } + } else { + return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue)) + } + return &at, nil +} + +func Account2ClaimsForSession(a *model.Account, expiration int, session model.SessionIdentifier) middleware.MapClaims { + t := createAccountToken(a, expiration, session) + return middleware.MapClaims{ + paramNameID: t.AccountRef.Hex(), + paramNameLogin: t.Login, + paramNameName: t.Name, + paramNameLocale: t.Locale, + paramNameClientID: t.ClientID, + paramNameDeviceID: t.DeviceID, + paramNameSessionVersion: t.SessionVersion, + paramNameExpiration: t.Expiration.Unix(), + paramNamePending: t.Pending, + } +} + +func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims { + t := createAccountToken(a, expirationMinutes/60, model.SessionIdentifier{}) + t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute) + t.Pending = true + return middleware.MapClaims{ + paramNameID: t.AccountRef.Hex(), + paramNameLogin: t.Login, + paramNameName: t.Name, + paramNameLocale: t.Locale, + paramNameExpiration: t.Expiration.Unix(), + paramNamePending: t.Pending, + } +} diff --git a/backend/edge/shared/interface/model/token_test.go b/backend/edge/shared/interface/model/token_test.go new file mode 100644 index 0000000..741ba90 --- /dev/null +++ b/backend/edge/shared/interface/model/token_test.go @@ -0,0 +1,32 @@ +package model + +import ( + "testing" + + basemodel "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestAccountTokenPreservesSessionIdentity(t *testing.T) { + account := &basemodel.Account{} + account.SetID(bson.NewObjectID()) + account.Login = "security@example.com" + account.Name = "Security" + account.Locale = "en" + session := basemodel.SessionIdentifier{ + ClientID: "web", + DeviceID: "device-a", + SessionVersion: "session-version-a", + } + + token, err := Claims2Token(Account2ClaimsForSession(account, 1, session)) + if err != nil { + t.Fatalf("decode account claims: %v", err) + } + if token.SessionIdentifier != session { + t.Fatalf("session identity = %#v, want %#v", token.SessionIdentifier, session) + } + if token.Expiration.IsZero() { + t.Fatal("access token expiration is missing") + } +} diff --git a/backend/edge/shared/interface/ratelimit/config.go b/backend/edge/shared/interface/ratelimit/config.go new file mode 100644 index 0000000..f76045f --- /dev/null +++ b/backend/edge/shared/interface/ratelimit/config.go @@ -0,0 +1,38 @@ +package ratelimit + +import ( + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +const ( + MinWindow = time.Second + MaxWindow = 24 * time.Hour + MaxLimit = 10000 +) + +type WindowConfig struct { + Limit int `yaml:"limit"` + Window string `yaml:"window"` +} + +type Window struct { + Limit int + Window time.Duration +} + +func ResolveWindow(raw WindowConfig, field string) (Window, error) { + if raw.Limit < 1 || raw.Limit > MaxLimit { + return Window{}, merrors.InvalidArgument("rate limit must be between 1 and 10000", field+".limit") + } + window, err := time.ParseDuration(strings.TrimSpace(raw.Window)) + if err != nil { + return Window{}, merrors.InvalidArgument("rate limit window must be a valid duration", field+".window") + } + if window < MinWindow || window > MaxWindow { + return Window{}, merrors.InvalidArgument("rate limit window must be between 1s and 24h", field+".window") + } + return Window{Limit: raw.Limit, Window: window}, nil +} diff --git a/backend/edge/shared/interface/storage/config.go b/backend/edge/shared/interface/storage/config.go new file mode 100644 index 0000000..5024e6a --- /dev/null +++ b/backend/edge/shared/interface/storage/config.go @@ -0,0 +1,25 @@ +package storage + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type Driver string + +const ( + LocalFS Driver = "local_fs" + AWSS3 Driver = "aws_s3" +) + +type Config = model.DriverConfig[Driver] + +type LocalFSConfig struct { + RootPath string `mapstructure:"root_path" yaml:"root_path"` +} + +type AWSS3Config struct { + AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"` + SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"` + RegionEnv string `mapstructure:"region_env" yaml:"region_env"` + BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"` + EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"` + UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"` +} diff --git a/backend/edge/shared/internal/runtime/api.go b/backend/edge/shared/internal/runtime/api.go new file mode 100644 index 0000000..165c605 --- /dev/null +++ b/backend/edge/shared/internal/runtime/api.go @@ -0,0 +1,74 @@ +package runtime + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http" + amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type Microservices = []mservice.MicroService + +// APIImp represents the structure of the APIImp +type APIImp struct { + lifecycleCtx context.Context + logger mlogger.Logger + db db.Factory + domain domainprovider.DomainProvider + config *api.Config + profile api.ConfigProvider + runtime api.RuntimeConfig + components api.RuntimeComponents + services Microservices + health amr.Health + mw *httptransport.Middleware +} + +func (a *APIImp) LifecycleContext() context.Context { + return a.lifecycleCtx +} + +func (a *APIImp) Logger() mlogger.Logger { + return a.logger +} + +func (a *APIImp) Config() *api.Config { + return a.config +} + +func (a *APIImp) ProfileConfig() api.ConfigProvider { + return a.profile +} + +func (a *APIImp) DBFactory() db.Factory { + return a.db +} + +func (a *APIImp) DomainProvider() domainprovider.DomainProvider { + return a.domain +} + +func (a *APIImp) Register() api.Register { + return a.mw +} + +func (a *APIImp) Permissions() auth.Provider { + return a.db.Permissions() +} + +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" +} diff --git a/backend/edge/shared/internal/runtime/bootstrap.go b/backend/edge/shared/internal/runtime/bootstrap.go new file mode 100644 index 0000000..f4fb56e --- /dev/null +++ b/backend/edge/shared/internal/runtime/bootstrap.go @@ -0,0 +1,109 @@ +package runtime + +import ( + "context" + "os" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http" + amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func CreateAPI( + lifecycleCtx context.Context, + logger mlogger.Logger, + config *api.Config, + profileConfig api.ConfigProvider, + db db.Factory, + rootRouter *chi.Mux, + apiRouter *chi.Mux, + debug bool, + runtime api.RuntimeConfig, +) (mservice.MicroService, error) { + p := &APIImp{ + lifecycleCtx: lifecycleCtx, + logger: logger.Named("api"), + config: config, + profile: profileConfig, + db: db, + runtime: runtime, + } + + if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil { + return nil, err + } + if err := p.bootstrapConfiguredRuntime(config, db); err != nil { + return nil, err + } + + p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName())) + if err := p.installServices(); err != nil { + p.logger.Error("Failed to install a microservice", zap.Error(err)) + return nil, err + } + p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services))) + + return p, nil +} + +func (a *APIImp) installRuntimeBasics( + logger mlogger.Logger, + db db.Factory, + rootRouter *chi.Mux, + apiRouter *chi.Mux, + debug bool, +) error { + var err error + if a.domain, err = domainprovider.CreateDomainProvider( + a.logger, + a.config.Mw.DomainEnv, + a.config.Mw.APIProtocolEnv, + a.config.Mw.EndPointEnv, + ); err != nil { + a.logger.Error("Failed to initialize domain provider") + return err + } + a.logger.Info("Domain provider installed") + + apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv) + if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil { + a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint)) + return err + } + if a.mw, err = httptransport.CreateMiddleware( + logger, + db, + a.db.Permissions().Enforcer(), + apiRouter, + a.config.Mw, + a.config.AuthStorage, + a.runtime.EnableWebSocket, + debug, + ); 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 { + a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName())) + return err + } + a.components = components + a.logger.Info("Runtime profile bootstrap complete", + zap.String("runtime", a.runtime.RuntimeName()), + zap.Int("components", len(components))) + } + return nil +} diff --git a/backend/edge/shared/internal/runtime/lifecycle.go b/backend/edge/shared/internal/runtime/lifecycle.go new file mode 100644 index 0000000..9a331b2 --- /dev/null +++ b/backend/edge/shared/internal/runtime/lifecycle.go @@ -0,0 +1,31 @@ +package runtime + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "go.uber.org/zap" +) + +func (a *APIImp) Finish(ctx context.Context) error { + a.health.SetStatus(health.SSTerminating) + a.health.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 { + 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 +} diff --git a/backend/edge/shared/internal/runtime/services.go b/backend/edge/shared/internal/runtime/services.go new file mode 100644 index 0000000..bc3748c --- /dev/null +++ b/backend/edge/shared/internal/runtime/services.go @@ -0,0 +1,39 @@ +package runtime + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +func (a *APIImp) installMicroservice(srv mservice.MicroService) { + a.services = append(a.services, srv) + a.logger.Info("Microservice installed", zap.String("service", srv.Name())) +} + +func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error { + srv, err := srvf(a) + if err != nil { + a.logger.Error("Failed to install a microservice", zap.Error(err)) + return err + } + + a.installMicroservice(srv) + return nil +} + +func (a *APIImp) installServices() error { + for _, service := range a.runtime.Services { + if service.Factory == nil { + return merrors.InvalidArgument("microservice factory is required", "runtime.services") + } + if err := a.addMicroservice(service.Factory); err != nil { + return err + } + } + + a.health.SetStatus(health.SSRunning) + return nil +} diff --git a/backend/edge/shared/internal/server/config_test.go b/backend/edge/shared/internal/server/config_test.go new file mode 100644 index 0000000..88eab9d --- /dev/null +++ b/backend/edge/shared/internal/server/config_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "errors" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "os" + "testing" +) + +func TestIncompleteConfigReturnsProjectError(t *testing.T) { + for _, data := range []string{"api: {}", "api: {middleware: {}}\ndatabase: {driver: mongodb}", "api: {middleware: {}}\ndatabase: {driver: mongodb}\nhttp_server: {}"} { + instance := &Imp{runtime: api.RuntimeConfig{}} + if err := instance.decodeConfig([]byte(data)); !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected configuration error, got %v", err) + } + } +} +func TestExampleConfigMatchesCopiedRuntime(t *testing.T) { + data, err := os.ReadFile("../../../../config.example.yml") + if err != nil { + t.Fatal(err) + } + instance := &Imp{runtime: api.RuntimeConfig{}} + if err := instance.decodeConfig(data); err != nil { + t.Fatal(err) + } + if err := instance.config.API.SharedConfig().Mw.Token.Validate(); err != nil { + t.Fatal(err) + } + if err := instance.config.API.SharedConfig().Mw.CORS.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/edge/shared/internal/server/server.go b/backend/edge/shared/internal/server/server.go new file mode 100644 index 0000000..f62da2a --- /dev/null +++ b/backend/edge/shared/internal/server/server.go @@ -0,0 +1,177 @@ +package server + +import ( + "context" + "errors" + "net/http" + "os" + "time" + + ac "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type httpServerConf struct { + ListenAddress string `yaml:"listen_address"` + ReadHeaderTimeout int `yaml:"read_header_timeout"` + ShutdownTimeout int `yaml:"shutdown_timeout"` +} + +// Config represents the server configuration +type Config struct { + API ac.ConfigProvider + DB *db.Config `yaml:"database"` + HTTPServer *httpServerConf `yaml:"http_server"` +} + +// Instance represents an instance of the server +type Imp struct { + lifecycleCtx context.Context + lifecycleCancel context.CancelFunc + logger mlogger.Logger + api mservice.MicroService + config *Config + db db.Factory + httpServer *http.Server + runtime ac.RuntimeConfig + debug bool + file string +} + +// Shutdown stops the server +func (i *Imp) Shutdown() { + if i.lifecycleCancel != nil { + i.lifecycleCancel() + } + if i.httpServer == nil { + return + } + + // Shutdown HTTP server + ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second)) + i.logger.Info("Shutting HTTP server down...") + if err := i.httpServer.Shutdown(ctx); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err)) + cancel() + os.Exit(1) + } + } + cancel() +} + +func (i *Imp) Run() error { + err := i.httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + i.logger.Info("HTTP Server stopped") + + if err := i.api.Finish(context.Background()); err != nil { + i.logger.Warn("Error when finishing service", zap.Error(err)) + } + + i.db.CloseConnection() + + if err != nil { + return merrors.OpaqueWrap(merrors.ErrInternal, err, "HTTP server stopped unexpectedly") + } + return nil +} + +// Start starts the server +func (i *Imp) Start() error { + if i.lifecycleCancel != nil { + defer i.lifecycleCancel() + } + i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug)) + // Load configuration file + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file)) + return err + } + + if err = i.decodeConfig(data); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return err + } + + if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil { + i.logger.Error("Could not open database connection", zap.Error(err)) + return err + } + + rootRouter := chi.NewRouter() + apiRouter := chi.NewRouter() + if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil { + i.db.CloseConnection() + return err + } + rootRouter.Mount("/", apiRouter) + + // Startup the HTTP Server in a way that we can gracefully shut it down again + i.httpServer = &http.Server{ + Addr: i.config.HTTPServer.ListenAddress, + Handler: rootRouter, + ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second), + } + + return i.Run() +} + +func (i *Imp) decodeConfig(data []byte) error { + var raw struct { + API yaml.Node `yaml:"api"` + DB *db.Config `yaml:"database"` + HTTPServer *httpServerConf `yaml:"http_server"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return err + } + + apiConfig := i.runtime.NewConfig() + if apiConfig == nil { + return merrors.Internal("runtime config factory returned nil api config") + } + if raw.API.Kind == 0 { + return merrors.InvalidArgument("api configuration is required", "api") + } + if err := raw.API.Decode(apiConfig); err != nil { + return err + } + + if raw.DB == nil || raw.HTTPServer == nil || apiConfig.SharedConfig() == nil || apiConfig.SharedConfig().Mw == nil { + return merrors.InvalidArgument("database, http_server and api.middleware configuration are required") + } + if raw.HTTPServer.ListenAddress == "" || raw.HTTPServer.ReadHeaderTimeout <= 0 || raw.HTTPServer.ShutdownTimeout <= 0 { + return merrors.InvalidArgument("HTTP address and positive timeouts are required") + } + i.config = &Config{ + API: apiConfig, + DB: raw.DB, + HTTPServer: raw.HTTPServer, + } + return nil +} + +func Create(logger mlogger.Logger, file string, debug bool, runtime ac.RuntimeConfig) (*Imp, error) { + lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background()) + srv := &Imp{ + lifecycleCtx: lifecycleCtx, + lifecycleCancel: lifecycleCancel, + logger: logger, + debug: debug, + file: file, + runtime: runtime, + } + return srv, nil +} diff --git a/backend/edge/shared/internal/server/server_test.go b/backend/edge/shared/internal/server/server_test.go new file mode 100644 index 0000000..641780d --- /dev/null +++ b/backend/edge/shared/internal/server/server_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "go.uber.org/zap" +) + +func TestShutdownCancelsLifecycleContextBeforeStartupCompletes(t *testing.T) { + instance, err := Create(zap.NewNop(), "config.yml", false, api.RuntimeConfig{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + instance.Shutdown() + if !errors.Is(instance.lifecycleCtx.Err(), context.Canceled) { + t.Fatalf("lifecycle context error = %v, want context.Canceled", instance.lifecycleCtx.Err()) + } +} diff --git a/backend/edge/shared/internal/transport/http/middleware.go b/backend/edge/shared/internal/transport/http/middleware.go new file mode 100644 index 0000000..ea519cc --- /dev/null +++ b/backend/edge/shared/internal/transport/http/middleware.go @@ -0,0 +1,172 @@ +package httptransport + +import ( + "net/http" + "os" + "path" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage" + edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router" + mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics" + ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" + cm "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/metrics" + "go.uber.org/zap" + "moul.io/chizap" +) + +type Middleware struct { + logger mlogger.Logger + router *chi.Mux + apiEndpoint string + metrics mr.Metrics + wshandler ws.Router + 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) WSHandler(messageType string, handler wsh.HandlerFunc) { + if mw.wshandler == nil { + mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType)) + return + } + mw.wshandler.InstallHandler(messageType, handler) +} + +func (mw *Middleware) Finish() { + mw.metrics.Finish() +} + +func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) { + mw.logger.Debug("Installing middleware stack...") + // Collect metrics for application traffic only. Scrapes and health probes are + // operational traffic and would otherwise distort request and in-flight SLIs. + mw.router.Use(metrics.Collector(metrics.CollectorOpts{ + Host: false, // avoid high-cardinality "host" label + Proto: true, // include HTTP protocol label + Skip: func(r *http.Request) bool { + return isObservabilityRequest(mw.apiEndpoint, r.URL.Path) + }, + })) + mw.router.Use(cm.RequestID) + if debug { + mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{ + WithReferer: true, + WithUserAgent: true, + })) + } + mw.router.Use(cors.Handler(cors.Options{ + AllowedOrigins: config.CORS.AllowedOrigins, + AllowedMethods: config.CORS.AllowedMethods, + AllowedHeaders: config.CORS.AllowedHeaders, + ExposedHeaders: config.CORS.ExposedHeaders, + AllowCredentials: config.CORS.AllowCredentials, + MaxAge: config.CORS.MaxAge, + OptionsPassthrough: false, + Debug: debug, + })) + mw.router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := browser.ValidateCSRF(r, config.CORS.AllowedOrigins); err != nil { + mw.logger.Info("Rejected browser request with invalid CSRF context", + zap.String("method", r.Method), zap.String("path", r.URL.Path)) + response.Forbidden(mw.logger, mservice.Accounts, "csrf_rejected", "browser request failed CSRF validation").ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r) + }) + }) + mw.router.Use(cm.Recoverer) + mw.router.Handle("/metrics", metrics.Handler()) + mw.logger.Info("Middleware stack installation complete") +} + +func isObservabilityRequest(apiEndpoint, requestPath string) bool { + healthPath := path.Join("/", apiEndpoint, "health") + metricsPath := path.Join("/", apiEndpoint, "metrics") + + return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics" +} + +func CreateMiddleware( + logger mlogger.Logger, + db db.Factory, + enforcer auth.Enforcer, + apiRouter *chi.Mux, + config *middleware.Config, + authStorage *edgeapi.AuthStorageConfig, + installWebSocket bool, + debug bool, +) (*Middleware, error) { + if config == nil { + return nil, merrors.InvalidArgument("middleware configuration is required", "middleware") + } + if err := config.Token.Validate(); err != nil { + return nil, err + } + if err := config.CORS.Validate(); err != nil { + return nil, err + } + loginRateLimits, err := config.ResolveLoginRateLimits() + if err != nil { + logger.Error("Invalid login rate limit configuration", zap.Error(err)) + return nil, err + } + p := &Middleware{ + logger: logger.Named("middleware"), + router: apiRouter, + apiEndpoint: os.Getenv(config.EndPointEnv), + } + p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint)) + p.installMiddleware(config, debug) + if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil { + p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint)) + return nil, err + } + + stores, err := authstorage.OpenStores(p.logger, db, authStorage) + if err != nil { + return nil, err + } + + p.epdispatcher = httprouter.NewDispatcher( + p.logger, + p.router, + stores.Accounts, + stores.Verification, + stores.RefreshTokens, + stores.LoginAttempts, + enforcer, + config, + loginRateLimits, + ) + if installWebSocket { + p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint) + } + return p, nil +} diff --git a/backend/edge/shared/internal/transport/http/middleware_test.go b/backend/edge/shared/internal/transport/http/middleware_test.go new file mode 100644 index 0000000..9b5a6db --- /dev/null +++ b/backend/edge/shared/internal/transport/http/middleware_test.go @@ -0,0 +1,155 @@ +package httptransport + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "github.com/go-chi/chi/v5" + chimetrics "github.com/go-chi/metrics" + "github.com/prometheus/client_golang/prometheus" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + "moul.io/chizap" +) + +func TestHTTPCollectorDoesNotGatherHealthOrMetricsRequests(t *testing.T) { + router := chi.NewRouter() + router.Use(chimetrics.Collector(chimetrics.CollectorOpts{ + Proto: true, + Skip: func(r *http.Request) bool { + return isObservabilityRequest("/api/v1", r.URL.Path) + }, + })) + router.Get("/api/v1/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + router.Handle("/metrics", chimetrics.Handler()) + router.Handle("/api/v1/metrics", chimetrics.Handler()) + router.Get("/api/v1/accounts", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + + beforeHealth := gatheredHTTPRequestCount(t, "GET /api/v1/health") + beforeMetrics := gatheredHTTPRequestCount(t, "GET /metrics") + beforeAPIMetrics := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") + beforeApplication := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") + + for _, requestPath := range []string{"/api/v1/health", "/metrics", "/api/v1/metrics", "/api/v1/accounts"} { + request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, requestPath, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("GET %s returned %d", requestPath, response.Code) + } + } + + if got := gatheredHTTPRequestCount(t, "GET /api/v1/health") - beforeHealth; got != 0 { + t.Fatalf("health probe changed http_requests_total by %v", got) + } + if got := gatheredHTTPRequestCount(t, "GET /metrics") - beforeMetrics; got != 0 { + t.Fatalf("metrics scrape changed http_requests_total by %v", got) + } + if got := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") - beforeAPIMetrics; got != 0 { + t.Fatalf("API metrics scrape changed http_requests_total by %v", got) + } + if got := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") - beforeApplication; got != 1 { + t.Fatalf("application request changed http_requests_total by %v, want 1", got) + } +} + +func gatheredHTTPRequestCount(t *testing.T, endpoint string) float64 { + t.Helper() + + families, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("gather metrics: %v", err) + } + for _, family := range families { + if family.GetName() != "http_requests_total" { + continue + } + + var total float64 + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "endpoint" && label.GetValue() == endpoint { + total += metric.GetCounter().GetValue() + } + } + } + return total + } + + return 0 +} + +func TestObservabilityRequestsAreExcludedFromHTTPMetrics(t *testing.T) { + tests := []struct { + name string + apiEndpoint string + requestPath string + want bool + }{ + {name: "health under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/health", want: true}, + {name: "metrics at root", apiEndpoint: "/api/v1", requestPath: "/metrics", want: true}, + {name: "metrics under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/metrics", want: true}, + {name: "trailing endpoint slash", apiEndpoint: "/api/v1/", requestPath: "/api/v1/health", want: true}, + {name: "application endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/accounts", want: false}, + {name: "health suffix is not a probe", apiEndpoint: "/api/v1", requestPath: "/api/v1/health/history", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isObservabilityRequest(tt.apiEndpoint, tt.requestPath); got != tt.want { + t.Fatalf("isObservabilityRequest(%q, %q) = %t, want %t", tt.apiEndpoint, tt.requestPath, got, tt.want) + } + }) + } +} + +func TestHealthMountedOutsideTraceMiddleware(t *testing.T) { + core, observed := observer.New(zapcore.InfoLevel) + logger := zap.New(core) + + rootRouter := chi.NewRouter() + apiRouter := chi.NewRouter() + + apiRouter.Use(chizap.New(logger.Named("middleware").Named("http_trace"), &chizap.Opts{})) + apiRouter.Get("/api/v1/accounts", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + healthRouter, err := amr.NewHealthRouter(logger.Named("middleware"), rootRouter, "/api/v1") + if err != nil { + t.Fatalf("failed to create health router: %v", err) + } + healthRouter.SetStatus(health.SSRunning) + + rootRouter.Mount("/", apiRouter) + + healthReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/health", nil) + healthRes := httptest.NewRecorder() + rootRouter.ServeHTTP(healthRes, healthReq) + if healthRes.Code != http.StatusOK { + t.Fatalf("expected health status %d, got %d", http.StatusOK, healthRes.Code) + } + if got := observed.FilterMessage("Served").Len(); got != 0 { + t.Fatalf("expected no trace logs for /health, got %d", got) + } + + apiReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/accounts", nil) + apiRes := httptest.NewRecorder() + rootRouter.ServeHTTP(apiRes, apiReq) + if apiRes.Code != http.StatusOK { + t.Fatalf("expected api status %d, got %d", http.StatusOK, apiRes.Code) + } + + entries := observed.FilterMessage("Served").All() + if len(entries) != 1 { + t.Fatalf("expected one trace log for non-health request, got %d", len(entries)) + } + if got := entries[0].ContextMap()["path"]; got != "/api/v1/accounts" { + t.Fatalf("expected logged path %q, got %#v", "/api/v1/accounts", got) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/authorized/handler.go b/backend/edge/shared/internal/transport/http/router/authorized/handler.go new file mode 100644 index 0000000..fdb5fb7 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/authorized/handler.go @@ -0,0 +1,154 @@ +package authorized + +import ( + "errors" + "net/http" + "strings" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" + "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "github.com/go-chi/jwtauth/v5" + "go.uber.org/zap" +) + +type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc + +func (ar *AuthorizedRouter) validateClientPolicy(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { + clientID := strings.TrimSpace(t.ClientID) + if clientID == "" { + // Legacy tokens without client_id remain valid until expiration. + return nil + } + client, err := ar.rtdb.GetClient(r.Context(), clientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + ar.logger.Debug("Client not found for access token", zap.String("client_id", clientID)) + return response.Unauthorized(ar.logger, ar.service, "client not found") + } + if err != nil { + ar.logger.Warn("Failed to resolve client for access token", zap.Error(err), zap.String("client_id", clientID)) + return response.Internal(ar.logger, ar.service, err) + } + if client.IsRevoked { + return response.Unauthorized(ar.logger, ar.service, "client has been revoked") + } + if client.AccountRef != nil && *client.AccountRef != t.AccountRef { + return response.Unauthorized(ar.logger, ar.service, "client account mismatch") + } + + clientIP := ipguard.ClientIP(r) + allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs) + if err != nil { + ar.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID)) + return response.Forbidden(ar.logger, ar.service, "client_ip_policy_invalid", "client ip policy is invalid") + } + if !allowed { + rawIP := "" + if clientIP != nil { + rawIP = clientIP.String() + } + ar.logger.Info("Client IP policy denied authorized request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP)) + return response.Forbidden(ar.logger, ar.service, "ip_not_allowed", "request ip is not allowed for this client") + } + + return nil +} + +func (ar *AuthorizedRouter) validateSession(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { + if t.Pending { + return nil + } + if strings.TrimSpace(t.ClientID) == "" || strings.TrimSpace(t.DeviceID) == "" || strings.TrimSpace(t.SessionVersion) == "" { + sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.MissingIdentity) + ar.logger.Debug("Access token has no session identifier", mzap.AccRef(t.AccountRef)) + return response.Unauthorized(ar.logger, ar.service, "session identifier is required") + } + if _, err := ar.rtdb.GetSession(r.Context(), t.AccountRef, &t.SessionIdentifier); err != nil { + if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) { + sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.AlreadyInactive) + ar.logger.Debug("Access token session is not active", mzap.AccRef(t.AccountRef), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID)) + return response.Unauthorized(ar.logger, ar.service, "session is not active") + } + sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.StorageError) + ar.logger.Warn("Failed to validate access token session", zap.Error(err), mzap.AccRef(t.AccountRef), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID)) + return response.Internal(ar.logger, ar.service, err) + } + sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.Active) + return nil +} + +func (ar *AuthorizedRouter) tokenHandlerWithSessionCheck(service mservice.Type, endpoint string, method api.HTTPMethod, checkSession bool, handler tokenHandlerFunc) { + hndlr := func(r *http.Request) http.HandlerFunc { + _, claims, err := jwtauth.FromContext(r.Context()) + if err != nil { + ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path)) + return response.Unauthorized(ar.logger, ar.service, "credentials required") + } + t, err := emodel.Claims2Token(claims) + if err != nil { + ar.logger.Debug("Failed to decode account token", zap.Error(err)) + return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials") + } + if h := ar.validateClientPolicy(r, t); h != nil { + return h + } + if checkSession { + if h := ar.validateSession(r, t); h != nil { + return h + } + } + return handler(r, t) + } + ar.imp.InstallHandler(service, endpoint, method, hndlr) +} + +func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) { + ar.tokenHandlerWithSessionCheck(service, endpoint, method, true, handler) +} + +func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) { + hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { + if t.Pending { + return response.Unauthorized(ar.logger, ar.service, "additional verification required") + } + var a model.Account + if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil { + if errors.Is(err, merrors.ErrNoData) { + ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef)) + return response.NotFound(ar.logger, ar.service, err.Error()) + } + return response.Internal(ar.logger, ar.service, err) + } + accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier) + if err != nil { + ar.logger.Warn("Failed to generate access token", zap.Error(err)) + return response.Internal(ar.logger, ar.service, err) + } + return handler(r, &a, &accessToken) + } + ar.tokenHandler(service, endpoint, method, hndlr) +} + +func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) { + hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { + var a model.Account + if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil { + if errors.Is(err, merrors.ErrNoData) { + ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef)) + return response.NotFound(ar.logger, ar.service, err.Error()) + } + return response.Internal(ar.logger, ar.service, err) + } + return handler(r, &a, t) + } + ar.tokenHandler(service, endpoint, method, hndlr) +} diff --git a/backend/edge/shared/internal/transport/http/router/authorized/router.go b/backend/edge/shared/internal/transport/http/router/authorized/router.go new file mode 100644 index 0000000..1abc55a --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/authorized/router.go @@ -0,0 +1,61 @@ +package authorized + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" + "go.uber.org/zap" +) + +type AuthorizedRouter struct { + logger mlogger.Logger + db account.DB + rtdb refreshtokens.DB + imp *re.HttpEndpointRouter + service mservice.Type +} + +func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { + ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey) + router.Use(jwtauth.Verifier(ja)) + router.Use(jwtauth.Authenticator(ja)) + l := logger.Named("authorized") + ar := AuthorizedRouter{ + logger: l, + db: db, + rtdb: rtdb, + imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature), + service: mservice.Accounts, + } + ar.tokenHandlerWithSessionCheck(mservice.Accounts, "/logout/all", api.Post, false, ar.logoutAll) + + return &ar +} + +func (ar *AuthorizedRouter) logoutAll(r *http.Request, token *emodel.AccountToken) http.HandlerFunc { + if token.ClientID == "" || token.DeviceID == "" || token.SessionVersion == "" { + sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.MissingIdentity) + return response.Unauthorized(ar.logger, ar.service, "session identifier is required") + } + if err := ar.rtdb.RevokeAll(r.Context(), token.AccountRef, ""); err != nil { + sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.StorageError) + ar.logger.Warn("Failed to revoke all account sessions", zap.Error(err), mzap.AccRef(token.AccountRef)) + return response.Internal(ar.logger, ar.service, err) + } + sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.Succeeded) + ar.logger.Info("All account sessions revoked", mzap.AccRef(token.AccountRef)) + return response.Success(ar.logger) +} diff --git a/backend/edge/shared/internal/transport/http/router/authorized/session_test.go b/backend/edge/shared/internal/transport/http/router/authorized/session_test.go new file mode 100644 index 0000000..8463636 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/authorized/session_test.go @@ -0,0 +1,113 @@ +package authorized + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type sessionDB struct { + refreshtokens.DB + token *model.RefreshToken + err error + revokeAllID string +} + +func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *model.SessionIdentifier) (*model.RefreshToken, error) { + return db.token, db.err +} + +func (db *sessionDB) RevokeAll(_ context.Context, _ bson.ObjectID, deviceID string) error { + db.revokeAllID = deviceID + return db.err +} + +func TestValidateSessionFailsClosed(t *testing.T) { + accountRef := bson.NewObjectID() + token := &emodel.AccountToken{ + AccountRef: accountRef, + SessionIdentifier: model.SessionIdentifier{ + ClientID: "web", + DeviceID: "device-a", + SessionVersion: "version-a", + }, + } + request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/accounts", nil) + + tests := []struct { + name string + db *sessionDB + mutate func(*emodel.AccountToken) + wantStatus int + }{ + {name: "active", db: &sessionDB{token: &model.RefreshToken{}}, wantStatus: 0}, + {name: "revoked", db: &sessionDB{err: merrors.ErrNoData}, wantStatus: http.StatusUnauthorized}, + {name: "storage unavailable", db: &sessionDB{err: merrors.Internal("database unavailable")}, wantStatus: http.StatusInternalServerError}, + { + name: "missing version", + db: &sessionDB{}, + mutate: func(token *emodel.AccountToken) { + token.SessionVersion = "" + }, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + candidate := *token + if tt.mutate != nil { + tt.mutate(&candidate) + } + router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: tt.db, service: mservice.Accounts} + handler := router.validateSession(request, &candidate) + if tt.wantStatus == 0 { + if handler != nil { + t.Fatal("active session was rejected") + } + return + } + if handler == nil { + t.Fatal("invalid session was accepted") + } + response := httptest.NewRecorder() + handler(response, request) + if response.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus) + } + }) + } +} + +func TestLogoutAllRevokesEveryDevice(t *testing.T) { + db := &sessionDB{} + router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + token := &emodel.AccountToken{ + AccountRef: bson.NewObjectID(), + SessionIdentifier: model.SessionIdentifier{ + ClientID: "web", + DeviceID: "device-a", + SessionVersion: "version-a", + }, + } + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout/all", nil) + response := httptest.NewRecorder() + + router.logoutAll(request, token)(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) + } + if db.revokeAllID != "" { + t.Fatalf("device exclusion = %q, want all devices", db.revokeAllID) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/dispatcher.go b/backend/edge/shared/internal/transport/http/router/dispatcher.go new file mode 100644 index 0000000..6c6c7cd --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/dispatcher.go @@ -0,0 +1,66 @@ +package router + +import ( + "os" + + "github.com/go-chi/chi/v5" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + rauthorized "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/authorized" + rpublic "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/public" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type Dispatcher struct { + logger mlogger.Logger + public APIRouter + protected ProtectedAPIRouter +} + +func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) { + d.public.InstallHandler(service, endpoint, method, handler) +} + +func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) { + d.protected.AccountHandler(service, endpoint, method, handler) +} + +func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) { + d.protected.PendingAccountHandler(service, endpoint, method, handler) +} + +func NewDispatcher( + logger mlogger.Logger, + router chi.Router, + db account.DB, + vdb verification.DB, + rtdb refreshtokens.DB, + attempts ratelimits.Store, + enforcer auth.Enforcer, + config *middleware.Config, + loginRateLimits middleware.LoginRateLimits, +) *Dispatcher { + d := &Dispatcher{ + logger: logger.Named("api_dispatcher"), + } + + d.logger.Debug("Installing endpoints middleware...") + endpoint := os.Getenv(config.EndPointEnv) + signature := middleware.SignatureConf(config) + router.Group(func(r chi.Router) { + d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits) + }) + router.Group(func(r chi.Router) { + d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature) + }) + + return d +} diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go b/backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go new file mode 100644 index 0000000..5e73352 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go @@ -0,0 +1,36 @@ +package endpoint + +import ( + "context" + "net/http" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/go-chi/chi/v5" +) + +type ( + RegistratorT = func(chi.Router, string, http.HandlerFunc) + ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc +) + +type HttpEndpointRouter struct { + logger mlogger.Logger + apiEndpoint string + router chi.Router + config middleware.TokenConfig + signature middleware.Signature +} + +func NewHttpEndpointRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *HttpEndpointRouter { + er := HttpEndpointRouter{ + logger: logger.Named("http"), + apiEndpoint: apiEndpoint, + router: router, + signature: *signature, + config: *config, + } + return &er +} diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/install.go b/backend/edge/shared/internal/transport/http/router/endpoint/install.go new file mode 100644 index 0000000..863e3f5 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/endpoint/install.go @@ -0,0 +1,55 @@ +package endpoint + +import ( + "net/http" + "path" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT { + switch method { + case api.Get: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Get(p, h) } + case api.Post: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Post(p, h) } + case api.Put: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Put(p, h) } + case api.Delete: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Delete(p, h) } + case api.Patch: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Patch(p, h) } + case api.Options: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Options(p, h) } + case api.Head: + return func(r chi.Router, p string, h http.HandlerFunc) { r.Head(p, h) } + default: + return nil + } +} + +func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string { + return path.Join(er.apiEndpoint, service, handler) +} + +func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) { + ep := er.endpoint(service, endpoint) + hm := er.chooseMethod(method) + if hm == nil { + er.logger.Warn("Handler install skipped because method is unsupported", + zap.String("endpoint", ep), + zap.String("method", api.HTTPMethod2String(method)), + ) + return + } + hndlr := func(w http.ResponseWriter, r *http.Request) { + res := handler(r) + res(w, r) + } + hm(er.router, ep, hndlr) + er.logger.Info("Handler installed", zap.String("endpoint", ep), zap.String("method", api.HTTPMethod2String(method))) +} diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/token.go b/backend/edge/shared/internal/transport/http/router/endpoint/token.go new file mode 100644 index 0000000..2c6d14b --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/endpoint/token.go @@ -0,0 +1,30 @@ +package endpoint + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/go-chi/jwtauth/v5" +) + +func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *model.Account, session model.SessionIdentifier) (sresponse.TokenData, error) { + ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey) + _, res, err := ja.Encode(emodel.Account2ClaimsForSession(user, er.config.Expiration.Account, session)) + token := sresponse.TokenData{ + Token: res, + Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour), + } + return token, err +} + +func (er *HttpEndpointRouter) CreatePendingToken(user *model.Account, ttlMinutes int) (sresponse.TokenData, error) { + ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey) + _, res, err := ja.Encode(emodel.PendingAccount2Claims(user, ttlMinutes)) + token := sresponse.TokenData{ + Token: res, + Expiration: time.Now().Add(time.Duration(ttlMinutes) * time.Minute), + } + return token, err +} diff --git a/backend/edge/shared/internal/transport/http/router/metrics/handler.go b/backend/edge/shared/internal/transport/http/router/metrics/handler.go new file mode 100644 index 0000000..d204243 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/metrics/handler.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" + "github.com/go-chi/metrics" + "go.uber.org/zap" +) + +type metricsRouter struct { + logger mlogger.Logger + handler http.Handler +} + +func (mr *metricsRouter) Finish() { + mr.logger.Debug("Stopped") +} + +func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) { + mr.logger.Debug("Serving metrics request...") + mr.handler.ServeHTTP(w, r) +} + +func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter { + mr := metricsRouter{ + logger: logger.Named("metrics"), + handler: metrics.Handler(), + } + + logger.Debug("Installing Prometheus middleware...") + router.Group(func(r chi.Router) { + ep := endpoint + "/metrics" + r.Get(ep, mr.handle) + logger.Info("Prometheus handler installed", zap.String("endpoint", ep)) + }) + + return &mr +} diff --git a/backend/edge/shared/internal/transport/http/router/metrics/router.go b/backend/edge/shared/internal/transport/http/router/metrics/router.go new file mode 100644 index 0000000..254071e --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/metrics/router.go @@ -0,0 +1,14 @@ +package metrics + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" +) + +type Metrics interface { + Finish() +} + +func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) { + return newMetricsRouter(logger, router, endpoint), nil +} diff --git a/backend/edge/shared/internal/transport/http/router/public/login.go b/backend/edge/shared/internal/transport/http/router/public/login.go new file mode 100644 index 0000000..79636f5 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/login.go @@ -0,0 +1,249 @@ +package public + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "strings" + + clientauth "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/client" + "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask" + "go.uber.org/zap" +) + +const pendingLoginTTLMinutes = 10 +const apiLoginGrantType = "password" +const apiLoginClientAuthMethod = "client_secret_post" + +const dummyPasswordHash = "$2y$10$pmvNFN5zyooml6Oqh.cGFenBF/indHDE.dyLwjNJW1.Tk2Suvdvve" + +func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, bool, http.HandlerFunc) { + // Get the account database entry + trimmedLogin := strings.TrimSpace(req.Login) + account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin)) + if errors.Is(err, merrors.ErrNoData) || (err == nil && account == nil) { + (&model.Account{Password: dummyPasswordHash}).MatchPassword(req.Password) + pr.logger.Debug("Login rejected: invalid credentials") + return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") + } + if err != nil { + pr.logger.Warn("Failed to query user during login", zap.Error(err)) + return nil, false, response.Internal(pr.logger, pr.service, err) + } + + if !account.MatchPassword(req.Password) { + pr.logger.Debug("Login rejected: invalid credentials") + return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") + } + + if !account.IsActive() { + return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required") + } + + return account, false, nil +} + +func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc { + pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes) + if err != nil { + pr.logger.Warn("Failed to generate pending token", zap.Error(err)) + return response.Internal(pr.logger, pr.service, err) + } + + return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login)) +} + +func hasGrantType(grants []string, target string) bool { + for _, grant := range grants { + if strings.EqualFold(strings.TrimSpace(grant), target) { + return true + } + } + return false +} + +func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc { + if client == nil { + pr.logger.Debug("Client not found, rejecting authorization", zap.String("client_id", clientID)) + return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) + } + clientIP := ipguard.ClientIP(r) + allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs) + if err != nil { + pr.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID)) + return response.Forbidden(pr.logger, pr.service, "client_ip_policy_invalid", "client ip policy is invalid") + } + if !allowed { + rawIP := "" + if clientIP != nil { + rawIP = clientIP.String() + } + pr.logger.Info("Client IP policy denied request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP)) + return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") + } + return nil +} + +func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*model.Client, http.HandlerFunc) { + client, err := clientauth.Resolve(ctx, pr.rtdb, clientID) + if errors.Is(err, clientauth.ErrUnknownClientID) { + pr.logger.Debug("Login rejected: unknown client id", zap.String("client_id", clientID)) + return nil, response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) + } + if err != nil { + pr.logger.Warn("Login rejected: failed to load client", zap.Error(err), zap.String("client_id", clientID)) + return nil, response.Internal(pr.logger, pr.service, err) + } + return client, nil +} + +func (pr *PublicRouter) validateClientAccountBinding(client *model.Client, account *model.Account) http.HandlerFunc { + if client == nil || client.AccountRef == nil { + return nil + } + accountRef := account.GetID() + if accountRef == nil || *client.AccountRef != *accountRef { + return response.Forbidden(pr.logger, pr.service, "client_account_mismatch", "client is bound to another account") + } + return nil +} + +func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, client *model.Client) http.HandlerFunc { + if client == nil { + return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) + } + if client.IsRevoked { + return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked") + } + if h := pr.validateClientIPPolicy(r, clientID, client); h != nil { + return h + } + return nil +} + +func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { + client, h := pr.loadClient(ctx, req.ClientID) + if h != nil { + return h + } + if h := pr.validateClientState(r, req.ClientID, client); h != nil { + return h + } + if !hasGrantType(client.GrantTypes, apiLoginGrantType) { + return response.Forbidden(pr.logger, pr.service, "client_grant_not_allowed", "client does not allow password grant") + } + method := strings.ToLower(strings.TrimSpace(client.TokenEndpointAuthMethod)) + if method == "" { + method = apiLoginClientAuthMethod + } + if method != apiLoginClientAuthMethod { + return response.Forbidden(pr.logger, pr.service, "client_auth_method_unsupported", "unsupported client auth method") + } + + storedSecret := strings.TrimSpace(client.ClientSecret) + if storedSecret == "" { + return response.Forbidden(pr.logger, pr.service, "client_secret_missing", "client secret is not configured") + } + if subtle.ConstantTimeCompare([]byte(storedSecret), []byte(req.ClientSecret)) != 1 { + pr.logger.Debug("API login rejected: invalid client secret", zap.String("client_id", req.ClientID)) + return response.Unauthorized(pr.logger, pr.service, "invalid client secret") + } + if h := pr.validateClientAccountBinding(client, account); h != nil { + return h + } + + return nil +} + +func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, clientID string) (*model.Client, http.HandlerFunc) { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return nil, nil + } + client, h := pr.loadClient(ctx, clientID) + if h != nil { + return nil, h + } + if h := pr.validateClientState(r, clientID, client); h != nil { + return nil, h + } + return client, nil +} + +func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { + if req.ClientID == "" || req.DeviceID == "" { + return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required") + } + return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account) +} + +func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.HandlerFunc) { + var req srequest.Login + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Info("Failed to decode login request", zap.Error(err)) + return nil, response.BadPayload(logger, mservice.Accounts, err) + } + req.Login = strings.TrimSpace(req.Login) + req.Password = strings.TrimSpace(req.Password) + req.ClientID = strings.TrimSpace(req.ClientID) + req.DeviceID = strings.TrimSpace(req.DeviceID) + req.ClientSecret = strings.TrimSpace(req.ClientSecret) + + if req.Login == "" { + return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name") + } + if req.Password == "" { + return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password") + } + return &req, nil +} + +func (a *PublicRouter) login(r *http.Request) http.HandlerFunc { + req, h := decodeLogin(r, a.logger) + if h != nil { + return h + } + client, h := a.resolveLoginClient(r.Context(), r, req.ClientID) + if h != nil { + return h + } + account, h := a.authenticateRateLimited(r, req) + if h != nil { + return h + } + if h = a.validateClientAccountBinding(client, account); h != nil { + return h + } + return a.respondPendingLogin(account) +} + +func (a *PublicRouter) apiLogin(r *http.Request) http.HandlerFunc { + req, h := decodeLogin(r, a.logger) + if h != nil { + return h + } + if req.ClientID == "" { + return response.BadRequest(a.logger, mservice.Accounts, "client_id_missing", "clientId is required") + } + if req.ClientSecret == "" { + return response.BadRequest(a.logger, mservice.Accounts, "client_secret_missing", "clientSecret is required") + } + account, h := a.authenticateRateLimited(r, req) + if h != nil { + return h + } + if h = a.validateAPIClient(r.Context(), r, req, account); h != nil { + return h + } + return a.respondAPILogin(r.Context(), r, req, account) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go b/backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go new file mode 100644 index 0000000..483031b --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go @@ -0,0 +1,179 @@ +package public + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "go.uber.org/zap" +) + +const ( + loginScopeAccount = "account" + loginScopeIP = "ip" +) + +var loginRateLimitDecisions = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "sendico_login_rate_limit_decisions_total", + Help: "Login rate-limit decisions grouped by scope kind and outcome.", +}, []string{"scope_kind", "outcome"}) + +type loginRateScope struct { + kind string + hash string + limit int + windowStart time.Time + expiresAt time.Time +} + +func (pr *PublicRouter) authenticateRateLimited( + r *http.Request, + req *srequest.Login, +) (*model.Account, http.HandlerFunc) { + if pr.attempts == nil { + account, _, h := pr.authenticateAccount(r.Context(), req) + return account, h + } + + clock := pr.now + if clock == nil { + clock = time.Now + } + now := clock().UTC() + scopes := pr.loginRateScopes(r, req.Login, now) + if h := pr.checkLoginRateLimits(r.Context(), scopes, now); h != nil { + return nil, h + } + + account, credentialFailure, h := pr.authenticateAccount(r.Context(), req) + if !credentialFailure { + for _, scope := range scopes { + loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc() + } + return account, h + } + + limited, retryAfter, failureHandler := pr.recordLoginFailure(r.Context(), scopes, now) + if failureHandler != nil { + return nil, failureHandler + } + if limited { + return nil, pr.loginRateLimited(retryAfter) + } + return nil, h +} + +func (pr *PublicRouter) loginRateScopes(r *http.Request, login string, now time.Time) []loginRateScope { + normalizedLogin := strings.ToLower(strings.TrimSpace(login)) + clientIP := "unresolved" + if resolved := ipguard.ClientIP(r); resolved != nil { + clientIP = resolved.String() + } + return []loginRateScope{ + newLoginRateScope(loginScopeAccount, normalizedLogin, pr.limits.PerAccount, now), + newLoginRateScope(loginScopeIP, clientIP, pr.limits.PerIP, now), + } +} + +func newLoginRateScope(kind, value string, limit middleware.LoginRateLimitWindow, now time.Time) loginRateScope { + windowStart := now.Truncate(limit.Window) + return loginRateScope{ + kind: kind, + hash: ratelimits.HashScope(kind, value), + limit: limit.Limit, + windowStart: windowStart, + expiresAt: windowStart.Add(limit.Window), + } +} + +func (pr *PublicRouter) checkLoginRateLimits( + ctx context.Context, + scopes []loginRateScope, + now time.Time, +) http.HandlerFunc { + for _, scope := range scopes { + count, err := pr.attempts.Current(ctx, scope.kind, scope.hash, scope.windowStart) + if err != nil { + return pr.loginRateLimitStorageFailure(scope.kind, err) + } + if count >= scope.limit { + loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc() + retryAfter := retryAfterDuration(now, scope.expiresAt) + pr.logger.Info("Denied login request at rate limit", + zap.String("scope_kind", scope.kind), + zap.Int64("retry_after_seconds", retryAfterSeconds(retryAfter))) + return pr.loginRateLimited(retryAfter) + } + } + return nil +} + +func (pr *PublicRouter) recordLoginFailure( + ctx context.Context, + scopes []loginRateScope, + now time.Time, +) (bool, time.Duration, http.HandlerFunc) { + limited := false + var retryAfter time.Duration + for _, scope := range scopes { + count, err := pr.attempts.Increment(ctx, scope.kind, scope.hash, scope.windowStart, scope.expiresAt) + if err != nil { + return false, 0, pr.loginRateLimitStorageFailure(scope.kind, err) + } + if count >= scope.limit { + limited = true + loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc() + scopeRetry := retryAfterDuration(now, scope.expiresAt) + if retryAfter == 0 || scopeRetry < retryAfter { + retryAfter = scopeRetry + } + pr.logger.Info("Login failure reached rate limit", + zap.String("scope_kind", scope.kind), + zap.Int64("retry_after_seconds", retryAfterSeconds(scopeRetry))) + continue + } + loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc() + } + return limited, retryAfter, nil +} + +func (pr *PublicRouter) loginRateLimitStorageFailure(scopeKind string, cause error) http.HandlerFunc { + loginRateLimitDecisions.WithLabelValues(scopeKind, "error").Inc() + pr.logger.Warn("Login protection storage failed", zap.Error(cause), zap.String("scope_kind", scopeKind)) + err := merrors.OpaqueWrap(merrors.ErrServiceUnavailable, cause, "login protection is temporarily unavailable") + return response.Auto(pr.logger, pr.service, err) +} + +func (pr *PublicRouter) loginRateLimited(retryAfter time.Duration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", strconv.FormatInt(retryAfterSeconds(retryAfter), 10)) + response.TooManyRequests(pr.logger, pr.service, "too many login attempts; try again later")(w, r) + } +} + +func retryAfterDuration(now, expiresAt time.Time) time.Duration { + retryAfter := expiresAt.Sub(now) + if retryAfter <= 0 { + return time.Second + } + return retryAfter +} + +func retryAfterSeconds(retryAfter time.Duration) int64 { + seconds := int64((retryAfter + time.Second - 1) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} diff --git a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go b/backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go new file mode 100644 index 0000000..3412f0c --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go @@ -0,0 +1,316 @@ +package public + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +type fakeLoginAttemptStore struct { + mu sync.Mutex + counts map[string]int + currentErr error + incrementErr error +} + +func newFakeLoginAttemptStore() *fakeLoginAttemptStore { + return &fakeLoginAttemptStore{counts: make(map[string]int)} +} + +func (s *fakeLoginAttemptStore) Current(_ context.Context, kind, hash string, start time.Time) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.currentErr != nil { + return 0, s.currentErr + } + return s.counts[attemptKey(kind, hash, start)], nil +} + +func (s *fakeLoginAttemptStore) Increment( + _ context.Context, + kind, hash string, + start, _ time.Time, +) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.incrementErr != nil { + return 0, s.incrementErr + } + key := attemptKey(kind, hash, start) + s.counts[key]++ + return s.counts[key], nil +} + +func attemptKey(kind, hash string, start time.Time) string { + return kind + ":" + hash + ":" + start.UTC().Format(time.RFC3339Nano) +} + +func testLoginLimits(accountLimit, ipLimit int, window time.Duration) middleware.LoginRateLimits { + return middleware.LoginRateLimits{ + PerAccount: middleware.LoginRateLimitWindow{Limit: accountLimit, Window: window}, + PerIP: middleware.LoginRateLimitWindow{Limit: ipLimit, Window: window}, + } +} + +func testRateLimitedRouter( + db *testAccountDB, + store *fakeLoginAttemptStore, + limits middleware.LoginRateLimits, + now *time.Time, +) *PublicRouter { + return &PublicRouter{ + logger: zap.NewNop(), + db: db, + service: mservice.Accounts, + attempts: store, + limits: limits, + now: func() time.Time { + return *now + }, + } +} + +func loginRequest(login, password, remoteAddr string) (*http.Request, *srequest.Login) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil) + req.RemoteAddr = remoteAddr + return req, &srequest.Login{LoginData: model.LoginData{ + UserDataBase: model.UserDataBase{Login: login}, + Password: password, + }} +} + +func executeLoginHandler(t *testing.T, h http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + if h == nil { + t.Fatal("expected response handler") + } + recorder := httptest.NewRecorder() + h(recorder, req) + return recorder +} + +func TestLoginRateLimitNormalizesAccountAndBoundsDistributedAttempts(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + store := newFakeLoginAttemptStore() + db := &testAccountDB{result: testActiveAccount(t)} + router := testRateLimitedRouter(db, store, testLoginLimits(3, 100, 10*time.Minute), &now) + + attempts := []struct { + login string + remote string + status int + }{ + {login: " User@Example.com ", remote: "192.0.2.1:1000", status: http.StatusUnauthorized}, + {login: "user@example.com", remote: "192.0.2.2:1000", status: http.StatusUnauthorized}, + {login: "USER@EXAMPLE.COM", remote: "192.0.2.3:1000", status: http.StatusTooManyRequests}, + } + for _, attempt := range attempts { + req, login := loginRequest(attempt.login, "wrong password", attempt.remote) + _, h := router.authenticateRateLimited(req, login) + recorder := executeLoginHandler(t, h, req) + if recorder.Code != attempt.status { + t.Fatalf("login %q status: got %d want %d", attempt.login, recorder.Code, attempt.status) + } + } + + req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.4:1000") + _, h := router.authenticateRateLimited(req, login) + recorder := executeLoginHandler(t, h, req) + if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "600" { + t.Fatalf("blocked recovery mismatch: status=%d retry=%q", recorder.Code, recorder.Header().Get("Retry-After")) + } + + now = now.Add(10 * time.Minute) + account, h := router.authenticateRateLimited(req, login) + if h != nil || account == nil { + t.Fatalf("expected legitimate login after window expiry, account=%v handler=%v", account, h) + } +} + +func TestLoginRateLimitBoundsOneSourceAcrossAccounts(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + store := newFakeLoginAttemptStore() + router := testRateLimitedRouter( + &testAccountDB{err: merrors.ErrNoData}, + store, + testLoginLimits(100, 3, 15*time.Minute), + &now, + ) + + for i, loginValue := range []string{"one@example.com", "two@example.com", "three@example.com"} { + req, login := loginRequest(loginValue, "wrong password", "198.51.100.10:9000") + _, h := router.authenticateRateLimited(req, login) + recorder := executeLoginHandler(t, h, req) + want := http.StatusUnauthorized + if i == 2 { + want = http.StatusTooManyRequests + } + if recorder.Code != want { + t.Fatalf("attempt %d status: got %d want %d", i+1, recorder.Code, want) + } + } +} + +func TestLoginEndpointReturnsStableRateLimitResponse(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + router := testRateLimitedRouter( + &testAccountDB{err: merrors.ErrNoData}, + newFakeLoginAttemptStore(), + testLoginLimits(2, 50, 15*time.Minute), + &now, + ) + body := `{"login":{"login":"missing@example.com","password":"wrong password"}}` + + for attempt := 1; attempt <= 2; attempt++ { + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(body)) + req.RemoteAddr = "198.51.100.20:9000" + recorder := httptest.NewRecorder() + router.login(req)(recorder, req) + + want := http.StatusUnauthorized + if attempt == 2 { + want = http.StatusTooManyRequests + } + if recorder.Code != want { + t.Fatalf("attempt %d status: got %d want %d", attempt, recorder.Code, want) + } + if attempt == 2 { + if recorder.Header().Get("Retry-After") != "900" { + t.Fatalf("unexpected Retry-After: %q", recorder.Header().Get("Retry-After")) + } + if !strings.Contains(recorder.Body.String(), `"error":"too_many_requests"`) { + t.Fatalf("unexpected 429 body: %s", recorder.Body.String()) + } + } + } +} + +func TestInvalidCredentialsDoNotDiscloseAccountExistence(t *testing.T) { + request := &srequest.Login{LoginData: model.LoginData{ + UserDataBase: model.UserDataBase{Login: "user@example.com"}, + Password: "wrong password", + }} + + responses := make([]*httptest.ResponseRecorder, 0, 2) + for _, db := range []*testAccountDB{ + {result: testActiveAccount(t)}, + {err: merrors.ErrNoData}, + } { + router := &PublicRouter{logger: zap.NewNop(), db: db, service: mservice.Accounts} + _, failed, h := router.authenticateAccount(context.Background(), request) + if !failed { + t.Fatal("invalid credentials were not classified as a failure") + } + req, _ := loginRequest("user@example.com", "wrong password", "192.0.2.1:1000") + responses = append(responses, executeLoginHandler(t, h, req)) + } + + if responses[0].Code != http.StatusUnauthorized || responses[1].Code != http.StatusUnauthorized { + t.Fatalf("unexpected statuses: existing=%d unknown=%d", responses[0].Code, responses[1].Code) + } + if responses[0].Body.String() != responses[1].Body.String() || + !strings.Contains(responses[0].Body.String(), "invalid credentials") { + t.Fatalf("credential responses differ: existing=%s unknown=%s", responses[0].Body.String(), responses[1].Body.String()) + } +} + +func TestLoginRateLimitIsAtomicAcrossRouterInstances(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + store := newFakeLoginAttemptStore() + db := &testAccountDB{err: merrors.ErrNoData} + limits := testLoginLimits(5, 1000, 15*time.Minute) + routers := []*PublicRouter{ + testRateLimitedRouter(db, store, limits, &now), + testRateLimitedRouter(db, store, limits, &now), + } + + var unauthorized atomic.Int64 + var limited atomic.Int64 + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + req, login := loginRequest("shared@example.com", "wrong password", "203.0.113."+strconv.Itoa(index+1)+":9000") + _, h := routers[index%len(routers)].authenticateRateLimited(req, login) + recorder := httptest.NewRecorder() + h(recorder, req) + switch recorder.Code { + case http.StatusUnauthorized: + unauthorized.Add(1) + case http.StatusTooManyRequests: + limited.Add(1) + } + }(i) + } + wg.Wait() + + if unauthorized.Load() != 4 || limited.Load() != 16 { + t.Fatalf("concurrent decisions: unauthorized=%d limited=%d", unauthorized.Load(), limited.Load()) + } +} + +func TestLoginRateLimitFailsClosedOnStorageError(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + store := newFakeLoginAttemptStore() + store.currentErr = merrors.Internal("database unavailable") + db := &testAccountDB{result: testActiveAccount(t)} + router := testRateLimitedRouter(db, store, testLoginLimits(5, 50, 15*time.Minute), &now) + req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.1:1000") + + _, h := router.authenticateRateLimited(req, login) + recorder := executeLoginHandler(t, h, req) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("expected service unavailable, got %d", recorder.Code) + } + if db.getByEmailCalls != 0 { + t.Fatalf("storage failure must stop before password lookup, got %d lookups", db.getByEmailCalls) + } + if strings.Contains(recorder.Body.String(), "database unavailable") { + t.Fatal("storage cause leaked to the public response") + } +} + +func TestLoginRateLimitFailsClosedWhenFailureCannotBeRecorded(t *testing.T) { + now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC) + store := newFakeLoginAttemptStore() + store.incrementErr = merrors.Internal("write unavailable") + router := testRateLimitedRouter( + &testAccountDB{err: merrors.ErrNoData}, + store, + testLoginLimits(5, 50, 15*time.Minute), + &now, + ) + req, login := loginRequest("missing@example.com", "wrong password", "192.0.2.1:1000") + + _, h := router.authenticateRateLimited(req, login) + recorder := executeLoginHandler(t, h, req) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("expected service unavailable, got %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "login protection is temporarily unavailable") { + t.Fatalf("unexpected public failure body: %s", recorder.Body.String()) + } +} + +func TestDummyPasswordHashRemainsValid(t *testing.T) { + if !(&model.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") { + t.Fatal("dummy password hash is not a valid bcrypt hash") + } + if (&model.Account{Password: dummyPasswordHash}).MatchPassword("different") { + t.Fatal("dummy password hash unexpectedly matched another password") + } +} diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test.go b/backend/edge/shared/internal/transport/http/router/public/login_test.go new file mode 100644 index 0000000..097a0c5 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/login_test.go @@ -0,0 +1,268 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func TestLoginRejectsUnknownClientID(t *testing.T) { + testRejectsUnknownClientID(t, "/login", `{ + "login":{"login":"user@example.com","password":"correct horse battery staple"}, + "clientId":"missing-client", + "deviceId":"device-1" + }`, 0, func(router *PublicRouter, req *http.Request) http.HandlerFunc { + return router.login(req) + }) +} + +func TestAPILoginRejectsUnknownClientID(t *testing.T) { + testRejectsUnknownClientID(t, "/login/api", `{ + "login":{"login":"user@example.com","password":"correct horse battery staple"}, + "clientId":"missing-client", + "deviceId":"device-1", + "clientSecret":"secret" + }`, 1, func(router *PublicRouter, req *http.Request) http.HandlerFunc { + return router.apiLogin(req) + }) +} + +func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) { + account := testActiveAccount(t) + unusedDB := &testAccountDB{err: merrors.ErrNoData} + activeDB := &testAccountDB{result: account} + router := &PublicRouter{ + logger: zap.NewNop(), + db: activeDB, + service: mservice.Accounts, + } + + got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{ + LoginData: model.LoginData{ + UserDataBase: model.UserDataBase{ + Login: "user@example.com", + }, + Password: "correct horse battery staple", + }, + }) + if h != nil { + t.Fatal("expected login authentication to succeed against injected account store") + } + if failed { + t.Fatal("successful login was classified as a credential failure") + } + if got == nil || got.Login != account.Login { + t.Fatalf("expected account %q, got %#v", account.Login, got) + } + if activeDB.getByEmailCalls != 1 { + t.Fatalf("expected one active account store lookup, got %d", activeDB.getByEmailCalls) + } + if unusedDB.getByEmailCalls != 0 { + t.Fatalf("expected no unused account store lookups, got %d", unusedDB.getByEmailCalls) + } +} + +func testRejectsUnknownClientID( + t *testing.T, + path string, + body string, + wantLookups int, + handler func(*PublicRouter, *http.Request) http.HandlerFunc, +) { + t.Helper() + + account := testActiveAccount(t) + accountDB := &testAccountDB{result: account} + router := &PublicRouter{ + logger: zap.NewNop(), + db: accountDB, + rtdb: &testRefreshTokenDB{clientErr: merrors.ErrNoData}, + service: mservice.Accounts, + } + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + + handler(router, req)(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + if accountDB.getByEmailCalls != wantLookups { + t.Fatalf("expected %d account lookups, got %d", wantLookups, accountDB.getByEmailCalls) + } + + resp := decodeErrorResponse(t, rec.Body.Bytes()) + if resp.Data.Details != "unknown client id" { + t.Fatalf("expected unknown client id message, got %q", resp.Data.Details) + } +} + +func TestLoginRejectsPendingVerificationAccount(t *testing.T) { + account := testAccountWithStatus(t, model.AccountPendingVerification) + router := &PublicRouter{ + logger: zap.NewNop(), + db: &testAccountDB{result: account}, + service: mservice.Accounts, + } + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{ + "login":{"login":"user@example.com","password":"correct horse battery staple"} + }`)) + rec := httptest.NewRecorder() + + router.login(req)(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } + resp := decodeErrorResponse(t, rec.Body.Bytes()) + if resp.Data.Details != "Account verification required" { + t.Fatalf("expected account verification required message, got %q", resp.Data.Details) + } +} + +func TestLoginRejectsBlockedAccount(t *testing.T) { + account := testAccountWithStatus(t, model.AccountBlocked) + router := &PublicRouter{ + logger: zap.NewNop(), + db: &testAccountDB{result: account}, + service: mservice.Accounts, + } + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{ + "login":{"login":"user@example.com","password":"correct horse battery staple"} + }`)) + rec := httptest.NewRecorder() + + router.login(req)(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } + resp := decodeErrorResponse(t, rec.Body.Bytes()) + if resp.Data.Details != "Account verification required" { + t.Fatalf("expected account verification required message, got %q", resp.Data.Details) + } +} + +func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { + account := testActiveAccount(t) + clientID := "api-client" + deviceID := "device-1" + + rtdb := &testRefreshTokenDB{ + client: &model.Client{ + ClientID: clientID, + ClientSecret: "secret", + GrantTypes: []string{"password"}, + TokenEndpointAuthMethod: "client_secret_post", + AccountRef: account.GetID(), + }, + } + + var cfg middleware.TokenConfig + cfg.Length = 16 + cfg.Expiration.Account = 1 + cfg.Expiration.Refresh = 24 + + signature := middleware.Signature{ + Algorithm: "HS256", + PrivateKey: []byte("test-secret"), + } + + router := NewRouter( + zap.NewNop(), + "/", + &testAccountDB{result: account}, + nil, + rtdb, + nil, + chi.NewRouter(), + &cfg, + &signature, + middleware.LoginRateLimits{}, + ) + + body := `{ + "login":{"login":"user@example.com","password":"correct horse battery staple"}, + "clientId":"api-client", + "deviceId":"device-1", + "clientSecret":"secret" + }` + + firstReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body)) + firstRec := httptest.NewRecorder() + router.apiLogin(firstReq)(firstRec, firstReq) + + if firstRec.Code != http.StatusOK { + t.Fatalf("expected first login status %d, got %d", http.StatusOK, firstRec.Code) + } + firstResp := decodeLoginResponse(t, firstRec.Body.Bytes()) + if firstResp.Data.RefreshToken.Token == "" { + t.Fatal("expected first login to return refresh token") + } + + err := rtdb.Revoke(context.Background(), *account.GetID(), &model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }) + if err != nil { + t.Fatalf("failed to revoke refresh token: %v", err) + } + + secondReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body)) + secondRec := httptest.NewRecorder() + router.apiLogin(secondReq)(secondRec, secondReq) + + if secondRec.Code != http.StatusOK { + t.Fatalf("expected second login status %d, got %d", http.StatusOK, secondRec.Code) + } + secondResp := decodeLoginResponse(t, secondRec.Body.Bytes()) + if secondResp.Data.RefreshToken.Token == "" { + t.Fatal("expected second login to return refresh token") + } + if secondResp.Data.RefreshToken.Token == firstResp.Data.RefreshToken.Token { + t.Fatal("expected second login to issue a different refresh token after revocation") + } + + _, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: firstResp.Data.RefreshToken.Token, + }) + if !errors.Is(err, merrors.ErrNoData) { + t.Fatalf("expected old refresh token to be unusable, got %v", err) + } + + _, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: secondResp.Data.RefreshToken.Token, + }) + if err != nil { + t.Fatalf("expected new refresh token to be active, got %v", err) + } + + if got := rtdb.tokenCountForSession(*account.GetID(), clientID, deviceID); got != 2 { + t.Fatalf("expected 2 refresh token records for session, got %d", got) + } + if got := rtdb.activeTokenCountForSession(*account.GetID(), clientID, deviceID); got != 1 { + t.Fatalf("expected 1 active refresh token for session, got %d", got) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go new file mode 100644 index 0000000..2a60f86 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go @@ -0,0 +1,288 @@ +package public + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type testErrorResponse struct { + Data struct { + Details string `json:"details"` + } `json:"data"` +} + +type testLoginResponse struct { + Data struct { + AccessToken struct { + Token string `json:"token"` + } `json:"accessToken"` + RefreshToken struct { + Token string `json:"token"` + } `json:"refreshToken"` + } `json:"data"` +} + +func decodeErrorResponse(t *testing.T, body []byte) testErrorResponse { + t.Helper() + + var resp testErrorResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + return resp +} + +func decodeLoginResponse(t *testing.T, body []byte) testLoginResponse { + t.Helper() + + var resp testLoginResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to decode login response body: %v", err) + } + return resp +} + +func testActiveAccount(t *testing.T) *model.Account { + t.Helper() + return testAccountWithStatus(t, model.AccountActive) +} + +func testAccountWithStatus(t *testing.T, status model.AccountStatus) *model.Account { + t.Helper() + + account := &model.Account{ + AccountPublic: model.AccountPublic{ + UserDataBase: model.UserDataBase{ + Login: "user@example.com", + }, + }, + Password: "correct horse battery staple", + Status: status, + } + account.SetID(bson.NewObjectID()) + if err := account.HashPassword(); err != nil { + t.Fatalf("failed to hash password: %v", err) + } + return account +} + +type testAccountDB struct { + mu sync.Mutex + result *model.Account + err error + getByEmailCalls int +} + +func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { + db.mu.Lock() + defer db.mu.Unlock() + db.getByEmailCalls++ + return db.result, db.err +} + +func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) { + return nil, merrors.NotImplemented("stub") +} + +func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.Account) error { + if db.err != nil { + return db.err + } + if db.result == nil || db.result.GetID() == nil || *db.result.GetID() != objectRef { + return merrors.ErrNoData + } + *result = *db.result + return nil +} + +func (db *testAccountDB) Update(ctx context.Context, object *model.Account) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) Delete(ctx context.Context, objectRef bson.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) DeleteMany(ctx context.Context, query builder.Query) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (db *testAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error { + return merrors.NotImplemented("stub") +} + +type testRefreshTokenDB struct { + client *model.Client + clientErr error + tokens []*model.RefreshToken +} + +func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { + return db.client, db.clientErr +} + +func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error { + for _, token := range db.tokens { + if token.AccountRef == nil || *token.AccountRef != accountRef { + continue + } + if token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked { + token.IsRevoked = true + return nil + } + } + return nil +} + +func (db *testRefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) { + for _, stored := range db.tokens { + if stored.ClientID == token.ClientID && stored.DeviceID == token.DeviceID && stored.RefreshToken == token.RefreshToken && !stored.IsRevoked { + stored.IsRevoked = true + return true, nil + } + } + return false, nil +} + +func (db *testRefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) { + for _, token := range db.tokens { + if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked { + return cloneRefreshToken(token), nil + } + } + return nil, merrors.ErrNoData +} + +func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) { + for _, token := range db.tokens { + if token.ClientID == t.ClientID && token.DeviceID == t.DeviceID && token.RefreshToken == t.RefreshToken { + if token.IsRevoked { + return nil, merrors.ErrNoData + } + return cloneRefreshToken(token), nil + } + } + return nil, merrors.ErrNoData +} + +func (db *testRefreshTokenDB) Create(ctx context.Context, object *model.RefreshToken) error { + for _, token := range db.tokens { + if token.AccountRef == nil || object.AccountRef == nil { + continue + } + if *token.AccountRef == *object.AccountRef && token.ClientID == object.ClientID && token.DeviceID == object.DeviceID && !token.IsRevoked { + token.RefreshToken = object.RefreshToken + token.SessionVersion = object.SessionVersion + token.ExpiresAt = object.ExpiresAt + token.UserAgent = object.UserAgent + token.IPAddress = object.IPAddress + token.LastUsedAt = object.LastUsedAt + token.IsRevoked = object.IsRevoked + if token.GetID() == nil || token.GetID().IsZero() { + token.SetID(bson.NewObjectID()) + } + object.SetID(*token.GetID()) + return nil + } + } + + clone := cloneRefreshToken(object) + if clone.GetID() == nil || clone.GetID().IsZero() { + clone.SetID(bson.NewObjectID()) + } + object.SetID(*clone.GetID()) + db.tokens = append(db.tokens, clone) + return nil +} + +func (db *testRefreshTokenDB) InsertMany(ctx context.Context, objects []*model.RefreshToken) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.RefreshToken) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) Update(ctx context.Context, object *model.RefreshToken) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) Delete(ctx context.Context, objectRef bson.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) DeleteMany(ctx context.Context, query builder.Query) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) FindOne(ctx context.Context, query builder.Query, result *model.RefreshToken) error { + return merrors.NotImplemented("stub") +} + +func (db *testRefreshTokenDB) tokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int { + count := 0 + for _, token := range db.tokens { + if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID { + count++ + } + } + return count +} + +func (db *testRefreshTokenDB) activeTokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int { + count := 0 + for _, token := range db.tokens { + if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID && !token.IsRevoked { + count++ + } + } + return count +} + +func cloneRefreshToken(token *model.RefreshToken) *model.RefreshToken { + if token == nil { + return nil + } + + cloned := *token + if token.AccountRef != nil { + accountRef := *token.AccountRef + cloned.AccountRef = &accountRef + } + return &cloned +} diff --git a/backend/edge/shared/internal/transport/http/router/public/logout.go b/backend/edge/shared/internal/transport/http/router/public/logout.go new file mode 100644 index 0000000..99ad108 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/logout.go @@ -0,0 +1,55 @@ +package public + +import ( + "encoding/json" + "net/http" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +func (pr *PublicRouter) logout(r *http.Request) http.HandlerFunc { + respond := func(handler http.HandlerFunc, clearCookie bool) http.HandlerFunc { + if !clearCookie || !browser.UsesCookie(r) { + return handler + } + return func(w http.ResponseWriter, request *http.Request) { + browser.ClearRefreshCookie(w, request) + handler.ServeHTTP(w, request) + } + } + var req srequest.AccessTokenRefresh + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + pr.logger.Info("Rejected malformed logout request", zap.Error(err)) + return respond(response.BadPayload(pr.logger, mservice.RefreshTokens, err), false) + } + credential, err := browser.RefreshCredential(r, req.RefreshToken) + if err != nil { + sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive) + return respond(response.Success(pr.logger), true) + } + req.RefreshToken = credential + if req.ClientID == "" || req.DeviceID == "" || req.RefreshToken == "" { + sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.MissingIdentity) + return respond(response.BadRequest(pr.logger, pr.service, "missing_session", "refresh token and session identifier are required"), false) + } + + revoked, err := pr.rtdb.RevokeToken(r.Context(), &req) + if err != nil { + sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.StorageError) + pr.logger.Warn("Failed to revoke current session", zap.Error(err), + zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID)) + return respond(response.Internal(pr.logger, pr.service, err), false) + } + if !revoked { + sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive) + return respond(response.Success(pr.logger), true) + } + sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.Succeeded) + pr.logger.Info("Current session revoked", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID)) + return respond(response.Success(pr.logger), true) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/logout_test.go b/backend/edge/shared/internal/transport/http/router/public/logout_test.go new file mode 100644 index 0000000..9b162f0 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/logout_test.go @@ -0,0 +1,204 @@ +package public + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type logoutDB struct { + refreshtokens.DB + mu sync.Mutex + tokens map[string]*model.RefreshToken + lookupErr error + revokeErr error +} + +func (db *logoutDB) RevokeToken(_ context.Context, request *model.ClientRefreshToken) (bool, error) { + db.mu.Lock() + defer db.mu.Unlock() + if db.lookupErr != nil { + return false, db.lookupErr + } + token := db.tokens[request.DeviceID] + if token == nil || token.IsRevoked || token.RefreshToken != request.RefreshToken { + return false, nil + } + if db.revokeErr != nil { + return false, db.revokeErr + } + token.IsRevoked = true + return true, nil +} + +func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *model.RefreshToken { + return &model.RefreshToken{ + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID}, + RefreshToken: value, + }, + AccountRef: &accountRef, + ExpiresAt: time.Now().Add(time.Hour), + } +} + +func executeLogout(router *PublicRouter, deviceID, refreshToken string) int { + body := `{"clientId":"web","deviceId":"` + deviceID + `","token":"` + refreshToken + `"}` + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout", strings.NewReader(body)) + response := httptest.NewRecorder() + router.logout(request)(response, request) + return response.Code +} + +func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) { + accountRef := bson.NewObjectID() + db := &logoutDB{tokens: map[string]*model.RefreshToken{ + "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), + "device-b": newLogoutToken(accountRef, "device-b", "refresh-b"), + }} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + + if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK { + t.Fatalf("first logout status = %d", status) + } + if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK { + t.Fatalf("repeated logout status = %d", status) + } + if !db.tokens["device-a"].IsRevoked { + t.Fatal("current device session was not revoked") + } + if db.tokens["device-b"].IsRevoked { + t.Fatal("another device session was revoked") + } +} + +func TestConcurrentLogoutIsIdempotent(t *testing.T) { + accountRef := bson.NewObjectID() + db := &logoutDB{tokens: map[string]*model.RefreshToken{ + "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), + }} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + + statuses := make(chan int, 8) + var workers sync.WaitGroup + for range 8 { + workers.Add(1) + go func() { + defer workers.Done() + statuses <- executeLogout(router, "device-a", "refresh-a") + }() + } + workers.Wait() + close(statuses) + for status := range statuses { + if status != http.StatusOK { + t.Fatalf("concurrent logout status = %d", status) + } + } +} + +func TestDelayedLogoutDoesNotRevokeReplacementSession(t *testing.T) { + accountRef := bson.NewObjectID() + db := &logoutDB{tokens: map[string]*model.RefreshToken{ + "device-a": newLogoutToken(accountRef, "device-a", "new-refresh"), + }} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + + if status := executeLogout(router, "device-a", "old-refresh"); status != http.StatusOK { + t.Fatalf("delayed logout status = %d", status) + } + if db.tokens["device-a"].IsRevoked { + t.Fatal("replacement session was revoked by a stale logout request") + } +} + +func TestLogoutAcceptsAnAlreadyExpiredSession(t *testing.T) { + accountRef := bson.NewObjectID() + expired := newLogoutToken(accountRef, "device-a", "expired-refresh") + expired.ExpiresAt = time.Now().Add(-time.Hour) + db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": expired}} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + + if status := executeLogout(router, "device-a", "expired-refresh"); status != http.StatusOK { + t.Fatalf("expired logout status = %d", status) + } + if !expired.IsRevoked { + t.Fatal("expired session was not marked revoked before cleanup") + } +} + +func TestLogoutFailsClosedWhenSessionStoreIsUnavailable(t *testing.T) { + db := &logoutDB{lookupErr: merrors.Internal("database unavailable")} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusInternalServerError { + t.Fatalf("logout status = %d, want %d", status, http.StatusInternalServerError) + } +} + +func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) { + accountRef := bson.NewObjectID() + token := newLogoutToken(accountRef, "device-a", "cookie-refresh") + db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": token}} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` + request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) + request.Header.Set(browser.TransportHeader, browser.TransportCookie) + request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"}) + response := httptest.NewRecorder() + + router.logout(request)(response, request) + + require.Equal(t, http.StatusOK, response.Code) + require.True(t, token.IsRevoked) + cookies := response.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, browser.RefreshCookie, cookies[0].Name) + require.Equal(t, -1, cookies[0].MaxAge) + require.Equal(t, "/api/v1/accounts", cookies[0].Path) +} + +func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing.T) { + db := &logoutDB{tokens: map[string]*model.RefreshToken{}} + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` + request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) + request.Header.Set(browser.TransportHeader, browser.TransportCookie) + response := httptest.NewRecorder() + + router.logout(request)(response, request) + + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Header().Get("Set-Cookie"), browser.RefreshCookie+"=") + require.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0") +} + +func TestBrowserLogoutKeepsCookieWhenRevocationFails(t *testing.T) { + db := &logoutDB{ + tokens: map[string]*model.RefreshToken{}, + lookupErr: merrors.Internal("database unavailable"), + } + router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} + body := `{"clientId":"web","deviceId":"device-a"}` + request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) + request.Header.Set(browser.TransportHeader, browser.TransportCookie) + request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"}) + response := httptest.NewRecorder() + + router.logout(request)(response, request) + + require.Equal(t, http.StatusInternalServerError, response.Code) + require.Empty(t, response.Header().Get("Set-Cookie"), "client must retain the cookie so logout can retry revocation") +} diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh.go b/backend/edge/shared/internal/transport/http/router/public/refresh.go new file mode 100644 index 0000000..fcacefe --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/refresh.go @@ -0,0 +1,53 @@ +package public + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc { + pr.logger.Debug("Processing access token refresh request") + var req srequest.AccessTokenRefresh + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + pr.logger.Info("Failed to decode token rotation request", zap.Error(err)) + return response.BadPayload(pr.logger, mservice.RefreshTokens, err) + } + + account, token, refreshToken, err := pr.validateRefreshToken(r.Context(), r, &req) + if err != nil { + if errors.Is(err, errClientIPNotAllowed) { + return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") + } + if isExpectedRefreshValidationError(err) { + pr.logger.Debug("Access token refresh rejected", zap.Error(err)) + return pr.rejectRefresh(r, err) + } + pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err)) + return response.Auto(pr.logger, pr.service, err) + } + if browser.UsesCookie(r) && refreshToken.ExpiresAt.Before(time.Now().Add(7*24*time.Hour)) { + return pr.refreshAndRespondLogin(r.Context(), r, &refreshToken.SessionIdentifier, account) + } + + return sresponse.Account(pr.logger, account, token) +} + +func (pr *PublicRouter) rejectRefresh(r *http.Request, err error) http.HandlerFunc { + handler := response.Auto(pr.logger, pr.service, err) + if !browser.UsesCookie(r) { + return handler + } + return func(w http.ResponseWriter, request *http.Request) { + browser.ClearRefreshCookie(w, request) + handler.ServeHTTP(w, request) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go new file mode 100644 index 0000000..54acf0c --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go @@ -0,0 +1,137 @@ +package public + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, *testRefreshTokenDB) { + t.Helper() + account := testActiveAccount(t) + clientID := "web" + deviceID := "browser-1" + rtdb := &testRefreshTokenDB{ + client: &model.Client{ClientID: clientID}, + tokens: []*model.RefreshToken{ + { + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + SessionVersion: "version-1", + }, + RefreshToken: "cookie-refresh", + }, + AccountRef: account.GetID(), + ExpiresAt: expiresAt, + }, + }, + } + config := middleware.TokenConfig{ + Expiration: middleware.TokenExpiration{Account: 1, Refresh: 720}, + Length: 32, + } + signature := middleware.Signature{ + Algorithm: "HS256", + PrivateKey: []byte("browser-refresh-test-secret"), + } + return NewRouter( + zap.NewNop(), + "/api/v1", + &testAccountDB{result: account}, + nil, + rtdb, + nil, + chi.NewRouter(), + &config, + &signature, + middleware.LoginRateLimits{}, + ), rtdb +} + +func executeBrowserRefresh(t *testing.T, router *PublicRouter) *httptest.ResponseRecorder { + t.Helper() + body := `{"clientId":"web","deviceId":"browser-1","token":"body-must-be-ignored"}` + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body)) + request.Header.Set(browser.TransportHeader, browser.TransportCookie) + request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"}) + response := httptest.NewRecorder() + router.refreshAccessToken(request)(response, request) + return response +} + +func executeNativeRefresh(t *testing.T, router *PublicRouter, token string) *httptest.ResponseRecorder { + t.Helper() + body := `{"clientId":"web","deviceId":"browser-1","token":"` + token + `"}` + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body)) + response := httptest.NewRecorder() + router.refreshAccessToken(request)(response, request) + return response +} + +func TestBrowserRefreshRestoresAccessWithoutExposingRefreshCredential(t *testing.T) { + router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour)) + + response := executeBrowserRefresh(t, router) + + require.Equal(t, http.StatusOK, response.Code) + require.NotContains(t, response.Body.String(), "cookie-refresh") + require.NotEmpty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.AccessToken.Token) + require.Empty(t, response.Header().Get("Set-Cookie")) +} + +func TestBrowserRefreshRotatesExpiringCookieServerSide(t *testing.T) { + t.Setenv("API_ENDPOINT", "/api/v1") + router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour)) + + response := executeBrowserRefresh(t, router) + + require.Equal(t, http.StatusOK, response.Code) + require.NotContains(t, response.Body.String(), "cookie-refresh") + cookies := response.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, browser.RefreshCookie, cookies[0].Name) + require.NotEqual(t, "cookie-refresh", cookies[0].Value) + require.Empty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.RefreshToken.Token) + require.Equal(t, cookies[0].Value, rtdb.tokens[0].RefreshToken) +} + +func TestBrowserRefreshRejectsStaleCookieAndClearsIt(t *testing.T) { + t.Setenv("API_ENDPOINT", "/api/v1") + router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour)) + rtdb.tokens = nil + + response := executeBrowserRefresh(t, router) + + require.Equal(t, http.StatusUnauthorized, response.Code) + require.Contains(t, response.Body.String(), `"error":"unauthorized"`) + require.NotContains(t, response.Body.String(), "cookie-refresh") + cookies := response.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, browser.RefreshCookie, cookies[0].Name) + require.Empty(t, cookies[0].Value) + require.Equal(t, -1, cookies[0].MaxAge) + require.True(t, cookies[0].HttpOnly) + require.True(t, cookies[0].Secure) +} + +func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) { + router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour)) + + response := executeNativeRefresh(t, router, "stale-refresh") + + require.Equal(t, http.StatusUnauthorized, response.Code) + require.Contains(t, response.Body.String(), `"error":"unauthorized"`) + require.Empty(t, response.Header().Get("Set-Cookie")) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/respond.go b/backend/edge/shared/internal/transport/http/router/public/respond.go new file mode 100644 index 0000000..720212b --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/respond.go @@ -0,0 +1,48 @@ +package public + +import ( + "context" + "net/http" + + rtokens "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/tokens" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.uber.org/zap" +) + +func (pr *PublicRouter) refreshAndRespondLogin( + ctx context.Context, + r *http.Request, + session *model.SessionIdentifier, + account *model.Account, +) http.HandlerFunc { + refreshToken, err := rtokens.PrepareRefreshToken( + ctx, + r, + pr.rtdb, + pr.config.Length, + pr.config.Expiration.Refresh, + session, + account, + pr.logger, + ) + if err != nil { + pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account), + zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) + return response.Internal(pr.logger, pr.service, err) + } + accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier) + if err != nil { + pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account), + zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) + return response.Internal(pr.logger, pr.service, err) + } + + token := sresponse.TokenData{ + Token: refreshToken.RefreshToken, + Expiration: refreshToken.ExpiresAt, + } + return sresponse.Login(pr.logger, account, &accessToken, &token) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/rotate.go b/backend/edge/shared/internal/transport/http/router/public/rotate.go new file mode 100644 index 0000000..e7b5883 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/rotate.go @@ -0,0 +1,36 @@ +package public + +import ( + "encoding/json" + "errors" + "net/http" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc { + pr.logger.Debug("Processing token rotation request...") + var req srequest.TokenRefreshRotate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + pr.logger.Info("Failed to decode token rotation request", zap.Error(err)) + return response.BadPayload(pr.logger, mservice.RefreshTokens, err) + } + + account, _, _, err := pr.validateRefreshToken(r.Context(), r, &req) + if err != nil { + if errors.Is(err, errClientIPNotAllowed) { + return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") + } + if isExpectedRefreshValidationError(err) { + pr.logger.Debug("Refresh token rotation rejected", zap.Error(err)) + return pr.rejectRefresh(r, err) + } + pr.logger.Warn("Failed to validate refresh token", zap.Error(err)) + return response.Auto(pr.logger, pr.service, err) + } + + return pr.refreshAndRespondLogin(r.Context(), r, &req.SessionIdentifier, account) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/router.go b/backend/edge/shared/internal/transport/http/router/public/router.go new file mode 100644 index 0000000..d6b537f --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/router.go @@ -0,0 +1,69 @@ +package public + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" +) + +type PublicRouter struct { + logger mlogger.Logger + db account.DB + imp *re.HttpEndpointRouter + rtdb refreshtokens.DB + config middleware.TokenConfig + signature middleware.Signature + service mservice.Type + attempts ratelimits.Store + limits middleware.LoginRateLimits + now func() time.Time +} + +func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) { + pr.imp.InstallHandler(service, endpoint, method, handler) +} + +func NewRouter( + logger mlogger.Logger, + apiEndpoint string, + db account.DB, + vdb verification.DB, + rtdb refreshtokens.DB, + attempts ratelimits.Store, + router chi.Router, + config *middleware.TokenConfig, + signature *middleware.Signature, + limits middleware.LoginRateLimits, +) *PublicRouter { + l := logger.Named("public") + hr := PublicRouter{ + logger: l, + db: db, + rtdb: rtdb, + config: *config, + signature: *signature, + imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature), + service: mservice.Accounts, + attempts: attempts, + limits: limits, + now: time.Now, + } + + hr.InstallHandler(hr.service, "/login", api.Post, hr.login) + hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin) + hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken) + hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken) + hr.InstallHandler(hr.service, "/logout", api.Post, hr.logout) + + return &hr +} diff --git a/backend/edge/shared/internal/transport/http/router/public/validate.go b/backend/edge/shared/internal/transport/http/router/public/validate.go new file mode 100644 index 0000000..bd7b514 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/public/validate.go @@ -0,0 +1,98 @@ +package public + +import ( + "context" + "errors" + "net/http" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.uber.org/zap" +) + +var errClientIPNotAllowed = merrors.Define("client_ip_not_allowed") + +func isExpectedRefreshValidationError(err error) bool { + return errors.Is(err, merrors.ErrUnauthorized) +} + +func validateToken(token string, rt *model.RefreshToken) string { + if rt.AccountRef == nil { + return "missing account reference" + } + if token != rt.RefreshToken { + return "tokens do not match" + } + if rt.ExpiresAt.Before(time.Now()) { + return "token expired" + } + if rt.IsRevoked { + return "token has been revoked" + } + + return "" +} + +func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, *model.RefreshToken, error) { + credential, err := browser.RefreshCredential(r, req.RefreshToken) + if err != nil { + return nil, nil, nil, err + } + req.RefreshToken = credential + client, err := pr.rtdb.GetClient(ctx, req.ClientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + pr.logger.Debug("Refresh token rejected: client not found", zap.String("client_id", req.ClientID)) + return nil, nil, nil, merrors.Unauthorized("client not found") + } + if err != nil { + pr.logger.Warn("Failed to fetch client for refresh token validation", zap.Error(err), zap.String("client_id", req.ClientID)) + return nil, nil, nil, err + } + if client.IsRevoked { + return nil, nil, nil, merrors.Unauthorized("client has been revoked") + } + if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil { + return nil, nil, nil, errClientIPNotAllowed + } + + rt, err := pr.rtdb.GetByCRT(ctx, req) + if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) { + pr.logger.Debug("Refresh session is inactive", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID)) + return nil, nil, nil, merrors.Unauthorized("refresh session is inactive") + } + if err != nil { + return nil, nil, nil, err + } + if rt.SessionVersion == "" { + pr.logger.Debug("Refresh token rejected: legacy session requires reauthentication", + zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID)) + return nil, nil, nil, merrors.Unauthorized("session reauthentication required") + } + + if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 { + pr.logger.Debug("Token validation failed", zap.String("reason", reason)) + return nil, nil, nil, merrors.Unauthorized(reason) + } + + var account model.Account + if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) { + pr.logger.Debug("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef)) + return nil, nil, nil, merrors.Unauthorized("user not found") + } else if err != nil { + pr.logger.Warn("Failed to fetch user while validating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef)) + return nil, nil, nil, err + } + + accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier) + if err != nil { + pr.logger.Warn("Failed to generate access token", zap.Error(err)) + return nil, nil, nil, err + } + + return &account, &accessToken, rt, nil +} diff --git a/backend/edge/shared/internal/transport/http/router/router.go b/backend/edge/shared/internal/transport/http/router/router.go new file mode 100644 index 0000000..eff32e1 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/router.go @@ -0,0 +1,16 @@ +package router + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type APIRouter interface { + InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) +} + +type ProtectedAPIRouter interface { + AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) + PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) +} diff --git a/backend/edge/shared/internal/transport/http/websocket/dispimp.go b/backend/edge/shared/internal/transport/http/websocket/dispimp.go new file mode 100644 index 0000000..7b9cac3 --- /dev/null +++ b/backend/edge/shared/internal/transport/http/websocket/dispimp.go @@ -0,0 +1,68 @@ +package websocket + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + "golang.org/x/net/websocket" +) + +type DispatcherImpl struct { + logger mlogger.Logger + handlers map[string]ws.HandlerFunc + timeout int +} + +func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) { + d.handlers[messageType] = handler + d.logger.Info("Handler installed", zap.String("message_type", messageType)) +} + +func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) { + var msg ws.Message + err := websocket.JSON.Receive(conn, &msg) + if err != nil { + d.logger.Warn("Failed to read websocket message", zap.Error(err)) + return + } + + if handler, exists := d.handlers[msg.MessageType]; exists { + responseHandler := handler(ctx, msg) + responseHandler(msg.MessageType, conn) + } else { + d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg)) + } +} + +func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) { + //nolint:contextcheck // websocket.Handler callback signature does not carry request context. + websocket.Handler(func(conn *websocket.Conn) { + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second) + defer cancel() + d.dispatchMessage(ctx, conn) + }).ServeHTTP(w, r) +} + +func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl { + d := &DispatcherImpl{ + logger: logger.Named("websocket"), + handlers: make(map[string]ws.HandlerFunc), + timeout: config.Timeout, + } + + d.logger.Debug("Installing websocket middleware...") + router.Group(func(r chi.Router) { + ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv)) + d.logger.Info("Installing websockets handler", zap.String("endpoint", ep)) + r.Get(ep, d.handle) + }) + + return d +} diff --git a/backend/edge/shared/internal/transport/http/websocket/router.go b/backend/edge/shared/internal/transport/http/websocket/router.go new file mode 100644 index 0000000..600c41e --- /dev/null +++ b/backend/edge/shared/internal/transport/http/websocket/router.go @@ -0,0 +1,15 @@ +package websocket + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" +) + +type Router interface { + InstallHandler(messageType string, handler ws.HandlerFunc) +} + +func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router { + return NewDispatcher(logger, router, config, apiEndpoint) +} diff --git a/backend/edge/shared/server/server.go b/backend/edge/shared/server/server.go new file mode 100644 index 0000000..5d35149 --- /dev/null +++ b/backend/edge/shared/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + internalserver "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/server" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/server" +) + +func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) { + return internalserver.Create(logger, file, debug, runtime) +} diff --git a/backend/edge/shared/storage/mongo/dependencies.go b/backend/edge/shared/storage/mongo/dependencies.go new file mode 100644 index 0000000..84105e8 --- /dev/null +++ b/backend/edge/shared/storage/mongo/dependencies.go @@ -0,0 +1,50 @@ +package mongo + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// Dependencies resolves shared DB/auth/transaction primitives for edge-local store wiring only. +type Dependencies struct { + Database *mongo.Database + Enforcer auth.Enforcer + Policies policy.DB + Transactions transaction.Factory +} + +type DatabaseProvider interface { + MongoDatabase() *mongo.Database +} + +func ResolveDependencies(dbf db.Factory) (*Dependencies, error) { + if dbf == nil { + return nil, merrors.InvalidArgument("database factory is required", "dbFactory") + } + + provider, ok := dbf.(DatabaseProvider) + if !ok || provider.MongoDatabase() == nil { + return nil, merrors.Internal("mongo database provider is not available for edge-local storage wiring") + } + + permissions := dbf.Permissions() + if permissions == nil || permissions.Enforcer() == nil { + return nil, merrors.Internal("permissions provider is not available for edge-local storage wiring") + } + + pdb, err := dbf.NewPoliciesDB() + if err != nil { + return nil, err + } + + return &Dependencies{ + Database: provider.MongoDatabase(), + Enforcer: permissions.Enforcer(), + Policies: pdb, + Transactions: dbf.TransactionFactory(), + }, nil +} diff --git a/backend/edge/shared/storage/organization/archived.go b/backend/edge/shared/storage/organization/archived.go new file mode 100644 index 0000000..68c2ca3 --- /dev/null +++ b/backend/edge/shared/storage/organization/archived.go @@ -0,0 +1,32 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// SetArchived sets the archived status of an organization and optionally cascades to projects, tasks, comments, and reactions +func (db *OrganizationDB) SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error { + db.DBImp.Logger.Debug("Setting organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade)) + + // Get the organization first + var organization model.Organization + if err := db.Get(ctx, accountRef, organizationRef, &organization); err != nil { + db.DBImp.Logger.Warn("Error retrieving organization for archival", zap.Error(err), mzap.OrgRef(organizationRef)) + return err + } + + // Update the organization's archived status + organization.SetArchived(archived) + if err := db.Update(ctx, accountRef, &organization); err != nil { + db.DBImp.Logger.Warn("Error updating organization archived status", zap.Error(err), mzap.OrgRef(organizationRef)) + return err + } + + db.DBImp.Logger.Debug("Successfully set organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived)) + return nil +} diff --git a/backend/edge/shared/storage/organization/cascade.go b/backend/edge/shared/storage/organization/cascade.go new file mode 100644 index 0000000..b2f11d4 --- /dev/null +++ b/backend/edge/shared/storage/organization/cascade.go @@ -0,0 +1,23 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// DeleteCascade deletes an organization and all its related data (projects, tasks, comments, reactions, statuses) +func (db *OrganizationDB) DeleteCascade(ctx context.Context, organizationRef bson.ObjectID) error { + db.DBImp.Logger.Debug("Starting organization deletion with projects", mzap.OrgRef(organizationRef)) + + // Delete the organization itself + if err := db.Unprotected().Delete(ctx, organizationRef); err != nil { + db.DBImp.Logger.Warn("Error deleting organization", zap.Error(err), mzap.OrgRef(organizationRef)) + return err + } + + db.DBImp.Logger.Debug("Successfully deleted organization with projects", mzap.OrgRef(organizationRef)) + return nil +} diff --git a/backend/edge/shared/storage/organization/create.go b/backend/edge/shared/storage/organization/create.go new file mode 100644 index 0000000..15a2928 --- /dev/null +++ b/backend/edge/shared/storage/organization/create.go @@ -0,0 +1,19 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *OrganizationDB) Create(ctx context.Context, _, _ bson.ObjectID, org *model.Organization) error { + if org == nil { + return merrors.InvalidArgument("Organization object is nil", "organization") + } + org.SetID(bson.NewObjectID()) + // Organizaiton reference must be set to the same value as own organization reference + org.SetOrganizationRef(*org.GetID()) + return db.DBImp.Create(ctx, org) +} diff --git a/backend/edge/shared/storage/organization/db.go b/backend/edge/shared/storage/organization/db.go new file mode 100644 index 0000000..2bf8f4b --- /dev/null +++ b/backend/edge/shared/storage/organization/db.go @@ -0,0 +1,113 @@ +package organization + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type OrganizationDB struct { + auth.ProtectedDBImp[*model.Organization] +} + +func Create(ctx context.Context, + logger mlogger.Logger, + enforcer auth.Enforcer, + pdb policy.DB, + db *mongo.Database, +) (*OrganizationDB, error) { + p, err := auth.CreateDBImp[*model.Organization](ctx, logger, pdb, enforcer, mservice.Organizations, db) + if err != nil { + return nil, err + } + + res := &OrganizationDB{ + ProtectedDBImp: *p, + } + p.DBImp.SetDeleter(res.DeleteCascade) + if err := ensureProvisioningIndexes(ctx, res); err != nil { + return nil, err + } + return res, nil +} + +// ensureProvisioningIndexes guards concurrent operator-driven provisioning: +// two attempts creating an organization for the same owner and name race the +// find-then-create in the provisioner, and the unique index makes the loser +// fail over to the winner's organization instead of duplicating it. The index +// spans every provisioned state, not only pending: activation keeps the +// provisioning provenance, so a retry racing a concurrent finalization is +// stopped here rather than by the pre-create lookups alone. It is keyed by the +// immutable provisioning name snapshot, not the mutable display name, so a +// rename can neither free the key for a duplicate nor collide with a neighbour. +func ensureProvisioningIndexes(ctx context.Context, db *OrganizationDB) error { + definition := &ri.Definition{ + Name: "uq_organization_provisioning_owner_name", + Keys: []ri.Key{ + {Field: model.OrganizationProvisioningOwnerRefField, Sort: ri.Asc}, + {Field: model.OrganizationProvisioningNameField, Sort: ri.Asc}, + }, + Unique: true, + // Archived organizations leave the index: archiving is how an operator + // retires a name, and a retired name must be reusable. Documents from + // builds that predate the name snapshot stay out as well; sharing the + // missing-name key would otherwise make them collide with each other. + PartialFilter: repository.Query(). + Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true). + Comparison(repository.Field(model.OrganizationProvisioningNameField), builder.Exists, true). + Filter(repository.Field(storable.IsArchivedField), false), + } + if err := db.DBImp.Repository.CreateIndex(definition); err != nil { + db.DBImp.Logger.Error("Failed to create provisioned organization index", + zap.String("index", definition.Name), zap.Error(err)) + return merrors.InternalWrap(err, "failed to create provisioned organization index") + } + // Predecessors of this index on environments that ran earlier builds of + // this feature: the pending-only variant ignored isArchived, the display + // name variant let a rename detach the organization from its dedup key. + if err := dropSupersededProvisioningIndexes(ctx, db.DBImp.Repository); err != nil { + db.DBImp.Logger.Error("Failed to remove superseded organization provisioning indexes", zap.Error(err)) + return err + } + return nil +} + +var supersededProvisioningIndexes = [...]string{ + "uq_organization_pending_provisioning_owner_name", + "uq_organization_provisioned_owner_name", +} + +func dropSupersededProvisioningIndexes(ctx context.Context, repo repository.Repository) error { + for _, name := range supersededProvisioningIndexes { + if err := repo.DropIndex(ctx, name); err != nil { + if provisioningIndexNotFound(err) { + continue + } + return merrors.InternalWrap(err, "failed to drop superseded organization provisioning index "+name) + } + } + return nil +} + +const ( + mongoNamespaceNotFoundCode = 26 + mongoIndexNotFoundCode = 27 +) + +func provisioningIndexNotFound(err error) bool { + var serverErr mongo.ServerError + return errors.As(err, &serverErr) && + (serverErr.HasErrorCode(mongoNamespaceNotFoundCode) || serverErr.HasErrorCode(mongoIndexNotFoundCode)) +} diff --git a/backend/edge/shared/storage/organization/get.go b/backend/edge/shared/storage/organization/get.go new file mode 100644 index 0000000..c6a001f --- /dev/null +++ b/backend/edge/shared/storage/organization/get.go @@ -0,0 +1,12 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *OrganizationDB) GetByRef(ctx context.Context, organizationRef bson.ObjectID, org *model.Organization) error { + return db.Unprotected().Get(ctx, organizationRef, org) +} diff --git a/backend/edge/shared/storage/organization/index_test.go b/backend/edge/shared/storage/organization/index_test.go new file mode 100644 index 0000000..786e2d0 --- /dev/null +++ b/backend/edge/shared/storage/organization/index_test.go @@ -0,0 +1,54 @@ +package organization + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func TestDropSupersededProvisioningIndexesIgnoresMissingIndexes(t *testing.T) { + repo := &dropIndexRepository{dropErrors: []error{ + mongo.CommandError{Code: mongoIndexNotFoundCode}, + mongo.CommandError{Code: mongoNamespaceNotFoundCode}, + }} + + if err := dropSupersededProvisioningIndexes(context.Background(), repo); err != nil { + t.Fatalf("missing superseded indexes must be ignored: %v", err) + } + if len(repo.dropped) != len(supersededProvisioningIndexes) { + t.Fatalf("drop count mismatch: got=%d want=%d", len(repo.dropped), len(supersededProvisioningIndexes)) + } +} + +func TestDropSupersededProvisioningIndexesPreservesUnexpectedFailure(t *testing.T) { + wantErr := merrors.ServiceUnavailable("MongoDB is unavailable") + repo := &dropIndexRepository{dropErrors: []error{wantErr}} + + err := dropSupersededProvisioningIndexes(context.Background(), repo) + if !errors.Is(err, wantErr) { + t.Fatalf("unexpected drop failure must be preserved: got=%v want=%v", err, wantErr) + } + if len(repo.dropped) != 1 { + t.Fatalf("cleanup must stop after an unexpected failure: dropped=%v", repo.dropped) + } +} + +type dropIndexRepository struct { + repository.Repository + dropErrors []error + dropped []string +} + +func (r *dropIndexRepository) DropIndex(_ context.Context, name string) error { + r.dropped = append(r.dropped, name) + if len(r.dropErrors) == 0 { + return nil + } + err := r.dropErrors[0] + r.dropErrors = r.dropErrors[1:] + return err +} diff --git a/backend/edge/shared/storage/organization/list.go b/backend/edge/shared/storage/organization/list.go new file mode 100644 index 0000000..a4a677c --- /dev/null +++ b/backend/edge/shared/storage/organization/list.go @@ -0,0 +1,16 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *OrganizationDB) List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) { + filter := repository.Query().Comparison(repository.Field("members"), builder.Eq, accountRef) + return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, filter, cursor, db.DBImp.Repository) +} diff --git a/backend/edge/shared/storage/organization/member.go b/backend/edge/shared/storage/organization/member.go new file mode 100644 index 0000000..8c43150 --- /dev/null +++ b/backend/edge/shared/storage/organization/member.go @@ -0,0 +1,49 @@ +package organization + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (db *OrganizationDB) IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) { + db.DBImp.Logger.Debug("Checking organization membership", mzap.OrgRef(orgRef), mzap.AccRef(callerRef)) + + filter := repository.Query(). + Filter(repository.IDField(), orgRef). + Comparison(repository.Field("members"), builder.Eq, callerRef) + var org model.Organization + if err := db.Unprotected().FindOne(ctx, filter, &org); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return false, nil + } + db.DBImp.Logger.Warn("Membership check failed", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(callerRef)) + return false, err + } + return true, nil +} + +// AddMember atomically appends the account to the members list and reports +// false when it already was one. A point patch on purpose: replacing the whole +// document would clobber concurrent writes to fields the caller never read, +// most notably the provisioning lease. +func (db *OrganizationDB) AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) { + filter := repository.Query(). + Filter(repository.IDField(), orgRef). + Comparison(repository.Field("members"), builder.Ne, accountRef) + patch := repository.Patch().AddToSet(repository.Field("members"), accountRef) + + matched, err := db.DBImp.PatchMany(ctx, filter, patch) + if err != nil { + db.DBImp.Logger.Warn("Failed to add organization member", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(accountRef)) + return false, err + } + return matched > 0, nil +} diff --git a/backend/edge/shared/storage/organization/member_test.go b/backend/edge/shared/storage/organization/member_test.go new file mode 100644 index 0000000..a32daf2 --- /dev/null +++ b/backend/edge/shared/storage/organization/member_test.go @@ -0,0 +1,153 @@ +package organization + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// membershipTestRepository shadows FindOneByFilter with a members-aware implementation. +// When findErr is set, FindOneByFilter returns it immediately. +type membershipTestRepository struct { + *memoryOrganizationRepository + findErr error +} + +func (r *membershipTestRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error { + if r.findErr != nil { + return r.findErr + } + r.mu.RLock() + defer r.mu.RUnlock() + for _, id := range r.order { + if org, ok := r.data[id]; ok && memberMatchesQuery(query, org) { + dst, ok := result.(*model.Organization) + if !ok { + return merrors.InvalidDataType("expected organization result") + } + *dst = *cloneOrganization(org) + return nil + } + } + return merrors.ErrNoData +} + +func memberMatchesQuery(query builder.Query, org *model.Organization) bool { + if query == nil { + return true + } + for _, elem := range query.BuildQuery() { + switch elem.Key { + case storable.IDField: + id, ok := elem.Value.(bson.ObjectID) + if !ok || *org.GetID() != id { + return false + } + case "members": + cond, ok := elem.Value.(bson.M) + if !ok { + return false + } + ref, ok := cond[string(builder.Eq)].(bson.ObjectID) + if !ok { + return false + } + found := false + for _, m := range org.Members { + if m == ref { + found = true + break + } + } + if !found { + return false + } + } + } + return true +} + +func newMemberTestDB(t *testing.T, findErr error) *OrganizationDB { + t.Helper() + repo := &membershipTestRepository{ + memoryOrganizationRepository: newMemoryOrganizationRepository(), + findErr: findErr, + } + dbImp := &template.DBImp[*model.Organization]{ + Logger: zap.NewNop(), + Repository: repo, + } + dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error { + return repo.Delete(ctx, objectRef) + }) + return &OrganizationDB{ + ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{ + DBImp: dbImp, + Enforcer: allowAllEnforcer{}, + PermissionRef: bson.NewObjectID(), + Collection: mservice.Organizations, + }, + } +} + +func TestOrganizationDB_IsMember_Member(t *testing.T) { + ctx := context.Background() + db := newMemberTestDB(t, nil) + callerRef := bson.NewObjectID() + + org := &model.Organization{} + org.SetID(bson.NewObjectID()) + org.Members = []bson.ObjectID{callerRef} + require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org)) + + ok, err := db.IsMember(ctx, *org.GetID(), callerRef) + require.NoError(t, err) + assert.True(t, ok) +} + +func TestOrganizationDB_IsMember_NonMember(t *testing.T) { + ctx := context.Background() + db := newMemberTestDB(t, nil) + callerRef := bson.NewObjectID() + otherRef := bson.NewObjectID() + + org := &model.Organization{} + org.SetID(bson.NewObjectID()) + org.Members = []bson.ObjectID{otherRef} + require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org)) + + ok, err := db.IsMember(ctx, *org.GetID(), callerRef) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestOrganizationDB_IsMember_OrgNotFound(t *testing.T) { + ctx := context.Background() + db := newMemberTestDB(t, nil) + + ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID()) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestOrganizationDB_IsMember_DBError(t *testing.T) { + ctx := context.Background() + boom := merrors.Internal("backend down") + db := newMemberTestDB(t, boom) + + ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID()) + assert.False(t, ok) + assert.True(t, errors.Is(err, boom)) +} diff --git a/backend/edge/shared/storage/organization/memory_repository_queries_test.go b/backend/edge/shared/storage/organization/memory_repository_queries_test.go new file mode 100644 index 0000000..2d5c300 --- /dev/null +++ b/backend/edge/shared/storage/organization/memory_repository_queries_test.go @@ -0,0 +1,378 @@ +package organization + +import ( + "context" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (m *memoryOrganizationRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { + return merrors.NotImplemented("aggregate is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error { + m.mu.RLock() + defer m.mu.RUnlock() + for _, id := range m.order { + if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { + dst, okCast := result.(*model.Organization) + if !okCast { + return merrors.InvalidDataType("expected organization result") + } + *dst = *cloneOrganization(org) + return nil + } + } + return merrors.ErrNoData +} + +func (m *memoryOrganizationRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { + return m.FindOneByFilter(ctx, query, result) +} + +func (m *memoryOrganizationRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error { + return merrors.NotImplemented("FindManyByFilter is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) FindAllByFilter(_ context.Context, query builder.Query, result any) error { + m.mu.RLock() + defer m.mu.RUnlock() + + dst, ok := result.(*[]model.Organization) + if !ok { + return merrors.InvalidDataType("expected organization slice result") + } + *dst = (*dst)[:0] + for _, id := range m.order { + if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { + *dst = append(*dst, *cloneOrganization(org)) + } + } + return nil +} + +func (m *memoryOrganizationRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var count int64 + for _, id := range m.order { + if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { + count++ + } + } + return count, nil +} + +func (m *memoryOrganizationRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error { + return merrors.NotImplemented("Patch is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) { + return false, merrors.NotImplemented("PatchOneByFilter is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) PatchMany(_ context.Context, query builder.Query, patch builder.Patch) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + matched := 0 + for _, id := range m.order { + org, ok := m.data[id] + if !ok || !m.matchesQuery(query, org) { + continue + } + if err := applyOrganizationPatch(org, patch); err != nil { + return matched, err + } + matched++ + } + return matched, nil +} + +// applyOrganizationPatch supports the operations the provisioning storage +// issues; anything else fails loudly so a test cannot pass vacuously. +func applyOrganizationPatch(org *model.Organization, patch builder.Patch) error { + for _, op := range patch.Build() { + fields, ok := op.Value.(bson.D) + if !ok { + return merrors.NotImplemented("unsupported patch value shape: " + op.Key) + } + for _, field := range fields { + switch op.Key { + case string(builder.Set): + if err := setOrganizationField(org, field.Key, field.Value); err != nil { + return err + } + case string(builder.Unset): + if err := unsetOrganizationField(org, field.Key); err != nil { + return err + } + case string(builder.AddToSet): + if field.Key != "members" { + return merrors.NotImplemented("unsupported $addToSet field: " + field.Key) + } + member, ok := field.Value.(bson.ObjectID) + if !ok { + return merrors.InvalidDataType("expected member object id") + } + if !containsObjectID(org.Members, member) { + org.Members = append(org.Members, member) + } + default: + return merrors.NotImplemented("unsupported patch operation: " + op.Key) + } + } + } + return nil +} + +func setOrganizationField(org *model.Organization, field string, value any) error { + switch field { + case storable.UpdatedAtField: + if updatedAt, ok := value.(time.Time); ok { + org.UpdatedAt = updatedAt + } + case model.OrganizationSignupStateField: + switch state := value.(type) { + case model.OrganizationSignupState: + org.SignupState = state + case string: + org.SignupState = model.OrganizationSignupState(state) + default: + return merrors.InvalidDataType("expected signup state value") + } + case model.OrganizationProvisioningLeaseOwnerField: + owner, ok := value.(string) + if !ok || org.Provisioning == nil { + return merrors.InvalidDataType("expected lease owner on a provisioned organization") + } + org.Provisioning.LeaseOwner = owner + case model.OrganizationProvisioningLeaseField: + expiresAt, ok := value.(time.Time) + if !ok || org.Provisioning == nil { + return merrors.InvalidDataType("expected lease expiry on a provisioned organization") + } + org.Provisioning.LeaseExpiresAt = expiresAt + default: + return merrors.NotImplemented("unsupported $set field: " + field) + } + return nil +} + +func unsetOrganizationField(org *model.Organization, field string) error { + switch field { + case model.OrganizationProvisioningField: + org.Provisioning = nil + case model.OrganizationProvisioningLeaseOwnerField: + if org.Provisioning != nil { + org.Provisioning.LeaseOwner = "" + } + case model.OrganizationProvisioningLeaseField: + if org.Provisioning != nil { + org.Provisioning.LeaseExpiresAt = time.Time{} + } + default: + return merrors.NotImplemented("unsupported $unset field: " + field) + } + return nil +} + +func containsObjectID(ids []bson.ObjectID, id bson.ObjectID) bool { + for _, item := range ids { + if item == id { + return true + } + } + return false +} + +func (m *memoryOrganizationRepository) DeleteMany(context.Context, builder.Query) error { + return merrors.NotImplemented("DeleteMany is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) CreateIndex(*ri.Definition) error { + return nil +} + +func (m *memoryOrganizationRepository) DropIndex(context.Context, string) error { + return nil +} + +func (m *memoryOrganizationRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { + return nil, merrors.NotImplemented("ListIndexDefinitions is not supported in memory repository") +} + +func (m *memoryOrganizationRepository) ListIDs(_ context.Context, query builder.Query) ([]bson.ObjectID, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var ids []bson.ObjectID + for _, id := range m.order { + if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, merrors.ErrNoData + } + return ids, nil +} + +func (m *memoryOrganizationRepository) ListPermissionBound(_ context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var res []model.PermissionBoundStorable + for _, org := range m.data { + if m.matchesQuery(query, org) { + res = append(res, cloneOrganization(org)) + } + } + return res, nil +} + +func (m *memoryOrganizationRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { + return nil, merrors.NotImplemented("Account bound list not supported") +} + +func (m *memoryOrganizationRepository) matchesQuery(query builder.Query, org *model.Organization) bool { + if query == nil { + return true + } + for _, elem := range query.BuildQuery() { + switch elem.Key { + case storable.IDField: + if !matchesObjectIDConstraint(*org.GetID(), elem.Value) { + return false + } + case storable.IsArchivedField: + value, ok := elem.Value.(bool) + if ok && org.IsArchived() != value { + return false + } + case model.OrganizationSignupStateField: + if !matchesStringConstraint(string(org.SignupState), elem.Value) { + return false + } + case model.NameField: + if !matchesStringConstraint(org.Name, elem.Value) { + return false + } + case model.OrganizationProvisioningOwnerRefField: + if org.Provisioning == nil { + return false + } + if exists, ok := existsConstraint(elem.Value); ok { + if !exists { + return false + } + continue + } + if !matchesObjectIDConstraint(org.Provisioning.OwnerRef, elem.Value) { + return false + } + case model.OrganizationProvisioningNameField: + if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.Name, elem.Value) { + return false + } + case model.OrganizationProvisioningLeaseOwnerField: + if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.LeaseOwner, elem.Value) { + return false + } + case "members": + if !matchesMembersConstraint(org.Members, elem.Value) { + return false + } + } + } + return true +} + +func matchesStringConstraint(current string, value any) bool { + switch expected := value.(type) { + case string: + return current == expected + case model.OrganizationSignupState: + return current == string(expected) + default: + return false + } +} + +func existsConstraint(value any) (bool, bool) { + ops, ok := value.(bson.M) + if !ok { + return false, false + } + exists, ok := ops[string(builder.Exists)].(bool) + return exists, ok +} + +func matchesMembersConstraint(members []bson.ObjectID, value any) bool { + ops, ok := value.(bson.M) + if !ok { + return false + } + for op, raw := range ops { + member, ok := raw.(bson.ObjectID) + if !ok { + return false + } + switch op { + case string(builder.Eq): + if !containsObjectID(members, member) { + return false + } + case string(builder.Ne): + if containsObjectID(members, member) { + return false + } + default: + return false + } + } + return true +} + +func matchesObjectIDConstraint(id bson.ObjectID, value any) bool { + if direct, ok := value.(bson.ObjectID); ok { + return id == direct + } + ops, ok := value.(bson.M) + if !ok { + return false + } + for op, raw := range ops { + ids, ok := raw.([]any) + if !ok { + return false + } + contains := false + for _, item := range ids { + if itemID, ok := item.(bson.ObjectID); ok && itemID == id { + contains = true + break + } + } + switch op { + case string(builder.In): + if !contains { + return false + } + case string(builder.NotIn): + if contains { + return false + } + default: + return false + } + } + return true +} diff --git a/backend/edge/shared/storage/organization/memory_repository_test.go b/backend/edge/shared/storage/organization/memory_repository_test.go new file mode 100644 index 0000000..f7576c3 --- /dev/null +++ b/backend/edge/shared/storage/organization/memory_repository_test.go @@ -0,0 +1,149 @@ +package organization + +import ( + "context" + "errors" + "sync" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type memoryOrganizationRepository struct { + mu sync.RWMutex + data map[bson.ObjectID]*model.Organization + order []bson.ObjectID +} + +func newMemoryOrganizationRepository() *memoryOrganizationRepository { + return &memoryOrganizationRepository{ + data: make(map[bson.ObjectID]*model.Organization), + } +} + +func (m *memoryOrganizationRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { + m.mu.Lock() + defer m.mu.Unlock() + + org, ok := obj.(*model.Organization) + if !ok { + return merrors.InvalidDataType("expected organization") + } + id := org.GetID() + if id == nil || *id == bson.NilObjectID { + return merrors.InvalidArgument("organization ID must be set") + } + if _, exists := m.data[*id]; exists { + return merrors.DataConflict("organization already exists") + } + m.data[*id] = cloneOrganization(org) + m.order = append(m.order, *id) + return nil +} + +func (m *memoryOrganizationRepository) InsertMany(ctx context.Context, objects []storable.Storable) error { + for _, obj := range objects { + if err := m.Insert(ctx, obj, nil); err != nil { + return err + } + } + return nil +} + +func (m *memoryOrganizationRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { + m.mu.RLock() + defer m.mu.RUnlock() + + org, ok := m.data[id] + if !ok { + return merrors.ErrNoData + } + dst, ok := result.(*model.Organization) + if !ok { + return merrors.InvalidDataType("expected organization result") + } + *dst = *cloneOrganization(org) + return nil +} + +func (m *memoryOrganizationRepository) Update(_ context.Context, obj storable.Storable) error { + m.mu.Lock() + defer m.mu.Unlock() + + org, ok := obj.(*model.Organization) + if !ok { + return merrors.InvalidDataType("expected organization") + } + id := org.GetID() + if id == nil { + return merrors.InvalidArgument("organization ID must be set") + } + if _, exists := m.data[*id]; !exists { + return merrors.ErrNoData + } + m.data[*id] = cloneOrganization(org) + return nil +} + +func (m *memoryOrganizationRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + org, ok := obj.(*model.Organization) + if !ok { + return false, merrors.InvalidDataType("expected organization") + } + id := org.GetID() + if id == nil { + return false, merrors.InvalidArgument("organization ID must be set") + } + + current, exists := m.data[*id] + if !exists || !m.matchesQuery(query, current) { + return false, nil + } + + m.data[*id] = cloneOrganization(org) + return true, nil +} + +func (m *memoryOrganizationRepository) Upsert(ctx context.Context, obj storable.Storable) error { + if err := m.Update(ctx, obj); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return m.Insert(ctx, obj, nil) + } + return err + } + return nil +} + +func (m *memoryOrganizationRepository) Delete(_ context.Context, id bson.ObjectID) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.data[id]; !exists { + return merrors.ErrNoData + } + delete(m.data, id) + return nil +} + +func (m *memoryOrganizationRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + return cb(ctx) +} + +func (m *memoryOrganizationRepository) Collection() string { + return mservice.Organizations +} + +func cloneOrganization(src *model.Organization) *model.Organization { + dst := *src + if len(src.Members) > 0 { + dst.Members = append([]bson.ObjectID{}, src.Members...) + } + return &dst +} diff --git a/backend/edge/shared/storage/organization/organization.go b/backend/edge/shared/storage/organization/organization.go new file mode 100644 index 0000000..401121f --- /dev/null +++ b/backend/edge/shared/storage/organization/organization.go @@ -0,0 +1,19 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// DB is the interface which must be implemented by all db drivers +type DB interface { + auth.ProtectedDB[*model.Organization] + List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) + ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) + SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error + IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) + AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) +} diff --git a/backend/edge/shared/storage/organization/owned.go b/backend/edge/shared/storage/organization/owned.go new file mode 100644 index 0000000..01a36a6 --- /dev/null +++ b/backend/edge/shared/storage/organization/owned.go @@ -0,0 +1,14 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *OrganizationDB) ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) { + return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, repository.Filter("ownerRef", accountRef), nil, db.DBImp.Repository) +} diff --git a/backend/edge/shared/storage/organization/provider.go b/backend/edge/shared/storage/organization/provider.go new file mode 100644 index 0000000..25eaf99 --- /dev/null +++ b/backend/edge/shared/storage/organization/provider.go @@ -0,0 +1,14 @@ +package organization + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +func (db *OrganizationDB) ListProvider(ctx context.Context, query builder.Query, cursor *model.ViewCursor) ([]model.Organization, error) { + orgs := make([]model.Organization, 0) + return orgs, db.DBImp.Repository.FindAllByFilter(ctx, repository.ApplyCursor(query, cursor), &orgs) +} diff --git a/backend/edge/shared/storage/organization/provisioning.go b/backend/edge/shared/storage/organization/provisioning.go new file mode 100644 index 0000000..ac02647 --- /dev/null +++ b/backend/edge/shared/storage/organization/provisioning.go @@ -0,0 +1,166 @@ +package organization + +import ( + "context" + "errors" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// AcquireProvisioningLease claims an organization that is still being +// provisioned. The update is atomic: it only matches a pending organization +// that carries a provisioning record and whose lease has expired or is already +// owned by this attempt, so two concurrent attempts cannot provision the same +// organization. Requiring the pending state here (not only in the caller's +// earlier read) closes the race with a concurrent finalization: an organization +// that just went active stops matching even though its lease fields are gone. +func (db *OrganizationDB) AcquireProvisioningLease( + ctx context.Context, + organizationRef bson.ObjectID, + leaseOwner string, + now time.Time, + expiresAt time.Time, +) (bool, error) { + // A freshly created attempt carries no lease yet, so "missing" counts as free + // alongside "expired"; otherwise the first attempt would block itself. + unset := repository.Exists(repository.Field(model.OrganizationProvisioningLeaseField), false) + expired := repository.Query().Comparison(repository.Field(model.OrganizationProvisioningLeaseField), builder.Lte, now) + mine := repository.Query().Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) + filter := repository.Query(). + Filter(repository.IDField(), organizationRef). + Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). + Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true). + Or(unset, expired, mine) + + patch := repository.Patch(). + Set(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner). + Set(repository.Field(model.OrganizationProvisioningLeaseField), expiresAt) + + matched, err := db.DBImp.PatchMany(ctx, filter, patch) + if err != nil { + db.DBImp.Logger.Warn("Failed to acquire organization provisioning lease", + zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) + return false, err + } + return matched > 0, nil +} + +// ReleaseProvisioningLease frees the lease so another attempt can take over +// immediately instead of waiting out the expiry. It only matches the lease of +// the given owner: a lease taken over by a newer attempt stays untouched. +func (db *OrganizationDB) ReleaseProvisioningLease( + ctx context.Context, + organizationRef bson.ObjectID, + leaseOwner string, +) error { + filter := repository.Query(). + Filter(repository.IDField(), organizationRef). + Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) + + patch := repository.Patch(). + Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)). + Unset(repository.Field(model.OrganizationProvisioningLeaseField)) + + if _, err := db.DBImp.PatchMany(ctx, filter, patch); err != nil { + db.DBImp.Logger.Warn("Failed to release organization provisioning lease", + zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) + return err + } + return nil +} + +// FinalizeProvisioning atomically activates a pending organization while the +// given attempt still owns its provisioning lease. It reports false when the +// organization is no longer pending or the lease was taken over, so a slow +// attempt cannot finalize an organization another attempt now works on. +// Only the lease is cleared: the provisioning provenance stays on the document +// so a retry after a lost success response can recognize the activated +// organization instead of creating a duplicate. +func (db *OrganizationDB) FinalizeProvisioning( + ctx context.Context, + organizationRef bson.ObjectID, + leaseOwner string, +) (bool, error) { + filter := repository.Query(). + Filter(repository.IDField(), organizationRef). + Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). + Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) + + patch := repository.Patch(). + Set(repository.Field(model.OrganizationSignupStateField), model.OrganizationSignupStateActive). + Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)). + Unset(repository.Field(model.OrganizationProvisioningLeaseField)) + + matched, err := db.DBImp.PatchMany(ctx, filter, patch) + if err != nil { + db.DBImp.Logger.Warn("Failed to finalize organization provisioning", + zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) + return false, err + } + return matched > 0, nil +} + +// FindPendingProvisioning returns the organization that a previous attempt left +// pending for the same owner and name, if any. The name matches the immutable +// provisioning snapshot, not the mutable display name, so a rename cannot +// detach the organization from the retries of the create that produced it. +func (db *OrganizationDB) FindPendingProvisioning( + ctx context.Context, + ownerRef bson.ObjectID, + name string, +) (*model.Organization, error) { + query := repository.Query(). + Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef). + Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). + Filter(repository.Field(model.OrganizationProvisioningNameField), name) + + org := &model.Organization{} + if err := db.DBImp.FindOne(ctx, query, org); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + db.DBImp.Logger.Warn("Failed to look up pending organization provisioning", + zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name)) + return nil, err + } + return org, nil +} + +// FindActiveProvisioned returns the already activated organization a previous +// attempt provisioned for the same owner and name, if any. It is the retry +// barrier for the success that got lost on the wire: without it a repeated +// create after a timed out but committed finalization would provision a +// duplicate organization. The name matches the immutable provisioning +// snapshot, so the barrier survives a rename of the activated organization. +func (db *OrganizationDB) FindActiveProvisioned( + ctx context.Context, + ownerRef bson.ObjectID, + name string, +) (*model.Organization, error) { + // Archived organizations release their name: they are excluded here and in + // the unique index alike, so the checks and the constraint stay in step. + query := repository.Query(). + Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef). + Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStateActive)). + Filter(repository.Field(model.OrganizationProvisioningNameField), name). + Filter(repository.Field(storable.IsArchivedField), false) + + org := &model.Organization{} + if err := db.DBImp.FindOne(ctx, query, org); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + db.DBImp.Logger.Warn("Failed to look up activated organization provisioning", + zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name)) + return nil, err + } + return org, nil +} diff --git a/backend/edge/shared/storage/organization/provisioning_test.go b/backend/edge/shared/storage/organization/provisioning_test.go new file mode 100644 index 0000000..e1adc6d --- /dev/null +++ b/backend/edge/shared/storage/organization/provisioning_test.go @@ -0,0 +1,180 @@ +package organization + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func provisionedOrganization(t *testing.T, db *OrganizationDB, ownerRef bson.ObjectID, name, leaseOwner string) *model.Organization { + t.Helper() + + org := &model.Organization{Members: []bson.ObjectID{}} + org.SetID(bson.NewObjectID()) + org.Name = name + org.SignupState = model.OrganizationSignupStatePending + org.Provisioning = &model.OrganizationProvisioning{ + OwnerRef: ownerRef, + Name: name, + Origin: "backoffice", + StartedAt: time.Now().UTC(), + LeaseOwner: leaseOwner, + LeaseExpiresAt: time.Now().UTC().Add(10 * time.Minute), + } + if err := db.Unprotected().Create(context.Background(), org); err != nil { + t.Fatalf("failed to store organization: %v", err) + } + return org +} + +func TestAddMemberIsAPointPatch(t *testing.T) { + db := newTestOrganizationDB(t) + ownerRef := bson.NewObjectID() + org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") + memberRef := bson.NewObjectID() + + added, err := db.AddMember(context.Background(), org.ID, memberRef) + if err != nil || !added { + t.Fatalf("first add must report success: added=%v err=%v", added, err) + } + // The second add must be a no-op, not an error: transaction replays and + // concurrent joins both hit this path. + added, err = db.AddMember(context.Background(), org.ID, memberRef) + if err != nil || added { + t.Fatalf("second add must report an existing member: added=%v err=%v", added, err) + } + + stored := &model.Organization{} + if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { + t.Fatalf("failed to read organization back: %v", err) + } + if len(stored.Members) != 1 || stored.Members[0] != memberRef { + t.Fatalf("members mismatch: got=%v", stored.Members) + } + // The membership write must not touch the provisioning lease: the fence in + // FinalizeProvisioning checks the stored lease, not this attempt's copy. + if stored.Provisioning == nil || stored.Provisioning.LeaseOwner != "operator/attempt-1" { + t.Fatalf("membership write clobbered the provisioning lease: %+v", stored.Provisioning) + } +} + +func TestFinalizeProvisioningKeepsProvenance(t *testing.T) { + db := newTestOrganizationDB(t) + ownerRef := bson.NewObjectID() + org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") + + finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") + if err != nil || !finalized { + t.Fatalf("finalization by the lease owner must succeed: finalized=%v err=%v", finalized, err) + } + + stored := &model.Organization{} + if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { + t.Fatalf("failed to read organization back: %v", err) + } + if stored.SignupState != model.OrganizationSignupStateActive { + t.Fatalf("organization must be active, got=%s", stored.SignupState) + } + // Provenance survives activation so a retry after a lost success response + // can recognize the organization instead of provisioning a duplicate. + if stored.Provisioning == nil || stored.Provisioning.OwnerRef != ownerRef { + t.Fatalf("provisioning provenance must survive activation: %+v", stored.Provisioning) + } + if stored.Provisioning.LeaseOwner != "" || !stored.Provisioning.LeaseExpiresAt.IsZero() { + t.Fatalf("lease must be cleared on activation: %+v", stored.Provisioning) + } + + // A replayed finalization must not match: the organization left pending. + finalized, err = db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") + if err != nil || finalized { + t.Fatalf("an active organization must not finalize again: finalized=%v err=%v", finalized, err) + } +} + +func TestFinalizeProvisioningRejectsTakenOverLease(t *testing.T) { + db := newTestOrganizationDB(t) + org := provisionedOrganization(t, db, bson.NewObjectID(), "ProCorp", "operator/attempt-2") + + finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") + if err != nil || finalized { + t.Fatalf("an attempt whose lease was taken over must not finalize: finalized=%v err=%v", finalized, err) + } +} + +func TestFindActiveProvisioned(t *testing.T) { + db := newTestOrganizationDB(t) + ownerRef := bson.NewObjectID() + org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") + + // Pending organizations belong to FindPendingProvisioning, not here. + found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") + if err != nil || found != nil { + t.Fatalf("a pending organization must not be reported as activated: found=%v err=%v", found, err) + } + + if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { + t.Fatalf("failed to finalize: %v", err) + } + + found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") + if err != nil || found == nil || found.ID != org.ID { + t.Fatalf("the activated organization must be found: found=%v err=%v", found, err) + } + if found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "Other"); err != nil || found != nil { + t.Fatalf("a different name must not match: found=%v err=%v", found, err) + } +} + +// The dedup key is the provisioning name snapshot: renaming an activated +// organization must not detach it from a retry of the create that produced it, +// or the retry would provision a duplicate. +func TestFindActiveProvisionedSurvivesRename(t *testing.T) { + db := newTestOrganizationDB(t) + ownerRef := bson.NewObjectID() + org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") + if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { + t.Fatalf("failed to finalize: %v", err) + } + + stored := &model.Organization{} + if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { + t.Fatalf("failed to read organization back: %v", err) + } + stored.Name = "Renamed Corp" + if err := db.Unprotected().Update(context.Background(), stored); err != nil { + t.Fatalf("failed to rename organization: %v", err) + } + + found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") + if err != nil || found == nil || found.ID != org.ID { + t.Fatalf("the renamed organization must still match its provisioning name: found=%v err=%v", found, err) + } +} + +// Archiving is how an operator retires a name: an archived organization must +// stop blocking the creation of a fresh one with the same owner and name. +func TestFindActiveProvisionedReleasesArchivedName(t *testing.T) { + db := newTestOrganizationDB(t) + ownerRef := bson.NewObjectID() + org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") + if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { + t.Fatalf("failed to finalize: %v", err) + } + + stored := &model.Organization{} + if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { + t.Fatalf("failed to read organization back: %v", err) + } + stored.Archived = true + if err := db.Unprotected().Update(context.Background(), stored); err != nil { + t.Fatalf("failed to archive organization: %v", err) + } + + found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") + if err != nil || found != nil { + t.Fatalf("an archived organization must release its name: found=%v err=%v", found, err) + } +} diff --git a/backend/edge/shared/storage/organization/setarchived_test.go b/backend/edge/shared/storage/organization/setarchived_test.go new file mode 100644 index 0000000..dc4e9b4 --- /dev/null +++ b/backend/edge/shared/storage/organization/setarchived_test.go @@ -0,0 +1,51 @@ +package organization + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestOrganizationDB_SetArchived_TogglesState(t *testing.T) { + ctx := context.Background() + accountRef := bson.NewObjectID() + orgDB := newTestOrganizationDB(t) + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Sendico"}, + TimeZone: "UTC", + }, + } + org.SetID(bson.NewObjectID()) + + require.NoError(t, orgDB.Create(ctx, accountRef, *org.GetID(), org)) + + var stored model.Organization + require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) + assert.False(t, stored.IsArchived()) + + require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), true, false)) + require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) + assert.True(t, stored.IsArchived()) + + require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), false, false)) + require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) + assert.False(t, stored.IsArchived()) +} + +func TestOrganizationDB_SetArchived_UnknownOrganization(t *testing.T) { + ctx := context.Background() + accountRef := bson.NewObjectID() + orgDB := newTestOrganizationDB(t) + + err := orgDB.SetArchived(ctx, accountRef, bson.NewObjectID(), true, false) + require.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) +} diff --git a/backend/edge/shared/storage/organization/test_helpers_test.go b/backend/edge/shared/storage/organization/test_helpers_test.go new file mode 100644 index 0000000..2d256e0 --- /dev/null +++ b/backend/edge/shared/storage/organization/test_helpers_test.go @@ -0,0 +1,58 @@ +package organization + +import ( + "context" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// newTestOrganizationDB wires the real OrganizationDB implementation to an in-memory repository. +func newTestOrganizationDB(t *testing.T) *OrganizationDB { + t.Helper() + + repo := newMemoryOrganizationRepository() + dbImp := &template.DBImp[*model.Organization]{ + Logger: zap.NewNop(), + Repository: repo, + } + dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error { + return repo.Delete(ctx, objectRef) + }) + + return &OrganizationDB{ + ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{ + DBImp: dbImp, + Enforcer: allowAllEnforcer{}, + PermissionRef: bson.NewObjectID(), + Collection: mservice.Organizations, + }, + } +} + +type allowAllEnforcer struct{} + +func (allowAllEnforcer) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) { + return true, nil +} + +func (allowAllEnforcer) EnforceBatch(_ context.Context, objects []model.PermissionBoundStorable, _ bson.ObjectID, _ model.Action) (map[bson.ObjectID]bool, error) { + result := make(map[bson.ObjectID]bool, len(objects)) + for _, obj := range objects { + result[*obj.GetID()] = true + } + return result, nil +} + +func (allowAllEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { + return nil, nil +} + +func (allowAllEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { + return nil, nil, nil +} diff --git a/backend/edge/shared/storage/ratelimits/db.go b/backend/edge/shared/storage/ratelimits/db.go new file mode 100644 index 0000000..cbc9995 --- /dev/null +++ b/backend/edge/shared/storage/ratelimits/db.go @@ -0,0 +1,184 @@ +package ratelimits + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + defaultCollection = "rate_limits" + + fieldScopeKind = "scopeKind" + fieldScopeHash = "scopeHash" + fieldWindowStart = "windowStart" + fieldCount = "count" + fieldExpiresAt = "expiresAt" +) + +// Store provides atomic, fixed-window abuse accounting shared by every BFF +// instance connected to the same database. +type Store interface { + Current(context.Context, string, string, time.Time) (int, error) + Increment(context.Context, string, string, time.Time, time.Time) (int, error) +} + +func HashScope(kind, value string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(kind) + "\x00" + strings.TrimSpace(value))) + return hex.EncodeToString(sum[:]) +} + +type record struct { + storable.Base `bson:",inline"` + ScopeKind string `bson:"scopeKind"` + ScopeHash string `bson:"scopeHash"` + WindowStart time.Time `bson:"windowStart"` + Count int `bson:"count"` + ExpiresAt time.Time `bson:"expiresAt"` +} + +func (r *record) Collection() string { return defaultCollection } + +type DB struct { + repo repository.Repository + logger mlogger.Logger +} + +func Create(logger mlogger.Logger, database *mongo.Database, collection string) (*DB, error) { + if database == nil { + return nil, merrors.InvalidArgument("database is required", "database") + } + collection = strings.TrimSpace(collection) + if collection == "" { + collection = defaultCollection + } + db := &DB{ + repo: repository.CreateMongoRepository(database, collection), + logger: logger.Named("rate_limits"), + } + if err := db.ensureIndexes(); err != nil { + db.logger.Error("Failed to create rate-limit indexes", zap.Error(err), zap.String("collection", collection)) + return nil, err + } + return db, nil +} + +func (db *DB) Current( + ctx context.Context, + scopeKind, scopeHash string, + windowStart time.Time, +) (int, error) { + filter, err := scopeFilter(scopeKind, scopeHash, windowStart) + if err != nil { + return 0, err + } + current := &record{} + if err := db.repo.FindOneByFilter(ctx, filter, current); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return 0, nil + } + return 0, err + } + return current.Count, nil +} + +func (db *DB) Increment( + ctx context.Context, + scopeKind, scopeHash string, + windowStart, expiresAt time.Time, +) (int, error) { + filter, err := scopeFilter(scopeKind, scopeHash, windowStart) + if err != nil { + return 0, err + } + if expiresAt.IsZero() || !expiresAt.After(windowStart) { + return 0, merrors.InvalidArgument("expiry must be after window start", "expiresAt") + } + + current := &record{} + if err := db.repo.FindOneByFilter(ctx, filter, current); err == nil { + updated := &record{} + matched, patchErr := db.repo.PatchOneByFilter(ctx, filter, repository.Patch(). + Inc(repository.Field(fieldCount), 1). + Set(repository.Field(fieldExpiresAt), expiresAt.UTC()), updated) + if patchErr != nil { + return 0, patchErr + } + if !matched { + return db.Increment(ctx, scopeKind, scopeHash, windowStart, expiresAt) + } + return updated.Count, nil + } else if !errors.Is(err, merrors.ErrNoData) { + return 0, err + } + + created := &record{ + ScopeKind: strings.TrimSpace(scopeKind), + ScopeHash: strings.TrimSpace(scopeHash), + WindowStart: windowStart.UTC(), + Count: 1, + ExpiresAt: expiresAt.UTC(), + } + if err := db.repo.Insert(ctx, created, nil); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return db.Increment(ctx, scopeKind, scopeHash, windowStart, expiresAt) + } + return 0, err + } + return created.Count, nil +} + +func (db *DB) ensureIndexes() error { + ttl := int32(0) + indexes := []*ri.Definition{ + { + Keys: []ri.Key{ + {Field: fieldScopeKind, Sort: ri.Asc}, + {Field: fieldScopeHash, Sort: ri.Asc}, + {Field: fieldWindowStart, Sort: ri.Asc}, + }, + Unique: true, + Name: "unique_rate_limit_window", + }, + { + Keys: []ri.Key{{Field: fieldExpiresAt, Sort: ri.Asc}}, + TTL: &ttl, + Name: "ttl_rate_limit_expiry", + }, + } + for _, index := range indexes { + if err := db.repo.CreateIndex(index); err != nil { + return err + } + } + return nil +} + +func scopeFilter(scopeKind, scopeHash string, windowStart time.Time) (repository.FilterQuery, error) { + scopeKind = strings.TrimSpace(scopeKind) + if scopeKind == "" { + return nil, merrors.InvalidArgument("scope kind is required", "scopeKind") + } + scopeHash = strings.TrimSpace(scopeHash) + if scopeHash == "" { + return nil, merrors.InvalidArgument("scope hash is required", "scopeHash") + } + if windowStart.IsZero() { + return nil, merrors.InvalidArgument("window start is required", "windowStart") + } + return repository.Query(). + Filter(repository.Field(fieldScopeKind), scopeKind). + Filter(repository.Field(fieldScopeHash), scopeHash). + Filter(repository.Field(fieldWindowStart), windowStart.UTC()), nil +} diff --git a/backend/edge/shared/storage/refreshtokens/client.go b/backend/edge/shared/storage/refreshtokens/client.go new file mode 100644 index 0000000..844d327 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/client.go @@ -0,0 +1,12 @@ +package refreshtokens + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { + var client model.Client + return &client, db.clients.FindOneByFilter(ctx, filterByClientId(clientID), &client) +} diff --git a/backend/edge/shared/storage/refreshtokens/crud.go b/backend/edge/shared/storage/refreshtokens/crud.go new file mode 100644 index 0000000..86ce4f4 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/crud.go @@ -0,0 +1,189 @@ +package refreshtokens + +import ( + "context" + "errors" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (db *RefreshTokenDB) Create(ctx context.Context, rt *model.RefreshToken) error { + if rt.LastUsedAt.IsZero() { + rt.LastUsedAt = time.Now() + } + + if rt.AccountRef == nil { + return merrors.InvalidArgument("Account reference must have a valid value", "refreshToken.accountRef") + } + + matched, err := db.replaceActive(ctx, rt) + if err != nil { + return err + } + if matched { + return nil + } + + db.Logger.Debug("Registering refresh token", zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID)) + if err := db.DBImp.Create(ctx, rt); err != nil { + if !errors.Is(err, merrors.ErrDataConflict) { + return err + } + + // Another login can establish the same active slot after the patch and + // before this insert. Converge on that row instead of returning the + // unique-index conflict to the caller. + db.Logger.Debug("Concurrent refresh session registration detected", + zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID)) + matched, reconcileErr := db.replaceActive(ctx, rt) + if reconcileErr != nil { + return reconcileErr + } + if !matched { + return err + } + } + return nil +} + +func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *model.RefreshToken) (bool, error) { + patch := repository.Patch(). + Set(repository.Field(TokenField), rt.RefreshToken). + Set(repository.Field(ExpiresAtField), rt.ExpiresAt). + Set(repository.Field(UserAgentField), rt.UserAgent). + Set(repository.Field(IPAddressField), rt.IPAddress). + Set(repository.Field(LastUsedAtField), rt.LastUsedAt). + Set(repository.Field(IsRevokedField), rt.IsRevoked). + Set(repository.Field(SessionVersionField), rt.SessionVersion) + + var stored model.RefreshToken + matched, err := db.Repository.PatchOneByFilter( + ctx, + filterSlotByAccount(*rt.AccountRef, &rt.SessionIdentifier), + patch, + &stored, + ) + if err != nil { + db.Logger.Warn("Failed to replace active refresh session", zap.Error(err), + zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID)) + return false, err + } + if !matched { + return false, nil + } + + db.Logger.Debug("Replaced active refresh session", + zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID)) + rt.SetID(*stored.GetID()) + return true, nil +} + +func (db *RefreshTokenDB) Update(ctx context.Context, rt *model.RefreshToken) error { + rt.LastUsedAt = time.Now() + + // Use Patch instead of Update to avoid race conditions + patch := repository.Patch(). + Set(repository.Field(TokenField), rt.RefreshToken). + Set(repository.Field(ExpiresAtField), rt.ExpiresAt). + Set(repository.Field(UserAgentField), rt.UserAgent). + Set(repository.Field(IPAddressField), rt.IPAddress). + Set(repository.Field(LastUsedAtField), rt.LastUsedAt). + Set(repository.Field(IsRevokedField), rt.IsRevoked). + Set(repository.Field(SessionVersionField), rt.SessionVersion) + + return db.Patch(ctx, *rt.GetID(), patch) +} + +func (db *RefreshTokenDB) Delete(ctx context.Context, tokenRef bson.ObjectID) error { + db.Logger.Debug("Deleting refresh token", mzap.ObjRef("refresh_token_ref", tokenRef)) + return db.DBImp.Delete(ctx, tokenRef) +} + +func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error { + var rt model.RefreshToken + f := filterByAccount(accountRef, session) + if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil { + if errors.Is(err, merrors.ErrNoData) { + db.Logger.Debug("Refresh token not found during revoke", + mzap.AccRef(accountRef), zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) + return nil + } + return err + } + + // Use Patch to update the revocation status + patch := repository.Patch(). + Set(repository.Field(IsRevokedField), true). + Set(repository.Field(LastUsedAtField), time.Now()) + + return db.Patch(ctx, *rt.GetID(), patch) +} + +func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) { + query := filter(&token.SessionIdentifier). + And(repository.Query().Comparison(repository.Field(TokenField), builder.Eq, token.RefreshToken)) + patch := repository.Patch(). + Set(repository.Field(IsRevokedField), true). + Set(repository.Field(LastUsedAtField), time.Now()) + modified, err := db.Repository.PatchMany(ctx, query, patch) + return modified > 0, err +} + +func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) { + var rt model.RefreshToken + if err := db.Repository.FindOneByFilter(ctx, filterByAccount(accountRef, session), &rt); err != nil { + return nil, err + } + if !rt.ExpiresAt.After(time.Now()) { + return nil, merrors.ErrNoData + } + return &rt, nil +} + +func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) { + var rt model.RefreshToken + f := filter(&t.SessionIdentifier).And(repository.Query().Filter(repository.Field("token"), t.RefreshToken)) + if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil { + if !errors.Is(err, merrors.ErrNoData) { + db.Logger.Warn("Failed to fetch refresh token", zap.Error(err), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID)) + } + return nil, err + } + + // Check if token is expired + if rt.ExpiresAt.Before(time.Now()) { + db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID), + zap.Time("expires_at", rt.ExpiresAt)) + return nil, merrors.AccessDenied(mservice.RefreshTokens, string(model.ActionRead), *rt.GetID()) + } + + // Check if token is revoked + if rt.IsRevoked { + db.Logger.Debug("Refresh token is revoked", mzap.StorableRef(&rt), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID)) + return nil, merrors.ErrNoData + } + + if rt.GetID() != nil && !rt.GetID().IsZero() { + lastUsedAt := time.Now() + patch := repository.Patch().Set(repository.Field(LastUsedAtField), lastUsedAt) + if err := db.Patch(ctx, *rt.GetID(), patch); err != nil { + db.Logger.Warn("Failed to update refresh token last usage timestamp", zap.Error(err), + zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID)) + } else { + rt.LastUsedAt = lastUsedAt + } + } + + return &rt, nil +} diff --git a/backend/edge/shared/storage/refreshtokens/crud_test.go b/backend/edge/shared/storage/refreshtokens/crud_test.go new file mode 100644 index 0000000..7a2173a --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/crud_test.go @@ -0,0 +1,162 @@ +package refreshtokens + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type createRepository struct { + repository.Repository + filters []bson.D + patches []bson.D + patchMatches []bool + patchErr error + insertCalls int + insertErr error + storedID bson.ObjectID +} + +func (r *createRepository) PatchOneByFilter( + _ context.Context, + filter builder.Query, + patch builder.Patch, + result storable.Storable, +) (bool, error) { + r.filters = append(r.filters, filter.BuildQuery()) + r.patches = append(r.patches, patch.Build()) + if r.patchErr != nil { + return false, r.patchErr + } + + call := len(r.filters) - 1 + matched := call < len(r.patchMatches) && r.patchMatches[call] + if matched { + result.SetID(r.storedID) + } + return matched, nil +} + +func (r *createRepository) Insert(_ context.Context, object storable.Storable, _ builder.Query) error { + r.insertCalls++ + if object.GetID() == nil || object.GetID().IsZero() { + object.SetID(bson.NewObjectID()) + } + return r.insertErr +} + +func newCreateDB(repo repository.Repository) *RefreshTokenDB { + return &RefreshTokenDB{ + DBImp: template.DBImp[*model.RefreshToken]{ + Logger: zap.NewNop(), + Repository: repo, + }, + } +} + +func versionedToken(accountRef bson.ObjectID, version, value string) *model.RefreshToken { + return &model.RefreshToken{ + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: "web-app", + DeviceID: "browser", + SessionVersion: version, + }, + RefreshToken: value, + }, + AccountRef: &accountRef, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserAgent: "test-agent", + IPAddress: "192.0.2.1", + } +} + +func extendedJSON(t *testing.T, document bson.D) string { + t.Helper() + raw, err := bson.MarshalExtJSON(document, true, false) + require.NoError(t, err) + return string(raw) +} + +func TestCreateReplacesSlotWithoutMatchingNewSessionVersion(t *testing.T) { + storedID := bson.NewObjectID() + repo := &createRepository{ + patchMatches: []bool{true}, + storedID: storedID, + } + db := newCreateDB(repo) + token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token") + + err := db.Create(context.Background(), token) + require.NoError(t, err) + require.Equal(t, storedID, *token.GetID()) + require.Zero(t, repo.insertCalls) + require.Len(t, repo.filters, 1) + + filterJSON := extendedJSON(t, repo.filters[0]) + require.Contains(t, filterJSON, "accountRef") + require.Contains(t, filterJSON, "clientId") + require.Contains(t, filterJSON, "deviceId") + require.Contains(t, filterJSON, "isRevoked") + require.NotContains(t, filterJSON, "sessionVersion") + + patchJSON := extendedJSON(t, repo.patches[0]) + require.Contains(t, patchJSON, "sessionVersion") + require.Contains(t, patchJSON, "version-2") +} + +func TestCreateReconcilesConcurrentSlotInsert(t *testing.T) { + storedID := bson.NewObjectID() + repo := &createRepository{ + patchMatches: []bool{false, true}, + insertErr: merrors.DataConflict("duplicate_key"), + storedID: storedID, + } + db := newCreateDB(repo) + token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token") + + err := db.Create(context.Background(), token) + require.NoError(t, err) + require.Equal(t, storedID, *token.GetID()) + require.Equal(t, 1, repo.insertCalls) + require.Len(t, repo.filters, 2) +} + +func TestCreatePreservesUnrelatedInsertConflict(t *testing.T) { + repo := &createRepository{ + patchMatches: []bool{false, false}, + insertErr: merrors.DataConflict("duplicate_key"), + storedID: bson.NewObjectID(), + } + db := newCreateDB(repo) + token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token") + + err := db.Create(context.Background(), token) + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrDataConflict)) + require.Equal(t, 1, repo.insertCalls) + require.Len(t, repo.filters, 2) +} + +func TestCredentialFilterKeepsSessionVersion(t *testing.T) { + query := filterByAccount(bson.NewObjectID(), &model.SessionIdentifier{ + ClientID: "web-app", + DeviceID: "browser", + SessionVersion: "version-2", + }) + + filterJSON := extendedJSON(t, query.BuildQuery()) + require.Contains(t, filterJSON, "sessionVersion") + require.Contains(t, filterJSON, "version-2") +} diff --git a/backend/edge/shared/storage/refreshtokens/db.go b/backend/edge/shared/storage/refreshtokens/db.go new file mode 100644 index 0000000..f6be858 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/db.go @@ -0,0 +1,102 @@ +package refreshtokens + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type Collections struct { + RefreshTokens string + Clients string +} + +type RefreshTokenDB struct { + template.DBImp[*model.RefreshToken] + clients repository.Repository + collection Collections +} + +func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) { + return CreateWithCollections(logger, db, Collections{}) +} + +func (c Collections) WithDefaults() Collections { + if strings.TrimSpace(c.RefreshTokens) == "" { + c.RefreshTokens = mservice.RefreshTokens + } + if strings.TrimSpace(c.Clients) == "" { + c.Clients = mservice.Clients + } + return c +} + +func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) { + collections = collections.WithDefaults() + p := &RefreshTokenDB{ + DBImp: *template.Create[*model.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db), + clients: repository.CreateMongoRepository(db, collections.Clients), + collection: collections, + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "token", Sort: ri.Asc}}, + Unique: true, + }); err != nil { + p.Logger.Error("Failed to create unique token index", zap.Error(err)) + return nil, err + } + + ttl := int32(0) + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: ExpiresAtField, Sort: ri.Asc}}, + Name: "expire_sessions", + TTL: &ttl, + }); err != nil { + p.Logger.Error("Failed to create refresh session expiration index", zap.Error(err)) + return nil, err + } + + // Add unique constraint on account/client/device combination + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{ + {Field: "accountRef", Sort: ri.Asc}, + {Field: "clientId", Sort: ri.Asc}, + {Field: "deviceId", Sort: ri.Asc}, + }, + Unique: true, + Name: "unique_active_session", + PartialFilter: repository.Filter(IsRevokedField, false), + }); err != nil { + p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err)) + return nil, err + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: IsRevokedField, Sort: ri.Asc}}, + }); err != nil { + p.Logger.Error("Failed to create unique token revokation status index", zap.Error(err)) + return nil, err + } + + if err := p.clients.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "clientId", Sort: ri.Asc}}, + Unique: true, + }); err != nil { + p.Logger.Error("Failed to create unique client identifier index", zap.Error(err)) + return nil, err + } + + return p, nil +} + +func (db *RefreshTokenDB) Collections() Collections { + return db.collection +} diff --git a/backend/edge/shared/storage/refreshtokens/fields.go b/backend/edge/shared/storage/refreshtokens/fields.go new file mode 100644 index 0000000..ff7ce10 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/fields.go @@ -0,0 +1,11 @@ +package refreshtokens + +const ( + ExpiresAtField = "expiresAt" + IsRevokedField = "isRevoked" + TokenField = "token" + UserAgentField = "userAgent" + IPAddressField = "ipAddress" + LastUsedAtField = "lastUsedAt" + SessionVersionField = "sessionVersion" +) diff --git a/backend/edge/shared/storage/refreshtokens/filters.go b/backend/edge/shared/storage/refreshtokens/filters.go new file mode 100644 index 0000000..6a04ccf --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/filters.go @@ -0,0 +1,37 @@ +package refreshtokens + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func filterByClientId(clientID string) builder.Query { + return repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID) +} + +func filterSlot(session *model.SessionIdentifier) builder.Query { + filter := filterByClientId(session.ClientID) + filter.And( + repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, session.DeviceID), + repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false), + ) + return filter +} + +func filter(session *model.SessionIdentifier) builder.Query { + filter := filterSlot(session) + if session.SessionVersion != "" { + filter.And(repository.Query().Comparison(repository.Field("sessionVersion"), builder.Eq, session.SessionVersion)) + } + return filter +} + +func filterByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query { + return filter(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef)) +} + +func filterSlotByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query { + return filterSlot(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef)) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens.go b/backend/edge/shared/storage/refreshtokens/refreshtokens.go new file mode 100644 index 0000000..4fc018a --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens.go @@ -0,0 +1,19 @@ +package refreshtokens + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + template.DB[*model.RefreshToken] + Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error + RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) + RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error + GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) + GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) + GetClient(ctx context.Context, clientID string) (*model.Client, error) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go b/backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go new file mode 100644 index 0000000..9ac328b --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go @@ -0,0 +1,92 @@ +//go:build integration +// +build integration + +package refreshtokens_test + +import ( + "context" + "fmt" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Unique_Token_Constraint", func(t *testing.T) { + userID1 := bson.NewObjectID() + userID2 := bson.NewObjectID() + token := "duplicate_token_123" + + refreshToken1 := createTestRefreshToken(userID1, "client1", "device1", token) + err := db.Create(ctx, refreshToken1) + require.NoError(t, err) + + refreshToken2 := createTestRefreshToken(userID2, "client2", "device2", token) + err = db.Create(ctx, refreshToken2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") + }) + + t.Run("Query_Performance_By_Revocation_Status", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + + for i := 0; i < 10; i++ { + token := createTestRefreshToken(userID, clientID, + fmt.Sprintf("device_%d", i), fmt.Sprintf("token_%d", i)) + if i%2 == 0 { + token.IsRevoked = true + } + err := db.Create(ctx, token) + require.NoError(t, err) + } + + query := repository.Query(). + Filter(repository.AccountField(), userID). + And(repository.Query().Comparison(repository.Field(refreshtokens.IsRevokedField), builder.Eq, false)) + + ids, err := db.ListIDs(ctx, query) + require.NoError(t, err) + assert.Len(t, ids, 5) + }) +} + +func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) { + db, database, cleanup := setupTestDBWithMongo(t) + defer cleanup() + + ctx := context.Background() + + cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx) + require.NoError(t, err) + defer cursor.Close(ctx) + + found := false + for cursor.Next(ctx) { + var idx bson.M + require.NoError(t, cursor.Decode(&idx)) + if idx["name"] == "unique_active_session" { + found = true + assert.Equal(t, true, idx["unique"]) + + partialRaw, err := bson.Marshal(idx["partialFilterExpression"]) + require.NoError(t, err) + + var partial bson.M + err = bson.Unmarshal(partialRaw, &partial) + require.NoError(t, err) + assert.Equal(t, bson.M{"isRevoked": false}, partial) + } + } + assert.True(t, found, "unique_active_session index not found") +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go b/backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go new file mode 100644 index 0000000..f822226 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go @@ -0,0 +1,253 @@ +//go:build integration +// +build integration + +package refreshtokens_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestRefreshTokenDB_ClientManagement(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Client_CRUD_Operations", func(t *testing.T) { + // Note: Client management is handled by a separate client database. + // This test verifies that refresh tokens work with different client IDs. + userID := bson.NewObjectID() + + webToken := createTestRefreshToken(userID, "web-app", "device1", "token1") + err := db.Create(ctx, webToken) + require.NoError(t, err) + + mobileToken := createTestRefreshToken(userID, "mobile-app", "device2", "token2") + err = db.Create(ctx, mobileToken) + require.NoError(t, err) + + webCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: "web-app", + DeviceID: "device1", + }, + RefreshToken: "token1", + } + + retrievedToken, err := db.GetByCRT(ctx, webCRT) + require.NoError(t, err) + assert.Equal(t, "web-app", retrievedToken.ClientID) + assert.Equal(t, "device1", retrievedToken.DeviceID) + + mobileCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: "mobile-app", + DeviceID: "device2", + }, + RefreshToken: "token2", + } + + retrievedToken, err = db.GetByCRT(ctx, mobileCRT) + require.NoError(t, err) + assert.Equal(t, "mobile-app", retrievedToken.ClientID) + assert.Equal(t, "device2", retrievedToken.DeviceID) + }) +} + +func TestRefreshTokenDB_SecurityScenarios(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Token_Hijacking_Prevention", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-browser" + token := "hijacked_token_123" + + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + session := &model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + } + err = db.Revoke(ctx, userID, session) + require.NoError(t, err) + + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + _, err = db.GetByCRT(ctx, crt) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Invalid_Token_Attempts", func(t *testing.T) { + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: "invalid-client", + DeviceID: "invalid-device", + }, + RefreshToken: "invalid_token_123", + } + + _, err := db.GetByCRT(ctx, crt) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) +} + +func TestRefreshTokenDB_ExpiredTokenHandling(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Expired_Token_Cleanup", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-device" + token := "expired_token_123" + + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + var storedToken model.RefreshToken + err = db.Get(ctx, *refreshToken.GetID(), &storedToken) + require.NoError(t, err) + assert.True(t, storedToken.ExpiresAt.Before(time.Now())) + + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + _, err = db.GetByCRT(ctx, crt) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrAccessDenied)) + }) +} + +func TestRefreshTokenDB_ConcurrentAccess(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Concurrent_Token_Usage", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-device" + token := "concurrent_token_123" + + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + done := make(chan error, 2) + + go func() { + _, err := db.GetByCRT(ctx, crt) + done <- err + }() + + go func() { + _, err := db.GetByCRT(ctx, crt) + done <- err + }() + + for i := 0; i < 2; i++ { + err := <-done + require.NoError(t, err) + } + }) +} + +func TestRefreshTokenDB_EdgeCases(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Delete_Token_By_ID", func(t *testing.T) { + userID := bson.NewObjectID() + refreshToken := createTestRefreshToken(userID, "web-app", "device-1", "token_123") + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + tokenID := *refreshToken.GetID() + + err = db.Delete(ctx, tokenID) + require.NoError(t, err) + + var result model.RefreshToken + err = db.Get(ctx, tokenID, &result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Revoke_Non_Existent_Token", func(t *testing.T) { + userID := bson.NewObjectID() + session := &model.SessionIdentifier{ + ClientID: "non-existent-client", + DeviceID: "non-existent-device", + } + + err := db.Revoke(ctx, userID, session) + assert.NoError(t, err) + }) + + t.Run("RevokeAll_No_Other_Devices", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "only-device" + + refreshToken := createTestRefreshToken(userID, clientID, deviceID, "token_123") + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + err = db.RevokeAll(ctx, userID, deviceID) + require.NoError(t, err) + + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "token_123", + } + + _, err = db.GetByCRT(ctx, crt) + require.NoError(t, err) + }) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go b/backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go new file mode 100644 index 0000000..0277457 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go @@ -0,0 +1,193 @@ +//go:build integration +// +build integration + +package refreshtokens_test + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestRefreshTokenDB_SessionReplacement(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("User_Login_From_Same_Device_Twice", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-laptop" + + // First login + firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_token_123") + firstToken.SessionVersion = "session-version-1" + err := db.Create(ctx, firstToken) + require.NoError(t, err) + firstTokenID := *firstToken.GetID() + + // Second login from same device - should replace existing token + secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_token_456") + secondToken.SessionVersion = "session-version-2" + err = db.Create(ctx, secondToken) + require.NoError(t, err) + + // Should reuse the same database record + assert.Equal(t, firstTokenID, *secondToken.GetID()) + + // First token should no longer work + firstCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + SessionVersion: "session-version-1", + }, + RefreshToken: "first_token_123", + } + _, err = db.GetByCRT(ctx, firstCRT) + assert.Error(t, err) + + // Second token should work + secondCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + SessionVersion: "session-version-2", + }, + RefreshToken: "second_token_456", + } + _, err = db.GetByCRT(ctx, secondCRT) + require.NoError(t, err) + + secondCRT.SessionVersion = "session-version-1" + _, err = db.GetByCRT(ctx, secondCRT) + assert.Error(t, err) + }) + + t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-laptop" + + firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123") + err := db.Create(ctx, firstToken) + require.NoError(t, err) + require.NotNil(t, firstToken.GetID()) + + // Global revoke (deviceID empty) - all tokens should be revoked + err = db.RevokeAll(ctx, userID, "") + require.NoError(t, err) + + var revoked model.RefreshToken + err = db.Get(ctx, *firstToken.GetID(), &revoked) + require.NoError(t, err) + assert.True(t, revoked.IsRevoked) + + // Creating a new token for the same account/client/device must succeed and produce an active token + reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation") + err = db.Create(ctx, reissueToken) + require.NoError(t, err) + + newCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "new_token_after_revocation", + } + _, err = db.GetByCRT(ctx, newCRT) + require.NoError(t, err) + + // Old token must remain unusable + oldCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "revoked_token_123", + } + _, err = db.GetByCRT(ctx, oldCRT) + assert.Error(t, err) + + // Both records exist: revoked + new active + query := repository.Query(). + Filter(repository.AccountField(), userID). + And( + repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID), + repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID), + ) + ids, err := db.Repository.ListIDs(ctx, query) + require.NoError(t, err) + assert.Len(t, ids, 2) + }) + + t.Run("User_Can_Login_Again_After_Session_Revoke", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-laptop" + + firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_login_token") + err := db.Create(ctx, firstToken) + require.NoError(t, err) + require.NotNil(t, firstToken.GetID()) + firstTokenID := *firstToken.GetID() + + session := &model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + } + err = db.Revoke(ctx, userID, session) + require.NoError(t, err) + + var revoked model.RefreshToken + err = db.Get(ctx, firstTokenID, &revoked) + require.NoError(t, err) + assert.True(t, revoked.IsRevoked) + + secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_login_token") + err = db.Create(ctx, secondToken) + require.NoError(t, err) + require.NotNil(t, secondToken.GetID()) + assert.NotEqual(t, firstTokenID, *secondToken.GetID()) + + firstCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "first_login_token", + } + _, err = db.GetByCRT(ctx, firstCRT) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + + secondCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "second_login_token", + } + _, err = db.GetByCRT(ctx, secondCRT) + require.NoError(t, err) + + query := repository.Query(). + Filter(repository.AccountField(), userID). + And( + repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID), + repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID), + ) + ids, err := db.Repository.ListIDs(ctx, query) + require.NoError(t, err) + assert.Len(t, ids, 2) + }) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go b/backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go new file mode 100644 index 0000000..607baf9 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go @@ -0,0 +1,148 @@ +//go:build integration +// +build integration + +package refreshtokens_test + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestRefreshTokenDB_TokenRotation(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Token_Rotation_On_Use", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-browser" + initialToken := "initial_token_123" + + // Create initial token + refreshToken := createTestRefreshToken(userID, clientID, deviceID, initialToken) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + // Simulate small delay + time.Sleep(10 * time.Millisecond) + + // Use token - should update LastUsedAt + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: initialToken, + } + + retrievedToken, err := db.GetByCRT(ctx, crt) + require.NoError(t, err) + // Token lookup should preserve data and advance LastUsedAt. + assert.Equal(t, initialToken, retrievedToken.RefreshToken) + + // Create new token with rotated value (simulating token rotation) + newToken := "rotated_token_456" + retrievedToken.RefreshToken = newToken + err = db.Update(ctx, retrievedToken) + require.NoError(t, err) + + // Old token should no longer work + _, err = db.GetByCRT(ctx, crt) + assert.Error(t, err) + + // New token should work + newCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: newToken, + } + _, err = db.GetByCRT(ctx, newCRT) + require.NoError(t, err) + }) + + t.Run("Create_Defaults_LastUsedAt_When_Replacing_Active_Session", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "replacement-browser" + + initialLastUsedAt := time.Now().Add(-2 * time.Hour) + firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_session_token") + firstToken.LastUsedAt = initialLastUsedAt + err := db.Create(ctx, firstToken) + require.NoError(t, err) + require.NotNil(t, firstToken.GetID()) + firstTokenID := *firstToken.GetID() + + time.Sleep(10 * time.Millisecond) + + replacement := &model.RefreshToken{ + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: "replacement_session_token", + }, + AccountRef: &userID, + ExpiresAt: time.Now().Add(24 * time.Hour), + IsRevoked: false, + UserAgent: "TestUserAgent/2.0", + IPAddress: "10.0.0.5", + } + + err = db.Create(ctx, replacement) + require.NoError(t, err) + require.NotNil(t, replacement.GetID()) + assert.Equal(t, firstTokenID, *replacement.GetID()) + + var stored model.RefreshToken + err = db.Get(ctx, firstTokenID, &stored) + require.NoError(t, err) + assert.False(t, stored.LastUsedAt.IsZero()) + assert.True(t, stored.LastUsedAt.After(initialLastUsedAt)) + assert.Equal(t, "replacement_session_token", stored.RefreshToken) + }) + + t.Run("GetByCRT_Updates_LastUsedAt", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "refresh-browser" + token := "refresh_token_last_used" + + initialLastUsedAt := time.Now().Add(-1 * time.Hour) + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + refreshToken.LastUsedAt = initialLastUsedAt + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + require.NotNil(t, refreshToken.GetID()) + + time.Sleep(10 * time.Millisecond) + + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + retrievedToken, err := db.GetByCRT(ctx, crt) + require.NoError(t, err) + assert.True(t, retrievedToken.LastUsedAt.After(initialLastUsedAt)) + + var stored model.RefreshToken + err = db.Get(ctx, *refreshToken.GetID(), &stored) + require.NoError(t, err) + assert.True(t, stored.LastUsedAt.After(initialLastUsedAt)) + }) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_test.go b/backend/edge/shared/storage/refreshtokens/refreshtokens_test.go new file mode 100644 index 0000000..76a7150 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/refreshtokens_test.go @@ -0,0 +1,231 @@ +//go:build integration +// +build integration + +package refreshtokens_test + +import ( + "context" + "crypto/sha1" + "errors" + "fmt" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func setupTestDB(t *testing.T) (*refreshtokens.RefreshTokenDB, func()) { + db, _, cleanup := setupTestDBWithMongo(t) + return db, cleanup +} + +func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.Database, func()) { + // mark as helper for better test failure reporting + t.Helper() + + startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer startCancel() + + mongoContainer, err := mongodb.Run(startCtx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)), + ) + require.NoError(t, err, "failed to start MongoDB container") + + mongoURI, err := mongoContainer.ConnectionString(startCtx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + + database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name())))) + logger := factory.NewLogger(true) + + db, err := refreshtokens.Create(logger, database) + require.NoError(t, err, "failed to create refresh tokens db") + + cleanup := func() { + _ = database.Drop(context.Background()) + _ = client.Disconnect(context.Background()) + termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = mongoContainer.Terminate(termCtx) + } + + return db, database, cleanup +} + +func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *model.RefreshToken { + return &model.RefreshToken{ + ClientRefreshToken: model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + }, + AccountRef: &accountRef, + ExpiresAt: time.Now().Add(24 * time.Hour), + IsRevoked: false, + UserAgent: "TestUserAgent/1.0", + IPAddress: "192.168.1.1", + LastUsedAt: time.Now(), + } +} + +func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("Complete_User_Authentication_Flow", func(t *testing.T) { + // Setup: Create user and client + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-desktop-chrome" + token := "refresh_token_12345" + + // Step 1: User logs in - create initial refresh token + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + // Step 2: User uses refresh token to get new access token + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + retrievedToken, err := db.GetByCRT(ctx, crt) + require.NoError(t, err) + require.NotNil(t, retrievedToken.AccountRef) + assert.Equal(t, userID, *retrievedToken.AccountRef) + assert.Equal(t, token, retrievedToken.RefreshToken) + assert.False(t, retrievedToken.IsRevoked) + + // Step 3: User logs out - revoke the token + session := &model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + } + err = db.Revoke(ctx, userID, session) + require.NoError(t, err) + + // Step 4: Try to use revoked token - should fail + _, err = db.GetByCRT(ctx, crt) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Manual_Token_Revocation_Workaround", func(t *testing.T) { + // Test manual revocation by directly updating the token + userID := bson.NewObjectID() + clientID := "web-app" + deviceID := "user-desktop-chrome" + token := "manual_revoke_token_123" + + // Step 1: Create token + refreshToken := createTestRefreshToken(userID, clientID, deviceID, token) + err := db.Create(ctx, refreshToken) + require.NoError(t, err) + + // Step 2: Manually revoke token by updating it directly + refreshToken.IsRevoked = true + err = db.Update(ctx, refreshToken) + require.NoError(t, err) + + // Step 3: Try to use revoked token - should fail + crt := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: deviceID, + }, + RefreshToken: token, + } + + _, err = db.GetByCRT(ctx, crt) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) +} + +func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + t.Run("User_With_Multiple_Devices", func(t *testing.T) { + userID := bson.NewObjectID() + clientID := "mobile-app" + + // User logs in from phone + phoneToken := createTestRefreshToken(userID, clientID, "phone-ios", "phone_token_123") + err := db.Create(ctx, phoneToken) + require.NoError(t, err) + + // User logs in from tablet + tabletToken := createTestRefreshToken(userID, clientID, "tablet-android", "tablet_token_456") + err = db.Create(ctx, tabletToken) + require.NoError(t, err) + + // User logs in from desktop + desktopToken := createTestRefreshToken(userID, clientID, "desktop-windows", "desktop_token_789") + err = db.Create(ctx, desktopToken) + require.NoError(t, err) + + // User wants to logout from all devices except current (phone) + err = db.RevokeAll(ctx, userID, "phone-ios") + require.NoError(t, err) + + // Phone should still work + phoneCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: "phone-ios", + }, + RefreshToken: "phone_token_123", + } + _, err = db.GetByCRT(ctx, phoneCRT) + require.NoError(t, err) + + // Tablet and desktop should be revoked + tabletCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: "tablet-android", + }, + RefreshToken: "tablet_token_456", + } + _, err = db.GetByCRT(ctx, tabletCRT) + assert.Error(t, err) + + desktopCRT := &model.ClientRefreshToken{ + SessionIdentifier: model.SessionIdentifier{ + ClientID: clientID, + DeviceID: "desktop-windows", + }, + RefreshToken: "desktop_token_789", + } + _, err = db.GetByCRT(ctx, desktopCRT) + assert.Error(t, err) + }) +} diff --git a/backend/edge/shared/storage/refreshtokens/revoke.go b/backend/edge/shared/storage/refreshtokens/revoke.go new file mode 100644 index 0000000..a704af8 --- /dev/null +++ b/backend/edge/shared/storage/refreshtokens/revoke.go @@ -0,0 +1,24 @@ +package refreshtokens + +import ( + "context" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *RefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error { + query := repository.Query(). + Filter(repository.AccountField(), accountRef). + And(repository.Query().Comparison(repository.Field("deviceId"), builder.Ne, deviceID)). + And(repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false)) + + patch := repository.Patch(). + Set(repository.Field(ExpiresAtField), time.Now()). + Set(repository.Field(IsRevokedField), true) + + _, err := db.Repository.PatchMany(ctx, query, patch) + return err +} diff --git a/backend/edge/shared/storage/s3key/key.go b/backend/edge/shared/storage/s3key/key.go new file mode 100644 index 0000000..cfd9fb7 --- /dev/null +++ b/backend/edge/shared/storage/s3key/key.go @@ -0,0 +1,35 @@ +package s3key + +import ( + "path" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +// Join confines an object reference to a relative S3 prefix. +func Join(prefix, reference string) (string, error) { + cleanPrefix, err := cleanRelative(prefix, "prefix") + if err != nil { + return "", err + } + cleanReference, err := cleanRelative(reference, "obj_ref") + if err != nil { + return "", err + } + return path.Join(cleanPrefix, cleanReference), nil +} + +func cleanRelative(value, argument string) (string, error) { + value = strings.TrimSpace(value) + for _, segment := range strings.Split(value, "/") { + if segment == ".." { + return "", merrors.InvalidArgument(argument+" is invalid", argument) + } + } + clean := path.Clean(value) + if value == "" || clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { + return "", merrors.InvalidArgument(argument+" is invalid", argument) + } + return clean, nil +} diff --git a/backend/edge/shared/storage/s3key/key_test.go b/backend/edge/shared/storage/s3key/key_test.go new file mode 100644 index 0000000..e016ff8 --- /dev/null +++ b/backend/edge/shared/storage/s3key/key_test.go @@ -0,0 +1,42 @@ +package s3key + +import "testing" + +func TestJoinConfinesReferenceToPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + reference string + want string + wantError bool + }{ + {name: "nested reference", prefix: "compliance-evidence", reference: "account/evidence", want: "compliance-evidence/account/evidence"}, + {name: "empty reference", prefix: "accounts", wantError: true}, + {name: "absolute reference", prefix: "accounts", reference: "/private", wantError: true}, + {name: "parent reference", prefix: "compliance-evidence", reference: "../accounts/avatar", wantError: true}, + {name: "normalized parent reference", prefix: "accounts", reference: "customer/../avatar", wantError: true}, + {name: "nested parent escape", prefix: "compliance-evidence", reference: "account/../../accounts/avatar", wantError: true}, + {name: "invalid prefix", prefix: "../private", reference: "object", wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got, err := Join(test.prefix, test.reference) + if test.wantError { + if err == nil { + t.Fatalf("Join(%q, %q) error = nil", test.prefix, test.reference) + } + return + } + if err != nil { + t.Fatalf("Join(%q, %q) error = %v", test.prefix, test.reference, err) + } + if got != test.want { + t.Fatalf("Join(%q, %q) = %q, want %q", test.prefix, test.reference, got, test.want) + } + }) + } +} diff --git a/backend/edge/shared/storage/verification/consume.go b/backend/edge/shared/storage/verification/consume.go new file mode 100644 index 0000000..a87bdcc --- /dev/null +++ b/backend/edge/shared/storage/verification/consume.go @@ -0,0 +1,80 @@ +package verification + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (db *verificationDB) Consume( + ct context.Context, + accountRef bson.ObjectID, + purpose model.VerificationPurpose, + contextRef string, + rawToken string, +) (*model.VerificationToken, error) { + scope, err := newConsumeScope(accountRef, purpose, contextRef, rawToken) + if err != nil { + return nil, err + } + + db.Logger.Debug("Verification consume started", scope.startFields()...) + + result, execErr := db.tf.CreateTransaction().Execute( + ct, + func(ctx context.Context) (any, error) { + token, err := db.lookupConsumeToken(ctx, scope) + if err != nil { + return nil, err + } + if err := db.validateConsumeToken(scope, token); err != nil { + return nil, err + } + return db.consumeMatchedToken(ctx, scope, token) + }, + ) + + if execErr != nil { + db.persistConsumeMissAttempts(ct, scope, execErr) + } + + return db.finishConsume(scope, result, execErr) +} + +func (db *verificationDB) persistConsumeMissAttempts(ctx context.Context, scope consumeScope, err error) { + var miss consumeMissError + if !errors.As(err, &miss) { + return + } + db.incrementConsumeAttemptsForTokenIDs(ctx, scope, miss.tokenIDs) +} + +func (db *verificationDB) finishConsume( + scope consumeScope, + result any, + err error, +) (*model.VerificationToken, error) { + if err != nil { + db.Logger.Debug("Verification consume finished with error", + scope.logFields(zap.Error(err))..., + ) + return nil, err + } + + token, ok := result.(*model.VerificationToken) + if !ok { + db.Logger.Warn("Verification consume returned unexpected result type", + scope.logFields(zap.String("result_type", ""))..., + ) + return nil, merrors.Internal("unexpected token type") + } + + db.Logger.Debug("Verification consume finished successfully", + scope.tokenLogFields(token)..., + ) + return token, nil +} diff --git a/backend/edge/shared/storage/verification/consume_logging.go b/backend/edge/shared/storage/verification/consume_logging.go new file mode 100644 index 0000000..30c5c96 --- /dev/null +++ b/backend/edge/shared/storage/verification/consume_logging.go @@ -0,0 +1,67 @@ +package verification + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.uber.org/zap" +) + +const hashPreviewChars = 12 + +func hashPreview(value string) string { + if value == "" { + return "" + } + if len(value) <= hashPreviewChars { + return value + } + return value[:hashPreviewChars] + "..." +} + +func tokenIsDigitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func tokenStateFields(token *model.VerificationToken) []zap.Field { + fields := []zap.Field{ + mzap.StorableRef(token), + mzap.ObjRef("token_account_ref", token.AccountRef), + zap.String("token_purpose", string(token.Purpose)), + zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""), + zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil), + zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)), + zap.Bool("token_has_salt", token.Salt != nil), + zap.Bool("token_used", token.UsedAt != nil), + zap.Time("token_expires_at", token.ExpiresAt), + zap.Int("token_attempts", token.Attempts), + zap.Bool("token_has_max_retries", token.MaxRetries != nil), + } + + if token.Salt != nil { + fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt))) + } + if token.UsedAt != nil { + fields = append(fields, zap.Time("token_used_at", *token.UsedAt)) + } + if token.MaxRetries != nil { + fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries)) + } + if token.IdempotencyKey != nil { + fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey))) + } + if strings.TrimSpace(token.ContextRef) != "" { + fields = append(fields, zap.String("token_context_ref", token.ContextRef)) + } + + return fields +} diff --git a/backend/edge/shared/storage/verification/consume_lookup.go b/backend/edge/shared/storage/verification/consume_lookup.go new file mode 100644 index 0000000..a6c10c2 --- /dev/null +++ b/backend/edge/shared/storage/verification/consume_lookup.go @@ -0,0 +1,183 @@ +package verification + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type consumeMissError struct { + cause error + tokenIDs []bson.ObjectID +} + +func newConsumeMissError(tokens []model.VerificationToken) consumeMissError { + tokenIDs := make([]bson.ObjectID, 0, len(tokens)) + for i := range tokens { + if tokens[i].ID != bson.NilObjectID { + tokenIDs = append(tokenIDs, tokens[i].ID) + } + } + return consumeMissError{ + cause: ErorrTokenNotFound(), + tokenIDs: tokenIDs, + } +} + +func (e consumeMissError) Error() string { + return e.cause.Error() +} + +func (e consumeMissError) Unwrap() error { + return e.cause +} + +func (db *verificationDB) lookupConsumeToken( + ctx context.Context, + scope consumeScope, +) (*model.VerificationToken, error) { + scopeFilter := scope.scopeFilter() + db.Logger.Debug("Verification consume scope filter prepared", scope.logFields()...) + + token, err := db.lookupConsumeMagicToken(ctx, scope) + if err != nil { + return nil, err + } + if token != nil { + return token, nil + } + + if !scope.accountScoped { + db.Logger.Debug("Verification consume rejected unscoped OTP scan", scope.logFields()...) + return nil, ErorrTokenNotFound() + } + + return db.lookupConsumeOTPToken(ctx, scope, scopeFilter) +} + +func (db *verificationDB) lookupConsumeMagicToken( + ctx context.Context, + scope consumeScope, +) (*model.VerificationToken, error) { + magicFilter := repository.Query().And( + append( + scope.scopeFilters(), + repository.Filter("verifyTokenHash", tokenHash(scope.rawToken)), + )..., + ) + db.Logger.Debug("Verification consume magic path lookup", scope.logFields()...) + + var direct model.VerificationToken + err := db.FindOne(ctx, magicFilter, &direct) + switch { + case err == nil: + db.Logger.Debug("Verification consume matched by magic hash path", + scope.tokenLogFields(&direct)..., + ) + return &direct, nil + case errors.Is(err, merrors.ErrNoData): + db.Logger.Debug("Verification consume no direct magic match", scope.logFields()...) + return nil, nil + default: + db.Logger.Warn("Verification consume magic path lookup failed", + scope.logFields(zap.Error(err))..., + ) + return nil, err + } +} + +func (db *verificationDB) lookupConsumeOTPToken( + ctx context.Context, + scope consumeScope, + scopeFilter builder.Query, +) (*model.VerificationToken, error) { + db.Logger.Debug("Verification consume OTP fallback lookup started", + scope.logFields(zap.Any("scope_filter", scopeFilter.BuildQuery()))..., + ) + tokens, err := mutil.GetObjects[model.VerificationToken]( + ctx, db.Logger, scopeFilter, nil, db.Repository, + ) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + db.Logger.Debug("Verification consume OTP fallback found no scoped tokens", scope.logFields()...) + return nil, ErorrTokenNotFound() + } + db.Logger.Warn("Verification consume OTP fallback query failed", + scope.logFields(zap.Error(err))..., + ) + return nil, err + } + db.Logger.Debug("Verification consume OTP fallback loaded candidates", + scope.logFields(zap.Int("candidate_count", len(tokens)))..., + ) + + for i := range tokens { + token := &tokens[i] + hash := hasherFor(token).Hash(scope.rawToken, token) + match := hash == token.VerifyTokenHash + db.Logger.Debug("Verification consume OTP candidate evaluated", + zap.Int("candidate_index", i), + zap.Int("candidate_total", len(tokens)), + mzap.ObjRef("candidate_ref", token.ID), + zap.Bool("candidate_has_salt", token.Salt != nil), + zap.Bool("candidate_used", token.UsedAt != nil), + zap.Time("candidate_expires_at", token.ExpiresAt), + zap.Int("candidate_attempts", token.Attempts), + zap.Bool("candidate_has_max_retries", token.MaxRetries != nil), + zap.String("candidate_stored_hash_prefix", hashPreview(token.VerifyTokenHash)), + zap.String("candidate_computed_hash_prefix", hashPreview(hash)), + zap.Bool("candidate_match", match), + ) + if match { + return token, nil + } + } + + return nil, newConsumeMissError(tokens) +} + +func (db *verificationDB) incrementConsumeAttemptsForTokenIDs( + ctx context.Context, + scope consumeScope, + tokenIDs []bson.ObjectID, +) { + if len(tokenIDs) == 0 { + return + } + db.Logger.Debug("Verification consume no OTP candidate matched, incrementing attempts for active scoped tokens", + scope.logFields(zap.Int("candidate_count", len(tokenIDs)))..., + ) + + filters := append( + scope.scopeFilters(), + repository.Query().Comparison(repository.IDField(), builder.In, tokenIDs), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, scope.now), + ) + incremented, err := db.PatchMany( + ctx, + repository.Query().And(filters...), + repository.Patch().Inc(repository.Field("attempts"), 1), + ) + if err != nil { + db.Logger.Warn("Verification consume failed to increment attempts for unmatched token", + scope.logFields(zap.Error(err))..., + ) + return + } + + db.Logger.Debug("Verification consume attempts increment for unmatched token executed", + scope.logFields( + zap.Int("updated_count", incremented), + zap.String("transaction_note", "this update occurs after failed consume transaction so not_found does not roll it back"), + )..., + ) +} diff --git a/backend/edge/shared/storage/verification/consume_scope.go b/backend/edge/shared/storage/verification/consume_scope.go new file mode 100644 index 0000000..7481c34 --- /dev/null +++ b/backend/edge/shared/storage/verification/consume_scope.go @@ -0,0 +1,112 @@ +package verification + +import ( + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type consumeScope struct { + now time.Time + accountRef bson.ObjectID + purpose model.VerificationPurpose + contextRef string + rawToken string + trimmedRawToken string + accountScoped bool + accountRefHex string +} + +func newConsumeScope( + accountRef bson.ObjectID, + purpose model.VerificationPurpose, + contextRef string, + rawToken string, +) (consumeScope, error) { + normalizedContextRef, err := normalizeContextRef(purpose, contextRef) + if err != nil { + return consumeScope{}, err + } + + accountScoped := accountRef != bson.NilObjectID + accountRefHex := "" + if accountScoped { + accountRefHex = accountRef.Hex() + } + + return consumeScope{ + now: time.Now().UTC(), + accountRef: accountRef, + purpose: purpose, + contextRef: normalizedContextRef, + rawToken: rawToken, + trimmedRawToken: strings.TrimSpace(rawToken), + accountScoped: accountScoped, + accountRefHex: accountRefHex, + }, nil +} + +func (scope consumeScope) logFields(extra ...zap.Field) []zap.Field { + fields := []zap.Field{ + zap.String("purpose", string(scope.purpose)), + zap.Bool("account_scoped", scope.accountScoped), + zap.String("account_ref", scope.accountRefHex), + } + return append(fields, extra...) +} + +func (scope consumeScope) tokenLogFields(token *model.VerificationToken, extra ...zap.Field) []zap.Field { + fields := scope.logFields(extra...) + return append(fields, tokenStateFields(token)...) +} + +func (scope consumeScope) startFields() []zap.Field { + return scope.logFields( + zap.Time("now_utc", scope.now), + zap.Int("raw_token_len", len(scope.rawToken)), + zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)), + zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)), + zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken), + zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))), + ) +} + +func (scope consumeScope) scopeFilters() []builder.Query { + filters := []builder.Query{ + repository.Filter("purpose", scope.purpose), + } + if scope.accountScoped { + filters = append(filters, repository.Filter("accountRef", scope.accountRef)) + } + filters = appendContextRefFilter(filters, scope.purpose, scope.contextRef) + return filters +} + +func (scope consumeScope) scopeFilter() builder.Query { + return repository.Query().And(scope.scopeFilters()...) +} + +func (scope consumeScope) consumeFilter(token *model.VerificationToken) builder.Query { + filters := []builder.Query{ + repository.IDFilter(token.ID), + repository.Filter("purpose", scope.purpose), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, scope.now), + } + filters = appendContextRefFilter(filters, scope.purpose, scope.contextRef) + if scope.accountScoped { + filters = append(filters, repository.Filter("accountRef", scope.accountRef)) + } + if token.MaxRetries != nil { + filters = append( + filters, + repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries), + ) + } + return repository.Query().And(filters...) +} diff --git a/backend/edge/shared/storage/verification/consume_update.go b/backend/edge/shared/storage/verification/consume_update.go new file mode 100644 index 0000000..488c42b --- /dev/null +++ b/backend/edge/shared/storage/verification/consume_update.go @@ -0,0 +1,149 @@ +package verification + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.uber.org/zap" +) + +func (db *verificationDB) validateConsumeToken( + scope consumeScope, + token *model.VerificationToken, +) error { + if token.UsedAt != nil { + db.Logger.Debug("Verification consume static check failed: token already used", + scope.tokenLogFields(token)..., + ) + return ErorrTokenAlreadyUsed() + } + if !token.ExpiresAt.After(scope.now) { + db.Logger.Debug("Verification consume static check failed: token expired", + scope.tokenLogFields(token, zap.Time("now_utc", scope.now))..., + ) + return ErorrTokenExpired() + } + if token.MaxRetries != nil && token.Attempts >= *token.MaxRetries { + db.Logger.Debug("Verification consume static check failed: attempts exceeded", + scope.tokenLogFields(token)..., + ) + return ErrorTokenAttemptsExceeded() + } + + db.Logger.Debug("Verification consume static checks passed", + scope.tokenLogFields(token)..., + ) + return nil +} + +func (db *verificationDB) consumeMatchedToken( + ctx context.Context, + scope consumeScope, + token *model.VerificationToken, +) (*model.VerificationToken, error) { + db.Logger.Debug("Verification consume atomic update attempt", + scope.logFields(mzap.StorableRef(token))..., + ) + + updated, err := db.PatchMany( + ctx, + scope.consumeFilter(token), + repository.Patch().Set(repository.Field("usedAt"), scope.now), + ) + if err != nil { + db.Logger.Warn("Verification consume atomic update failed", + scope.logFields(mzap.StorableRef(token), zap.Error(err))..., + ) + return nil, err + } + db.Logger.Debug("Verification consume atomic update result", + scope.logFields(mzap.StorableRef(token), zap.Int("updated_count", updated))..., + ) + + if updated == 1 { + token.UsedAt = &scope.now + db.Logger.Debug("Verification consume succeeded", scope.tokenLogFields(token)...) + return token, nil + } + + db.incrementConsumeAttemptsAfterAtomicMiss(ctx, scope, token) + return nil, db.consumeMissError(ctx, scope, token) +} + +func (db *verificationDB) incrementConsumeAttemptsAfterAtomicMiss( + ctx context.Context, + scope consumeScope, + token *model.VerificationToken, +) { + incremented, err := db.PatchMany( + ctx, + repository.IDFilter(token.ID), + repository.Patch().Inc(repository.Field("attempts"), 1), + ) + if err != nil { + db.Logger.Warn("Verification consume failed to increment attempts after atomic update miss", + scope.logFields(mzap.StorableRef(token), zap.Error(err))..., + ) + return + } + + db.Logger.Debug("Verification consume incremented attempts after atomic update miss", + scope.logFields( + mzap.StorableRef(token), + zap.Int("updated_count", incremented), + zap.String("transaction_note", "this update occurs inside transaction and may roll back depending on returned error"), + )..., + ) +} + +func (db *verificationDB) consumeMissError( + ctx context.Context, + scope consumeScope, + token *model.VerificationToken, +) error { + var fresh model.VerificationToken + if err := db.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil { + db.Logger.Warn("Verification consume failed to re-check token state", + scope.logFields(mzap.StorableRef(token), zap.Error(err))..., + ) + return merrors.Internal("failed to re-check token state") + } + db.Logger.Debug("Verification consume re-checked token state after atomic miss", + scope.tokenLogFields(&fresh, mzap.StorableRef(token))..., + ) + + if fresh.UsedAt != nil { + db.Logger.Debug("Verification consume final decision: already used after re-check", + scope.logFields(mzap.StorableRef(token))..., + ) + return ErorrTokenAlreadyUsed() + } + if !fresh.ExpiresAt.After(scope.now) { + db.Logger.Debug("Verification consume final decision: expired after re-check", + scope.logFields( + mzap.StorableRef(token), + zap.Time("now_utc", scope.now), + zap.Time("token_expires_at", fresh.ExpiresAt), + )..., + ) + return ErorrTokenExpired() + } + if fresh.MaxRetries != nil && fresh.Attempts >= *fresh.MaxRetries { + db.Logger.Debug("Verification consume final decision: attempts exceeded after re-check", + scope.logFields( + mzap.StorableRef(token), + zap.Int("token_attempts", fresh.Attempts), + zap.Int("token_max_retries", *fresh.MaxRetries), + )..., + ) + return ErrorTokenAttemptsExceeded() + } + + db.Logger.Debug("Verification consume final decision: token not found after re-check", + scope.logFields(mzap.StorableRef(token))..., + ) + return ErorrTokenNotFound() +} diff --git a/backend/edge/shared/storage/verification/context_ref.go b/backend/edge/shared/storage/verification/context_ref.go new file mode 100644 index 0000000..ef4b029 --- /dev/null +++ b/backend/edge/shared/storage/verification/context_ref.go @@ -0,0 +1,22 @@ +package verification + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +func normalizeContextRef(purpose model.VerificationPurpose, contextRef string) (string, error) { + _ = purpose + return strings.TrimSpace(contextRef), nil +} + +func appendContextRefFilter(filters []builder.Query, purpose model.VerificationPurpose, contextRef string) []builder.Query { + _ = purpose + if strings.TrimSpace(contextRef) != "" { + filters = append(filters, repository.Filter("contextRef", strings.TrimSpace(contextRef))) + } + return filters +} diff --git a/backend/edge/shared/storage/verification/context_ref_test.go b/backend/edge/shared/storage/verification/context_ref_test.go new file mode 100644 index 0000000..25ff1c2 --- /dev/null +++ b/backend/edge/shared/storage/verification/context_ref_test.go @@ -0,0 +1,99 @@ +package verification + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestCreate_BlankContextRefAllowed(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + _, err := db.Create(ctx, contextReq(accountRef, "")) + require.NoError(t, err) +} + +func TestCreate_OldContextDoesNotBlockNewContext(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + oldRaw, err := db.Create(ctx, contextReq(accountRef, "ctx-old")) + require.NoError(t, err) + + newRaw, err := db.Create(ctx, contextReq(accountRef, "ctx-new").WithCooldown(time.Minute)) + require.NoError(t, err) + assert.NotEqual(t, oldRaw, newRaw) + + oldTok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-old", oldRaw) + require.NoError(t, err) + assert.Equal(t, "ctx-old", oldTok.ContextRef) + + newTok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-new", newRaw) + require.NoError(t, err) + assert.Equal(t, "ctx-new", newTok.ContextRef) +} + +func TestConsume_OTPForOneContextCannotConfirmAnotherContext(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, contextReq(accountRef, "ctx-a")) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-b", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenNotFound)) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-a", raw) + require.NoError(t, err) + assert.Equal(t, "ctx-a", tok.ContextRef) +} + +func TestCreate_ContextIdempotencyReplayWorksOnlyWithinSameContext(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstReq := contextReq(accountRef, "ctx-a"). + WithCooldown(time.Minute). + WithIdempotencyKey("same-key") + firstRaw, err := db.Create(ctx, firstReq) + require.NoError(t, err) + require.NotEmpty(t, firstRaw) + + replayReq := contextReq(accountRef, "ctx-a"). + WithCooldown(time.Minute). + WithIdempotencyKey("same-key") + replayRaw, err := db.Create(ctx, replayReq) + require.NoError(t, err) + assert.Equal(t, firstRaw, replayRaw) + + conflictingReq := contextReq(accountRef, "ctx-b"). + WithIdempotencyKey("same-key") + _, err = db.Create(ctx, conflictingReq) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrIdempotencyConflict)) +} + +func TestCreate_ContextRefStoredForAnyPurpose(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour).WithContextRef("ctx")) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx", raw) + require.NoError(t, err) + assert.Equal(t, "ctx", tok.ContextRef) +} diff --git a/backend/edge/shared/storage/verification/create.go b/backend/edge/shared/storage/verification/create.go new file mode 100644 index 0000000..8a71bc6 --- /dev/null +++ b/backend/edge/shared/storage/verification/create.go @@ -0,0 +1,248 @@ +package verification + +import ( + "context" + "errors" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type CreateResult struct { + VerificationToken string + Created bool + ExpiresAt time.Time +} + +func activeContextFilter(request *Request, now time.Time) builder.Query { + filters := []builder.Query{ + repository.Filter("accountRef", request.AccountRef), + repository.Filter("purpose", request.Purpose), + repository.Filter("target", request.Target), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), + } + filters = appendContextRefFilter(filters, request.Purpose, request.ContextRef) + return repository.Query().And(filters...) +} + +func cooldownActiveContextFilter(request *Request, now, cutoff time.Time) builder.Query { + filters := []builder.Query{ + repository.Filter("accountRef", request.AccountRef), + repository.Filter("purpose", request.Purpose), + repository.Filter("target", request.Target), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), + repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff), + } + filters = appendContextRefFilter(filters, request.Purpose, request.ContextRef) + return repository.Query().And(filters...) +} + +func hashFilter(hash string) builder.Query { + return repository.Filter("verifyTokenHash", hash) +} + +func storedVerificationInstant(value time.Time) time.Time { + return value.UTC().Truncate(time.Millisecond) +} + +func newVerificationToken( + request *Request, + idempotencyKey string, + hasIdempotency bool, +) (*model.VerificationToken, string, error) { + + now := storedVerificationInstant(time.Now()) + + var ( + raw string + hash string + salt *string + err error + ) + + switch request.Kind { + case TokenKindOTP: + if hasIdempotency { + var saltValue string + raw, saltValue, hash = generateDeterministicOTP(idempotencySeed(request, idempotencyKey)) + salt = &saltValue + } else { + var s string + raw, s, hash, err = generateOTP() + if err != nil { + return nil, "", err + } + salt = &s + } + + default: // Magic token + if hasIdempotency { + raw, hash = generateDeterministicMagic(idempotencySeed(request, idempotencyKey)) + } else { + raw, hash, err = generateMagic() + if err != nil { + return nil, "", err + } + } + } + + token := &model.VerificationToken{ + AccountRef: request.AccountRef, + Purpose: request.Purpose, + Target: request.Target, + ContextRef: request.ContextRef, + IdempotencyKey: nil, + VerifyTokenHash: hash, + Salt: salt, + UsedAt: nil, + ExpiresAt: storedVerificationInstant(now.Add(request.Ttl)), + MaxRetries: request.MaxRetries, + } + if hasIdempotency { + token.IdempotencyKey = &idempotencyKey + } + + return token, raw, nil +} + +func (db *verificationDB) Create( + ctx context.Context, + request *Request, +) (string, error) { + result, err := db.CreateWithResult(ctx, request) + if err != nil { + return "", err + } + return result.VerificationToken, nil +} + +func (db *verificationDB) CreateWithResult( + ctx context.Context, + request *Request, +) (CreateResult, error) { + + if request == nil { + return CreateResult{}, merrors.Internal("nil request") + } + contextRef, err := normalizeContextRef(request.Purpose, request.ContextRef) + if err != nil { + return CreateResult{}, err + } + request.ContextRef = contextRef + + idempotencyKey, hasIdempotency := normalizedIdempotencyKey(request.IdempotencyKey) + if !hasIdempotency { + // Always store an idempotency key. Client keys enable replay; generated + // keys avoid null-key collisions on older Mongo indexes and keep + // transaction retries deterministic. + idempotencyKey = syntheticIdempotencyKey() + hasIdempotency = true + } + + token, raw, err := newVerificationToken(request, idempotencyKey, hasIdempotency) + if err != nil { + return CreateResult{}, err + } + + result, err := db.tf.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + now := time.Now().UTC() + + activeFilter := activeContextFilter(request, now) + + // Idempotency and transaction-retry support. + if hasIdempotency { + replayedToken, replayed, err := db.lookupIdempotencyReplay(tx, request, idempotencyKey) + if err != nil { + return nil, err + } + if replayed { + return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil + } + + var sameToken model.VerificationToken + err = db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken) + switch { + case err == nil: + // Same hash means the same Create operation already succeeded. + return CreateResult{Created: true, ExpiresAt: sameToken.ExpiresAt}, nil + case errors.Is(err, merrors.ErrNoData): + default: + return nil, err + } + } + + // 1) Cooldown: if there exists ANY active token created after cutoff → block + if request.Cooldown != nil { + cutoff := now.Add(-*request.Cooldown) + + var recent model.VerificationToken + err := db.FindOne(tx, cooldownActiveContextFilter(request, now, cutoff), &recent) + switch { + case err == nil: + retryAfter := recent.CreatedAt.Add(*request.Cooldown).Sub(now) + return nil, ErrorCooldownActiveFor(retryAfter) + case errors.Is(err, merrors.ErrNoData): + default: + return nil, err + } + } + + // Reserve every abuse-control scope in the same transaction as token + // creation. A denied request rolls back all reservations and cannot race + // another instance into publishing an extra code. + if err := db.reserveIssuance(tx, request, now); err != nil { + return nil, err + } + + // 2) Invalidate active tokens for this context + if _, err := db.PatchMany( + tx, + activeFilter, + repository.Patch().Set(repository.Field("usedAt"), now), + ); err != nil { + return nil, err + } + + // 3) Create new token only after cooldown/idempotency checks pass. + if err := db.DBImp.Create(tx, token); err != nil { + if hasIdempotency && errors.Is(err, merrors.ErrDataConflict) { + replayedToken, replayed, replayErr := db.lookupIdempotencyReplay(tx, request, idempotencyKey) + if replayErr != nil { + return nil, replayErr + } + if replayed { + return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil + } + + var sameToken model.VerificationToken + findErr := db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken) + switch { + case findErr == nil: + return CreateResult{Created: true, ExpiresAt: sameToken.ExpiresAt}, nil + case errors.Is(findErr, merrors.ErrNoData): + default: + return nil, findErr + } + } + return nil, err + } + return CreateResult{Created: true, ExpiresAt: token.ExpiresAt}, nil + }) + + if err != nil { + return CreateResult{}, err + } + + createResult, ok := result.(CreateResult) + if !ok { + return CreateResult{}, merrors.Internal("unexpected verification create result") + } + + createResult.VerificationToken = raw + return createResult, nil +} diff --git a/backend/edge/shared/storage/verification/db.go b/backend/edge/shared/storage/verification/db.go new file mode 100644 index 0000000..dd9df96 --- /dev/null +++ b/backend/edge/shared/storage/verification/db.go @@ -0,0 +1,88 @@ +package verification + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type verificationDB struct { + template.DBImp[*model.VerificationToken] + tf transaction.Factory + limits ratelimits.Store +} + +func (db *verificationDB) WithIssuanceLimits(limits ratelimits.Store) *verificationDB { + db.limits = limits + return db +} + +const verificationContextIdempotencyIndexName = "uniq_verification_context_idempotency_v2" + +func Create( + logger mlogger.Logger, + db *mongo.Database, + tf transaction.Factory, +) (*verificationDB, error) { + return CreateWithCollection(logger, db, tf, "") +} + +func CreateWithCollection( + logger mlogger.Logger, + db *mongo.Database, + tf transaction.Factory, + collection string, +) (*verificationDB, error) { + if strings.TrimSpace(collection) == "" { + collection = mservice.VerificationTokens + } + p := &verificationDB{ + DBImp: *template.Create[*model.VerificationToken](logger, mservice.Type(collection), db), + tf: tf, + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "verifyTokenHash", Sort: ri.Asc}}, + Unique: true, + Name: "unique_token_hash", + }); err != nil { + p.Logger.Error("Failed to create unique verifyTokenHash index", zap.Error(err)) + return nil, err + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{ + {Field: "accountRef", Sort: ri.Asc}, + {Field: "purpose", Sort: ri.Asc}, + {Field: "target", Sort: ri.Asc}, + {Field: "contextRef", Sort: ri.Asc}, + {Field: "idempotencyKey", Sort: ri.Asc}, + }, + Unique: true, + Sparse: true, + Name: verificationContextIdempotencyIndexName, + }); err != nil { + p.Logger.Error("Failed to create unique idempotency index on verification context", zap.Error(err)) + return nil, err + } + + ttl := int32(2678400) // 30 days + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, + TTL: &ttl, + Name: "ttl_expires_at", + }); err != nil { + p.Logger.Error("Failed to create TTL index on expiresAt", zap.Error(err)) + return nil, err + } + + return p, nil +} diff --git a/backend/edge/shared/storage/verification/errors.go b/backend/edge/shared/storage/verification/errors.go new file mode 100644 index 0000000..f3b7350 --- /dev/null +++ b/backend/edge/shared/storage/verification/errors.go @@ -0,0 +1,69 @@ +package verification + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +var ( + ErrTokenNotFound = merrors.Define("vtNotFound") + ErrTokenAlreadyUsed = merrors.Define("vtAlreadyUsed") + ErrTokenExpired = merrors.Define("vtExpired") + ErrTokenAttemptsExceeded = merrors.Define("vtAttemptsExceeded") + ErrCooldownActive = merrors.Define("vtCooldownActive") + ErrIdempotencyConflict = merrors.Define("vtIdempotencyConflict") + ErrIssuanceRateLimited = merrors.Define("vtIssuanceRateLimited") +) + +type ThrottleDetails = merrors.ThrottleDetails + +func Throttle(err error) (ThrottleDetails, bool) { + return merrors.ThrottleDetailsFrom(err) +} + +func wrap(err error, msg string) error { + return merrors.Wrap(err, msg) +} + +func ErorrTokenNotFound() error { + return wrap(ErrTokenNotFound, "verification token not found") +} + +func ErorrTokenAlreadyUsed() error { + return wrap(ErrTokenAlreadyUsed, "verification token has already been used") +} + +func ErorrTokenExpired() error { + return wrap(ErrTokenExpired, "verification token expired") +} + +func ErrorCooldownActive() error { + return ErrorCooldownActiveFor(0) +} + +func ErrorCooldownActiveFor(retryAfter time.Duration) error { + return merrors.WithThrottle( + ErrCooldownActive, + "cooldown", + retryAfter, + "verification request is temporarily limited", + ) +} + +func ErrorIssuanceRateLimited(scopeKind string, retryAfter time.Duration) error { + return merrors.WithThrottle( + ErrIssuanceRateLimited, + scopeKind, + retryAfter, + "verification request is temporarily limited", + ) +} + +func ErrorTokenAttemptsExceeded() error { + return wrap(ErrTokenAttemptsExceeded, "verification token max attempts exceeded") +} + +func ErrorIdempotencyConflict() error { + return wrap(ErrIdempotencyConflict, "verification token request idempotency key has already been used") +} diff --git a/backend/edge/shared/storage/verification/hash.go b/backend/edge/shared/storage/verification/hash.go new file mode 100644 index 0000000..1ec163d --- /dev/null +++ b/backend/edge/shared/storage/verification/hash.go @@ -0,0 +1,106 @@ +package verification + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type TokenHasher interface { + Hash(raw string, token *model.VerificationToken) string +} + +type magicHasher struct{} + +func (magicHasher) Hash(raw string, _ *model.VerificationToken) string { + return tokenHash(raw) +} + +type otpHasher struct{} + +func (otpHasher) Hash(raw string, t *model.VerificationToken) string { + return otpHash(raw, *t.Salt) +} + +func hasherFor(t *model.VerificationToken) TokenHasher { + if t.Salt != nil { + return otpHasher{} + } + return magicHasher{} +} + +const verificationTokenBytes = 32 +const otpDigits = 6 + +func generateMagic() (raw, hash string, err error) { + rawBytes := make([]byte, verificationTokenBytes) + if _, err = rand.Read(rawBytes); err != nil { + return + } + raw = base64.RawURLEncoding.EncodeToString(rawBytes) + hash = tokenHash(raw) + return +} + +func generateDeterministicMagic(seed string) (raw, hash string) { + sum := sha256.Sum256([]byte("magic:" + seed)) + raw = base64.RawURLEncoding.EncodeToString(sum[:]) + hash = tokenHash(raw) + return +} + +func generateOTP() (code, salt, hash string, err error) { + // otpDigits-digit code + n := make([]byte, 4) + if _, err = rand.Read(n); err != nil { + return + } + num := int(n[0])<<24 | int(n[1])<<16 | int(n[2])<<8 | int(n[3]) + + mod := 1 + for i := 0; i < otpDigits; i++ { + mod *= 10 + } + + code = fmt.Sprintf("%0*d", otpDigits, num%mod) + + // per-token salt + saltBytes := make([]byte, 16) + if _, err = rand.Read(saltBytes); err != nil { + return + } + salt = base64.RawURLEncoding.EncodeToString(saltBytes) + + hash = otpHash(code, salt) + return +} + +func generateDeterministicOTP(seed string) (code, salt, hash string) { + sum := sha256.Sum256([]byte("otp:" + seed)) + + num := int(sum[0])<<24 | int(sum[1])<<16 | int(sum[2])<<8 | int(sum[3]) + mod := 1 + for i := 0; i < otpDigits; i++ { + mod *= 10 + } + code = fmt.Sprintf("%0*d", otpDigits, num%mod) + + salt = base64.RawURLEncoding.EncodeToString(sum[4:20]) + hash = otpHash(code, salt) + return +} + +// We store only the resulting hash (+salt) in DB, never the OTP itself. +func otpHash(code, salt string) string { + sum := sha256.Sum256([]byte(salt + ":" + code)) + return hex.EncodeToString(sum[:]) +} + +func tokenHash(rawToken string) string { + hash := sha256.Sum256([]byte(rawToken)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} diff --git a/backend/edge/shared/storage/verification/hash_test.go b/backend/edge/shared/storage/verification/hash_test.go new file mode 100644 index 0000000..342d56c --- /dev/null +++ b/backend/edge/shared/storage/verification/hash_test.go @@ -0,0 +1,19 @@ +package verification + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenHash_Deterministic(t *testing.T) { + h1 := tokenHash("same-input") + h2 := tokenHash("same-input") + assert.Equal(t, h1, h2) +} + +func TestTokenHash_DifferentInputs(t *testing.T) { + h1 := tokenHash("input-a") + h2 := tokenHash("input-b") + assert.NotEqual(t, h1, h2) +} diff --git a/backend/edge/shared/storage/verification/idempotency.go b/backend/edge/shared/storage/verification/idempotency.go new file mode 100644 index 0000000..82fcaee --- /dev/null +++ b/backend/edge/shared/storage/verification/idempotency.go @@ -0,0 +1,84 @@ +package verification + +import ( + "context" + "errors" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func normalizedIdempotencyKey(value *string) (string, bool) { + if value == nil { + return "", false + } + key := strings.TrimSpace(*value) + if key == "" { + return "", false + } + return key, true +} + +func syntheticIdempotencyKey() string { + return "auto:" + bson.NewObjectID().Hex() +} + +func idempotencyContextFilter( + request *Request, + idempotencyKey string, +) builder.Query { + return repository.Query().And( + repository.Filter("accountRef", request.AccountRef), + repository.Filter("purpose", request.Purpose), + repository.Filter("target", request.Target), + repository.Filter("idempotencyKey", idempotencyKey), + ) +} + +func idempotencySeed(request *Request, idempotencyKey string) string { + return strings.Join([]string{ + request.AccountRef.Hex(), + string(request.Purpose), + request.Target, + request.ContextRef, + request.Kind, + idempotencyKey, + }, "|") +} + +func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *Request, idempotencyKey string) (*model.VerificationToken, bool, error) { + tokens, err := mutil.GetObjects[model.VerificationToken]( + ctx, + db.Logger, + idempotencyContextFilter(request, idempotencyKey), + nil, + db.Repository, + ) + switch { + case err == nil: + case errors.Is(err, merrors.ErrNoData): + return nil, false, nil + default: + return nil, false, err + } + + var replayed *model.VerificationToken + for i := range tokens { + existingContextRef := strings.TrimSpace(tokens[i].ContextRef) + // The lookup intentionally ignores contextRef. A reused idempotency key + // in another context is a client conflict, not a replay. + if existingContextRef != request.ContextRef { + return nil, false, ErrorIdempotencyConflict() + } + if replayed == nil { + token := tokens[i] + replayed = &token + } + } + return replayed, replayed != nil, nil +} diff --git a/backend/edge/shared/storage/verification/issuance_limit.go b/backend/edge/shared/storage/verification/issuance_limit.go new file mode 100644 index 0000000..1fa952b --- /dev/null +++ b/backend/edge/shared/storage/verification/issuance_limit.go @@ -0,0 +1,47 @@ +package verification + +import ( + "context" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +func (db *verificationDB) reserveIssuance(ctx context.Context, request *Request, now time.Time) error { + if len(request.IssuanceLimits) == 0 { + return nil + } + if db.limits == nil { + return merrors.ServiceUnavailable("verification rate-limit storage is unavailable") + } + for _, limit := range request.IssuanceLimits { + scopeKind := strings.TrimSpace(limit.ScopeKind) + if scopeKind == "" || strings.TrimSpace(limit.ScopeHash) == "" || limit.Limit < 1 || + limit.WindowStart.IsZero() || !limit.ExpiresAt.After(limit.WindowStart) { + return merrors.InvalidArgument("invalid verification issuance limit", "issuanceLimits") + } + count, err := db.limits.Increment( + ctx, + scopeKind, + limit.ScopeHash, + limit.WindowStart, + limit.ExpiresAt, + ) + if err != nil { + return merrors.OpaqueWrap( + merrors.ErrServiceUnavailable, + err, + "failed to reserve verification issuance capacity", + ) + } + if count > limit.Limit { + retryAfter := limit.ExpiresAt.Sub(now) + if retryAfter < time.Second { + retryAfter = time.Second + } + return ErrorIssuanceRateLimited(scopeKind, retryAfter) + } + } + return nil +} diff --git a/backend/edge/shared/storage/verification/issuance_limit_test.go b/backend/edge/shared/storage/verification/issuance_limit_test.go new file mode 100644 index 0000000..a869bac --- /dev/null +++ b/backend/edge/shared/storage/verification/issuance_limit_test.go @@ -0,0 +1,171 @@ +package verification + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestReserveIssuanceEnforcesThreshold(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + store := newMemoryIssuanceStore() + db := &verificationDB{limits: store} + request := &Request{IssuanceLimits: []IssuanceLimit{ + NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now), + }} + + for attempt := 1; attempt <= 2; attempt++ { + if err := db.reserveIssuance(context.Background(), request, now); err != nil { + t.Fatalf("attempt %d was unexpectedly rejected: %v", attempt, err) + } + } + err := db.reserveIssuance(context.Background(), request, now) + if !errors.Is(err, ErrIssuanceRateLimited) { + t.Fatalf("expected rate-limit error, got %v", err) + } + details, ok := Throttle(err) + if !ok || details.ScopeKind != "destination" || details.RetryAfter != time.Hour { + t.Fatalf("unexpected throttle details: %+v, ok=%v", details, ok) + } + + nextWindow := &Request{IssuanceLimits: []IssuanceLimit{ + NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now.Add(time.Hour)), + }} + if err := db.reserveIssuance(context.Background(), nextWindow, now.Add(time.Hour)); err != nil { + t.Fatalf("first request in the next window was rejected: %v", err) + } +} + +func TestCreateWithResultDoesNotMutateTokensWhenIssuanceDenied(t *testing.T) { + now := time.Now().UTC() + store := newMemoryIssuanceStore() + db := newTestVerificationDB(t).WithIssuanceLimits(store) + accountRef := bson.NewObjectID() + limit := NewIssuanceLimit("destination", "login\x00user@example.com", 1, time.Hour, now) + + first, err := db.CreateWithResult(context.Background(), NewOTPRequest( + accountRef, + model.PurposeLogin, + "user@example.com", + ).WithIdempotencyKey("issuance-first").WithIssuanceLimits(limit)) + if err != nil || !first.Created { + t.Fatalf("first token creation failed: result=%+v err=%v", first, err) + } + + _, err = db.CreateWithResult(context.Background(), NewOTPRequest( + accountRef, + model.PurposeLogin, + "user@example.com", + ).WithIdempotencyKey("issuance-second").WithIssuanceLimits(limit)) + if !errors.Is(err, ErrIssuanceRateLimited) { + t.Fatalf("expected second token creation to be rate-limited, got %v", err) + } + + repo, ok := db.Repository.(*memoryTokenRepository) + if !ok { + t.Fatalf("unexpected verification repository type %T", db.Repository) + } + snapshot := repo.snapshot() + if len(snapshot.data) != 1 { + t.Fatalf("rate-limited request changed token count: %d", len(snapshot.data)) + } + for _, token := range snapshot.data { + if token.UsedAt != nil { + t.Fatal("rate-limited request invalidated the existing token") + } + } +} + +func TestReserveIssuanceIsSharedAcrossInstances(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 15, 0, 0, time.UTC) + store := newMemoryIssuanceStore() + dbs := []*verificationDB{{limits: store}, {limits: store}} + request := &Request{IssuanceLimits: []IssuanceLimit{ + NewIssuanceLimit("ip", "login\x00192.0.2.10", 8, time.Hour, now), + }} + + var allowed atomic.Int32 + var denied atomic.Int32 + var wg sync.WaitGroup + for attempt := 0; attempt < 40; attempt++ { + wg.Add(1) + go func(db *verificationDB) { + defer wg.Done() + err := db.reserveIssuance(context.Background(), request, now) + switch { + case err == nil: + allowed.Add(1) + case errors.Is(err, ErrIssuanceRateLimited): + denied.Add(1) + default: + t.Errorf("unexpected issuance error: %v", err) + } + }(dbs[attempt%len(dbs)]) + } + wg.Wait() + + if got := allowed.Load(); got != 8 { + t.Fatalf("expected exactly 8 allowed reservations, got %d", got) + } + if got := denied.Load(); got != 32 { + t.Fatalf("expected 32 denied reservations, got %d", got) + } +} + +func TestReserveIssuanceFailsClosedWithoutLeakingStorageError(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + cause := merrors.Define("restricted verification destination") + db := &verificationDB{limits: &memoryIssuanceStore{err: cause}} + request := &Request{IssuanceLimits: []IssuanceLimit{ + NewIssuanceLimit("destination", "login\x00secret@example.com", 2, time.Hour, now), + }} + + err := db.reserveIssuance(context.Background(), request, now) + if !errors.Is(err, merrors.ErrServiceUnavailable) || !errors.Is(err, cause) { + t.Fatalf("expected opaque service-unavailable error preserving cause, got %v", err) + } + if got := err.Error(); got != "serviceUnavailable: failed to reserve verification issuance capacity" { + t.Fatalf("storage error leaked or changed: %q", got) + } +} + +type memoryIssuanceStore struct { + mu sync.Mutex + counts map[string]int + err error +} + +func newMemoryIssuanceStore() *memoryIssuanceStore { + return &memoryIssuanceStore{counts: make(map[string]int)} +} + +func (s *memoryIssuanceStore) Current(_ context.Context, scopeKind, scopeHash string, windowStart time.Time) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return 0, s.err + } + return s.counts[issuanceStoreKey(scopeKind, scopeHash, windowStart)], nil +} + +func (s *memoryIssuanceStore) Increment(_ context.Context, scopeKind, scopeHash string, windowStart, _ time.Time) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return 0, s.err + } + key := issuanceStoreKey(scopeKind, scopeHash, windowStart) + s.counts[key]++ + return s.counts[key], nil +} + +func issuanceStoreKey(scopeKind, scopeHash string, windowStart time.Time) string { + return scopeKind + "\x00" + scopeHash + "\x00" + windowStart.UTC().Format(time.RFC3339Nano) +} diff --git a/backend/edge/shared/storage/verification/memory_token_repository_test.go b/backend/edge/shared/storage/verification/memory_token_repository_test.go new file mode 100644 index 0000000..a935b0b --- /dev/null +++ b/backend/edge/shared/storage/verification/memory_token_repository_test.go @@ -0,0 +1,324 @@ +package verification + +import ( + "context" + "errors" + "sync" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type memoryTokenRepository struct { + mu sync.Mutex + data map[bson.ObjectID]*model.VerificationToken + order []bson.ObjectID + seq int +} + +type memoryTokenRepositorySnapshot struct { + data map[bson.ObjectID]*model.VerificationToken + order []bson.ObjectID + seq int +} + +func newMemoryTokenRepository() *memoryTokenRepository { + return &memoryTokenRepository{data: make(map[bson.ObjectID]*model.VerificationToken)} +} + +func (m *memoryTokenRepository) snapshot() memoryTokenRepositorySnapshot { + m.mu.Lock() + defer m.mu.Unlock() + + data := make(map[bson.ObjectID]*model.VerificationToken, len(m.data)) + for id, tok := range m.data { + data[id] = cloneToken(tok) + } + return memoryTokenRepositorySnapshot{ + data: data, + order: append([]bson.ObjectID(nil), m.order...), + seq: m.seq, + } +} + +func (m *memoryTokenRepository) restore(snapshot memoryTokenRepositorySnapshot) { + m.mu.Lock() + defer m.mu.Unlock() + + m.data = make(map[bson.ObjectID]*model.VerificationToken, len(snapshot.data)) + for id, tok := range snapshot.data { + m.data[id] = cloneToken(tok) + } + m.order = append([]bson.ObjectID(nil), snapshot.order...) + m.seq = snapshot.seq +} + +func (m *memoryTokenRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { + m.mu.Lock() + defer m.mu.Unlock() + tok, ok := obj.(*model.VerificationToken) + if !ok { + return merrors.InvalidDataType("expected VerificationToken") + } + id := tok.GetID() + if id == nil || *id == bson.NilObjectID { + m.seq++ + tok.SetID(bson.NewObjectID()) + id = tok.GetID() + } + if _, exists := m.data[*id]; exists { + return merrors.DataConflict("token already exists") + } + for _, existing := range m.data { + if existing.VerifyTokenHash == tok.VerifyTokenHash { + return merrors.DataConflict("duplicate verifyTokenHash") + } + if existing.AccountRef != tok.AccountRef { + continue + } + if existing.Purpose != tok.Purpose { + continue + } + if existing.Target != tok.Target { + continue + } + if existing.ContextRef != tok.ContextRef { + continue + } + + switch { + case existing.IdempotencyKey == nil && tok.IdempotencyKey == nil: + return merrors.DataConflict("duplicate verification context idempotency") + case existing.IdempotencyKey != nil && tok.IdempotencyKey != nil && *existing.IdempotencyKey == *tok.IdempotencyKey: + return merrors.DataConflict("duplicate verification context idempotency") + } + } + m.data[*id] = cloneToken(tok) + m.order = append(m.order, *id) + return nil +} + +func (m *memoryTokenRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { + m.mu.Lock() + defer m.mu.Unlock() + tok, ok := m.data[id] + if !ok { + return merrors.ErrNoData + } + dst := result.(*model.VerificationToken) + *dst = *cloneToken(tok) + return nil +} + +func (m *memoryTokenRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, id := range m.order { + tok := m.data[id] + if tok != nil && matchToken(query, tok) { + dst := result.(*model.VerificationToken) + *dst = *cloneToken(tok) + return nil + } + } + return merrors.ErrNoData +} + +func (m *memoryTokenRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { + return m.FindOneByFilter(ctx, query, result) +} + +func (m *memoryTokenRepository) Update(_ context.Context, obj storable.Storable) error { + m.mu.Lock() + defer m.mu.Unlock() + tok := obj.(*model.VerificationToken) + id := tok.GetID() + if id == nil { + return merrors.InvalidArgument("id required") + } + if _, exists := m.data[*id]; !exists { + return merrors.ErrNoData + } + m.data[*id] = cloneToken(tok) + return nil +} + +func (m *memoryTokenRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + tok, ok := obj.(*model.VerificationToken) + if !ok { + return false, merrors.InvalidDataType("expected VerificationToken") + } + for _, id := range m.order { + existing := m.data[id] + if existing == nil || !matchToken(query, existing) { + continue + } + if tokID := tok.GetID(); tokID == nil || *tokID != id { + return false, merrors.InvalidArgument("replace object id mismatch") + } + m.data[id] = cloneToken(tok) + return true, nil + } + return false, nil +} + +func (m *memoryTokenRepository) Upsert(ctx context.Context, obj storable.Storable) error { + id := obj.GetID() + if id == nil || *id == bson.NilObjectID { + return m.Insert(ctx, obj, nil) + } + if err := m.Update(ctx, obj); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return m.Insert(ctx, obj, nil) + } + return err + } + return nil +} + +func (m *memoryTokenRepository) PatchMany(_ context.Context, filter builder.Query, patch builder.Patch) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + patchDoc := patch.Build() + count := 0 + for _, id := range m.order { + tok := m.data[id] + if tok != nil && matchToken(filter, tok) { + applyPatch(tok, patchDoc) + count++ + } + } + return count, nil +} + +func (m *memoryTokenRepository) PatchOneByFilter(_ context.Context, filter builder.Query, patch builder.Patch, result storable.Storable) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + patchDoc := patch.Build() + for _, id := range m.order { + tok := m.data[id] + if tok == nil || !matchToken(filter, tok) { + continue + } + applyPatch(tok, patchDoc) + if result != nil { + dst := result.(*model.VerificationToken) + *dst = *cloneToken(tok) + } + return true, nil + } + return false, nil +} + +func (m *memoryTokenRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var count int64 + for _, id := range m.order { + tok := m.data[id] + if tok != nil && matchToken(query, tok) { + count++ + } + } + return count, nil +} + +func (m *memoryTokenRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { + return merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) InsertMany(ctx context.Context, objs []storable.Storable) error { + for _, o := range objs { + if err := m.Insert(ctx, o, nil); err != nil { + return err + } + } + return nil +} + +func (m *memoryTokenRepository) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error { + m.mu.Lock() + var matches []interface{} + for _, id := range m.order { + tok := m.data[id] + if tok != nil && matchToken(query, tok) { + raw, err := bson.Marshal(cloneToken(tok)) + if err != nil { + m.mu.Unlock() + return err + } + matches = append(matches, bson.Raw(raw)) + } + } + m.mu.Unlock() + + cur, err := mongo.NewCursorFromDocuments(matches, nil, nil) + if err != nil { + return err + } + defer func() { + _ = cur.Close(ctx) + }() + + for cur.Next(ctx) { + if err := decoder(cur); err != nil { + return err + } + } + return nil +} + +func (m *memoryTokenRepository) FindAllByFilter(context.Context, builder.Query, any) error { + return merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error { + return merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) Delete(_ context.Context, id bson.ObjectID) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, id) + return nil +} + +func (m *memoryTokenRepository) DeleteMany(context.Context, builder.Query) error { + return merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) CreateIndex(*ri.Definition) error { return nil } + +func (m *memoryTokenRepository) DropIndex(context.Context, string) error { return nil } + +func (m *memoryTokenRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { + return nil, merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson.ObjectID, error) { + return nil, merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) { + return nil, merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { + return nil, merrors.NotImplemented("not needed") +} + +func (m *memoryTokenRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + return cb(ctx) +} + +func (m *memoryTokenRepository) Collection() string { return mservice.VerificationTokens } diff --git a/backend/edge/shared/storage/verification/request.go b/backend/edge/shared/storage/verification/request.go new file mode 100644 index 0000000..72fab88 --- /dev/null +++ b/backend/edge/shared/storage/verification/request.go @@ -0,0 +1,102 @@ +package verification + +import ( + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type IssuanceLimit struct { + ScopeKind string + ScopeHash string + Limit int + WindowStart time.Time + ExpiresAt time.Time +} + +func NewIssuanceLimit(scopeKind, scopeValue string, limit int, window time.Duration, now time.Time) IssuanceLimit { + windowStart := now.UTC().Truncate(window) + return IssuanceLimit{ + ScopeKind: strings.TrimSpace(scopeKind), + ScopeHash: ratelimits.HashScope(scopeKind, scopeValue), + Limit: limit, + WindowStart: windowStart, + ExpiresAt: windowStart.Add(window), + } +} + +type TokenKind = string + +const ( + TokenKindOTP TokenKind = "otp" + TokenKindLink TokenKind = "link" +) + +type Request struct { + AccountRef bson.ObjectID + Purpose model.VerificationPurpose + Target string + ContextRef string + Ttl time.Duration + Kind TokenKind + MaxRetries *int + Cooldown *time.Duration + IdempotencyKey *string // Optional key to make Create idempotent for retries. + IssuanceLimits []IssuanceLimit +} + +func (r *Request) WithIssuanceLimits(limits ...IssuanceLimit) *Request { + r.IssuanceLimits = append([]IssuanceLimit(nil), limits...) + return r +} + +func newRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string, kind TokenKind) *Request { + return &Request{ + AccountRef: accountRef, + Purpose: purpose, + Target: target, + Kind: kind, + Ttl: 15 * time.Minute, // default TTL for verification tokens + } +} + +func NewLinkRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string) *Request { + return newRequest(accountRef, purpose, target, TokenKindLink) +} + +func NewOTPRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string) *Request { + return newRequest(accountRef, purpose, target, TokenKindOTP) +} + +func (r *Request) WithTTL(ttl time.Duration) *Request { + r.Ttl = ttl + return r +} + +func (r *Request) WithMaxRetries(maxRetries int) *Request { + r.MaxRetries = &maxRetries + return r +} + +func (r *Request) WithCooldown(cooldown time.Duration) *Request { + r.Cooldown = &cooldown + return r +} + +func (r *Request) WithIdempotencyKey(key string) *Request { + normalized := strings.TrimSpace(key) + if normalized == "" { + r.IdempotencyKey = nil + return r + } + r.IdempotencyKey = &normalized + return r +} + +func (r *Request) WithContextRef(contextRef string) *Request { + r.ContextRef = strings.TrimSpace(contextRef) + return r +} diff --git a/backend/edge/shared/storage/verification/token_query_matcher_test.go b/backend/edge/shared/storage/verification/token_query_matcher_test.go new file mode 100644 index 0000000..5cc511f --- /dev/null +++ b/backend/edge/shared/storage/verification/token_query_matcher_test.go @@ -0,0 +1,257 @@ +package verification + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func tokenFieldValue(tok *model.VerificationToken, field string) any { + switch field { + case "_id": + return tok.ID + case "createdAt": + return tok.CreatedAt + case "verifyTokenHash": + return tok.VerifyTokenHash + case "salt": + return tok.Salt + case "usedAt": + return tok.UsedAt + case "expiresAt": + return tok.ExpiresAt + case "accountRef": + return tok.AccountRef + case "purpose": + return tok.Purpose + case "target": + return tok.Target + case "contextRef": + return tok.ContextRef + case "idempotencyKey": + if tok.IdempotencyKey == nil { + return nil + } + return *tok.IdempotencyKey + case "maxRetries": + return tok.MaxRetries + case "attempts": + return tok.Attempts + default: + return nil + } +} + +func matchToken(query builder.Query, tok *model.VerificationToken) bool { + if query == nil { + return true + } + return matchBsonD(query.BuildQuery(), tok) +} + +func matchBsonD(filter bson.D, tok *model.VerificationToken) bool { + for _, elem := range filter { + if !matchElem(elem, tok) { + return false + } + } + return true +} + +func matchElem(elem bson.E, tok *model.VerificationToken) bool { + switch elem.Key { + case "$and": + arr, ok := elem.Value.(bson.A) + if !ok { + return false + } + for _, sub := range arr { + d, ok := sub.(bson.D) + if !ok { + return false + } + if !matchBsonD(d, tok) { + return false + } + } + return true + + default: + stored := tokenFieldValue(tok, elem.Key) + if opDoc, ok := elem.Value.(bson.M); ok { + return matchOperator(stored, opDoc) + } + return valuesEqual(stored, elem.Value) + } +} + +func matchOperator(stored any, ops bson.M) bool { + for op, cmpVal := range ops { + switch op { + case "$gt": + if !cmpGt(stored, cmpVal) { + return false + } + case "$lt": + if !cmpLt(stored, cmpVal) { + return false + } + } + } + return true +} + +func cmpGt(stored, cmpVal any) bool { + if si, ok := toInt(stored); ok { + if ci, ok := toInt(cmpVal); ok { + return si > ci + } + } + return timeGt(stored, cmpVal) +} + +func cmpLt(stored, cmpVal any) bool { + if si, ok := toInt(stored); ok { + if ci, ok := toInt(cmpVal); ok { + return si < ci + } + } + return timeLt(stored, cmpVal) +} + +func toInt(v any) (int, bool) { + switch iv := v.(type) { + case int: + return iv, true + case int64: + return int(iv), true + case int32: + return int(iv), true + } + return 0, false +} + +func valuesEqual(a, b any) bool { + if b == nil { + return a == nil || a == (*time.Time)(nil) + } + switch av := a.(type) { + case *time.Time: + if av == nil { + return b == nil + } + if bv, ok := b.(*time.Time); ok { + return av.Equal(*bv) + } + return false + case bson.ObjectID: + if bv, ok := b.(bson.ObjectID); ok { + return av == bv + } + return false + case model.VerificationPurpose: + if bv, ok := b.(model.VerificationPurpose); ok { + return av == bv + } + return false + case string: + if bv, ok := b.(string); ok { + return av == bv + } + return false + } + return false +} + +func timeGt(stored, cmpVal any) bool { + st, ok := toTime(stored) + if !ok { + return false + } + ct, ok := toTime(cmpVal) + if !ok { + return false + } + return st.After(ct) +} + +func timeLt(stored, cmpVal any) bool { + st, ok := toTime(stored) + if !ok { + return false + } + ct, ok := toTime(cmpVal) + if !ok { + return false + } + return st.Before(ct) +} + +func toTime(v any) (time.Time, bool) { + switch tv := v.(type) { + case time.Time: + return tv, true + case *time.Time: + if tv == nil { + return time.Time{}, false + } + return *tv, true + } + return time.Time{}, false +} + +func applyPatch(tok *model.VerificationToken, patchDoc bson.D) { + for _, op := range patchDoc { + switch op.Key { + case "$set": + fields, ok := op.Value.(bson.D) + if !ok { + continue + } + for _, f := range fields { + switch f.Key { + case "usedAt": + if t, ok := f.Value.(time.Time); ok { + tok.UsedAt = &t + } + } + } + case "$inc": + fields, ok := op.Value.(bson.D) + if !ok { + continue + } + for _, f := range fields { + switch f.Key { + case "attempts": + if v, ok := f.Value.(int); ok { + tok.Attempts += v + } + } + } + } + } +} + +func cloneToken(src *model.VerificationToken) *model.VerificationToken { + dst := *src + if src.UsedAt != nil { + t := *src.UsedAt + dst.UsedAt = &t + } + if src.MaxRetries != nil { + v := *src.MaxRetries + dst.MaxRetries = &v + } + if src.Salt != nil { + s := *src.Salt + dst.Salt = &s + } + if src.IdempotencyKey != nil { + k := *src.IdempotencyKey + dst.IdempotencyKey = &k + } + return &dst +} diff --git a/backend/edge/shared/storage/verification/verification.go b/backend/edge/shared/storage/verification/verification.go new file mode 100644 index 0000000..b893a2c --- /dev/null +++ b/backend/edge/shared/storage/verification/verification.go @@ -0,0 +1,15 @@ +package verification + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + // template.DB[*model.VerificationToken] + Create(ctx context.Context, request *Request) (verificationToken string, err error) + CreateWithResult(ctx context.Context, request *Request) (CreateResult, error) + Consume(ctx context.Context, accountRef bson.ObjectID, purpose model.VerificationPurpose, contextRef, verificationToken string) (*model.VerificationToken, error) +} diff --git a/backend/edge/shared/storage/verification/verification_attempts_test.go b/backend/edge/shared/storage/verification/verification_attempts_test.go new file mode 100644 index 0000000..1148a54 --- /dev/null +++ b/backend/edge/shared/storage/verification/verification_attempts_test.go @@ -0,0 +1,148 @@ +package verification + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestConsume_MaxRetriesExceeded(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + r := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithMaxRetries(2) + raw, err := db.Create(ctx, r) + require.NoError(t, err) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + for _, tok := range repo.data { + tok.Attempts = 2 + } + repo.mu.Unlock() + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded)) +} + +func TestConsume_UnderMaxRetriesSucceeds(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + r := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithMaxRetries(3) + raw, err := db.Create(ctx, r) + require.NoError(t, err) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + for _, tok := range repo.data { + tok.Attempts = 2 + } + repo.mu.Unlock() + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestConsume_NoMaxRetriesIgnoresAttempts(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + for _, tok := range repo.data { + tok.Attempts = 100 + } + repo.mu.Unlock() + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestConsume_WrongHashReturnsNotFound(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", "wrong-code") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenNotFound)) +} + +func TestConsume_WrongOTPAttemptsPersistAfterTransactionRollback(t *testing.T) { + db := newRollbackTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create( + ctx, + NewOTPRequest(accountRef, model.PurposeEmailChange, "new@example.com"). + WithTTL(time.Hour). + WithMaxRetries(2), + ) + require.NoError(t, err) + + wrongCode := differentOTP(raw) + for attempt := 1; attempt <= 2; attempt++ { + _, err = db.Consume(ctx, accountRef, model.PurposeEmailChange, "", wrongCode) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenNotFound)) + assert.Equal(t, attempt, storedTokenAttempts(t, db)) + } + + _, err = db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded)) +} + +func TestConsume_ContextMismatchReturnsNotFound(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + otherAccount := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Consume(ctx, otherAccount, model.PurposePasswordReset, "", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenNotFound)) +} + +func differentOTP(raw string) string { + if raw == "000000" { + return "111111" + } + return "000000" +} + +func storedTokenAttempts(t *testing.T, db *verificationDB) int { + t.Helper() + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + defer repo.mu.Unlock() + + require.Len(t, repo.data, 1) + for _, tok := range repo.data { + return tok.Attempts + } + return 0 +} diff --git a/backend/edge/shared/storage/verification/verification_idempotency_test.go b/backend/edge/shared/storage/verification/verification_idempotency_test.go new file mode 100644 index 0000000..5ee9fab --- /dev/null +++ b/backend/edge/shared/storage/verification/verification_idempotency_test.go @@ -0,0 +1,177 @@ +package verification + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestCreate_CooldownBlocksCreation(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Minute) + _, err = db.Create(ctx, r2) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrCooldownActive)) +} + +func TestCreate_CooldownExpiresAllowsCreation(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + time.Sleep(2 * time.Millisecond) + + r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Millisecond) + secondRaw, err := db.Create(ctx, r2) + require.NoError(t, err) + assert.NotEqual(t, firstRaw, secondRaw) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", firstRaw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed)) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", secondRaw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestCreate_CooldownNilIgnored(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) +} + +func TestCreate_IdempotencyKeyReplayReturnsSameToken(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") + firstRaw, err := db.Create(ctx, firstReq) + require.NoError(t, err) + require.NotEmpty(t, firstRaw) + + secondReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") + secondRaw, err := db.Create(ctx, secondReq) + require.NoError(t, err) + assert.Equal(t, firstRaw, secondRaw) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + assert.Len(t, repo.data, 1) + repo.mu.Unlock() +} + +func TestCreateWithResult_IdempotencyReplayReportsNotCreated(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + first, err := db.CreateWithResult(ctx, firstReq) + require.NoError(t, err) + require.NotEmpty(t, first.VerificationToken) + assert.True(t, first.Created) + + secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + second, err := db.CreateWithResult(ctx, secondReq) + require.NoError(t, err) + assert.Equal(t, first.VerificationToken, second.VerificationToken) + assert.False(t, second.Created) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + assert.Len(t, repo.data, 1) + repo.mu.Unlock() +} + +func TestCreateWithResult_IdempotencyReplayReusesExpiredStoredExpiry(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", -time.Minute).WithIdempotencyKey("same-key") + first, err := db.CreateWithResult(ctx, firstReq) + require.NoError(t, err) + require.NotEmpty(t, first.VerificationToken) + require.True(t, first.ExpiresAt.Before(time.Now().UTC()), "test must create an already expired token") + + secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + second, err := db.CreateWithResult(ctx, secondReq) + require.NoError(t, err) + assert.Equal(t, first.VerificationToken, second.VerificationToken) + assert.False(t, second.Created) + assert.True(t, second.ExpiresAt.Equal(first.ExpiresAt), "replay must reuse stored expiry") + assert.True(t, second.ExpiresAt.Before(time.Now().UTC()), "replay must not extend token validity") +} + +func TestCreate_IdempotencyScopeIncludesTarget(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + r1 := req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour).WithIdempotencyKey("same-key") + raw1, err := db.Create(ctx, r1) + require.NoError(t, err) + require.NotEmpty(t, raw1) + + r2 := req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour).WithIdempotencyKey("same-key") + raw2, err := db.Create(ctx, r2) + require.NoError(t, err) + require.NotEmpty(t, raw2) + assert.NotEqual(t, raw1, raw2) + + t1, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw1) + require.NoError(t, err) + assert.Equal(t, "a@example.com", t1.Target) + + t2, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw2) + require.NoError(t, err) + assert.Equal(t, "b@example.com", t2.Target) +} + +func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) { + db := newTestVerificationDBWithFactory(t, &retryingTxFactory{}) + ctx := context.Background() + accountRef := bson.NewObjectID() + + r := req(accountRef, model.PurposePasswordReset, "", time.Hour). + WithCooldown(time.Minute). + WithIdempotencyKey("retry-safe") + + raw, err := db.Create(ctx, r) + require.NoError(t, err) + require.NotEmpty(t, raw) + + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + require.Len(t, repo.data, 1) + for _, tok := range repo.data { + require.NotNil(t, tok.IdempotencyKey) + assert.Equal(t, "retry-safe", *tok.IdempotencyKey) + assert.Nil(t, tok.UsedAt) + assert.Equal(t, tok.VerifyTokenHash, tokenHash(raw)) + } + repo.mu.Unlock() +} diff --git a/backend/edge/shared/storage/verification/verification_test.go b/backend/edge/shared/storage/verification/verification_test.go new file mode 100644 index 0000000..aed6ca7 --- /dev/null +++ b/backend/edge/shared/storage/verification/verification_test.go @@ -0,0 +1,289 @@ +package verification + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestCreate_ReturnsRawToken(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + assert.NotEmpty(t, raw) +} + +func TestCreate_TokenCanBeConsumed(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) + assert.Equal(t, model.PurposePasswordReset, tok.Purpose) + assert.NotNil(t, tok.UsedAt) +} + +func TestConsume_ReturnsCorrectFields(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) + assert.Equal(t, model.PurposeEmailChange, tok.Purpose) + assert.Equal(t, "new@example.com", tok.Target) +} + +func TestConsume_SecondConsumeFailsAlreadyUsed(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), + "second consume should fail with already-used after usedAt is set") +} + +func TestConsume_ExpiredTokenFails(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + // Create with a TTL that is already in the past. + raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenExpired), + "expired token should return explicit expiry error") +} + +func TestConsume_UnknownTokenFails(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + + _, err := db.Consume(ctx, bson.NilObjectID, "", "", "nonexistent-token-value") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenNotFound)) +} + +func TestConsume_AccountActivationWithoutAccountRef(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + raw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", raw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) + assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) +} + +func TestCreate_InvalidatesPreviousToken(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + oldRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + newRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + assert.NotEqual(t, oldRaw, newRaw, "new token should differ from old one") + + // Old token is no longer consumable — invalidated (usedAt set) by the second Create. + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", oldRaw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), + "old token should return already-used after invalidation") + + // New token works fine. + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", newRaw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + // First issue for the same verification scope. + firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + + // Second issue during resend should rotate token instead of failing with duplicate key. + secondRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + assert.NotEqual(t, firstRaw, secondRaw) + + // Old token becomes unusable after reissue. + _, err = db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", firstRaw) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed)) + + // New token is valid. + tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", secondRaw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) + assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) + + // Non-idempotent requests should still persist unique internal keys, + // preventing uniqueness collisions on (accountRef, purpose, target, idempotencyKey). + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + defer repo.mu.Unlock() + + keys := map[string]struct{}{} + for _, stored := range repo.data { + if stored.AccountRef != accountRef || stored.Purpose != model.PurposeAccountActivation { + continue + } + require.NotNil(t, stored.IdempotencyKey) + assert.True(t, strings.HasPrefix(*stored.IdempotencyKey, "auto:")) + keys[*stored.IdempotencyKey] = struct{}{} + } + assert.Len(t, keys, 2) +} + +func TestCreate_InvalidatesMultiplePreviousTokens(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + first, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + second, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + third, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", first) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "first should be invalidated/used") + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", second) + assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second should be invalidated/used") + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", third) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestCreate_DifferentPurposeNotInvalidated(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + resetRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + // Creating an activation token should NOT invalidate the password-reset token. + _, err = db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", resetRaw) + require.NoError(t, err) + assert.Equal(t, model.PurposePasswordReset, tok.Purpose) +} + +func TestCreate_DifferentTargetNotInvalidated(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour)) + require.NoError(t, err) + + // Creating a token for a different target email should NOT invalidate the first. + _, err = db.Create(ctx, req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", firstRaw) + require.NoError(t, err) + assert.Equal(t, "a@example.com", tok.Target) +} + +func TestCreate_DifferentAccountNotInvalidated(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + account1 := bson.NewObjectID() + account2 := bson.NewObjectID() + + raw1, err := db.Create(ctx, req(account1, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + _, err = db.Create(ctx, req(account2, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, account1, model.PurposePasswordReset, "", raw1) + require.NoError(t, err) + assert.Equal(t, account1, tok.AccountRef) +} + +func TestCreate_AlreadyUsedTokenNotInvalidatedAgain(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + // Create and consume first token. + raw1, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw1) + require.NoError(t, err) + + // Create second — the already-consumed token should have usedAt set, + // so the invalidation query (usedAt == nil) should skip it. + // This tests that the PatchMany filter correctly excludes already-used tokens. + raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} + +func TestCreate_ExpiredTokenNotInvalidated(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + // Create a token that is already expired. + _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) + require.NoError(t, err) + + // Create a fresh one — invalidation should skip the expired token (expiresAt > now filter). + raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + require.NoError(t, err) + + tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) +} diff --git a/backend/edge/shared/storage/verification/verification_test_helpers_test.go b/backend/edge/shared/storage/verification/verification_test_helpers_test.go new file mode 100644 index 0000000..1229061 --- /dev/null +++ b/backend/edge/shared/storage/verification/verification_test_helpers_test.go @@ -0,0 +1,103 @@ +package verification + +import ( + "context" + "sync" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func newTestVerificationDB(t *testing.T) *verificationDB { + return newTestVerificationDBWithFactory(t, &passthroughTxFactory{}) +} + +func newRollbackTestVerificationDB(t *testing.T) *verificationDB { + t.Helper() + repo := newMemoryTokenRepository() + logger := zap.NewNop() + return &verificationDB{ + DBImp: template.DBImp[*model.VerificationToken]{ + Logger: logger, + Repository: repo, + }, + tf: &rollbackTxFactory{repo: repo}, + } +} + +func newTestVerificationDBWithFactory(t *testing.T, tf transaction.Factory) *verificationDB { + t.Helper() + repo := newMemoryTokenRepository() + logger := zap.NewNop() + return &verificationDB{ + DBImp: template.DBImp[*model.VerificationToken]{ + Logger: logger, + Repository: repo, + }, + tf: tf, + } +} + +type passthroughTxFactory struct{} + +func (*passthroughTxFactory) CreateTransaction() transaction.Transaction { return &passthroughTx{} } + +type passthroughTx struct{} + +func (*passthroughTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + return cb(ctx) +} + +type retryingTxFactory struct{} + +func (*retryingTxFactory) CreateTransaction() transaction.Transaction { return &retryingTx{} } + +type retryingTx struct{} + +func (*retryingTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + if _, err := cb(ctx); err != nil { + return nil, err + } + return cb(ctx) +} + +type rollbackTxFactory struct { + mu sync.Mutex + repo *memoryTokenRepository +} + +func (f *rollbackTxFactory) CreateTransaction() transaction.Transaction { + return &rollbackTx{factory: f} +} + +type rollbackTx struct { + factory *rollbackTxFactory +} + +func (tx *rollbackTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + tx.factory.mu.Lock() + defer tx.factory.mu.Unlock() + + snapshot := tx.factory.repo.snapshot() + result, err := cb(ctx) + if err != nil { + tx.factory.repo.restore(snapshot) + return nil, err + } + return result, nil +} + +func req(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string, ttl time.Duration) *Request { + return NewLinkRequest(accountRef, purpose, target).WithTTL(ttl) +} + +func contextReq(accountRef bson.ObjectID, contextRef string) *Request { + return NewOTPRequest(accountRef, model.PurposePasswordReset, "user@example.com"). + WithTTL(time.Hour). + WithContextRef(contextRef) +} diff --git a/backend/go.mod b/backend/go.mod index e43372c..e909a1a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,98 @@ module git.sendico.io/tech/sxchnge/bff -go 1.26 +go 1.27.0 + +require ( + github.com/casbin/casbin/v3 v3.11.0 + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/jwtauth/v5 v5.4.0 + github.com/go-chi/metrics v0.1.1 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/google/uuid v1.6.0 + github.com/mattn/go-colorable v0.1.15 + github.com/prometheus/client_golang v1.24.1 + github.com/stretchr/testify v1.12.1 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 + go.mongodb.org/mongo-driver/v2 v2.9.0 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.56.0 + golang.org/x/net v0.58.0 + gopkg.in/yaml.v3 v3.0.1 + moul.io/chizap v1.0.3 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/casbin/govaluate v1.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-chi/chi v1.5.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..246ab68 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,295 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v3 v3.11.0 h1:xVlnpCGs0c2Hp2WuUF36o8iZgEsXUne9/j1qJjOgJPA= +github.com/casbin/casbin/v3 v3.11.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= +github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= +github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= +github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY= +github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE= +github.com/lestrrat-go/jwx/v3 v3.0.2 h1:N+XLjTJEzDZRP3S0SezclXFAfopwL+o5vaL+qg6rX1I= +github.com/lestrrat-go/jwx/v3 v3.0.2/go.mod h1:qO9w1qkQH77a0r9OXNM33YQPnV/evetKYRg58h1rBNE= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0= +go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +moul.io/chizap v1.0.3 h1:mliXvvuS5HVo3QP8qPXczWtRM5dQ9UmK3bBVIkZo6ek= +moul.io/chizap v1.0.3/go.mod h1:pq4R9kGLwz4XjBc4hodQYuoE7Yc9RUabLBFyyi2uErk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/internal/architecture/boundary_test.go b/backend/internal/architecture/boundary_test.go new file mode 100644 index 0000000..280c91d --- /dev/null +++ b/backend/internal/architecture/boundary_test.go @@ -0,0 +1,42 @@ +package architecture + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Provider access is HTTP only. Local Mongo repositories belong to SXCHNGE. +func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) { + err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".go" { + return nil + } + f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, im := range f.Imports { + name, err := strconv.Unquote(im.Path.Value) + if err != nil { + return err + } + for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/"} { + if strings.Contains(name, blocked) { + t.Errorf("%s imports private provider transport or shared runtime: %s", path, name) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/architecture/contracts_test.go b/backend/internal/architecture/contracts_test.go new file mode 100644 index 0000000..dab6966 --- /dev/null +++ b/backend/internal/architecture/contracts_test.go @@ -0,0 +1,60 @@ +package architecture + +import ( + "gopkg.in/yaml.v3" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPublicContractReferencesAreSelfContained(t *testing.T) { + root := filepath.Join("..", "..", "contracts", "sendico") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".yaml" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + var visit func(*yaml.Node) + visit = func(n *yaml.Node) { + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + if n.Content[i].Value == "$ref" { + ref := strings.SplitN(n.Content[i+1].Value, "#", 2)[0] + if ref == "" { + continue + } + target := filepath.Clean(filepath.Join(filepath.Dir(path), ref)) + relative, err := filepath.Rel(root, target) + if err != nil || strings.HasPrefix(relative, "..") || strings.Contains(ref, "://") { + t.Errorf("%s: nonlocal reference %s", path, ref) + continue + } + if _, err := os.Stat(target); err != nil { + t.Errorf("%s: missing reference %s", path, ref) + } + } + } + } + for _, child := range n.Content { + visit(child) + } + } + visit(&doc) + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/architecture/errors_test.go b/backend/internal/architecture/errors_test.go new file mode 100644 index 0000000..56a6edb --- /dev/null +++ b/backend/internal/architecture/errors_test.go @@ -0,0 +1,69 @@ +package architecture + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "testing" +) + +// Keep the backend error-model guideline executable, including aliased imports. +func TestErrorConstructorsUseProjectModel(t *testing.T) { + root := filepath.Join("..", "..") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".go" { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + imports := map[string]string{} + for _, imp := range file.Imports { + pkg, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if pkg != "errors" && pkg != "fmt" { + continue + } + alias := pkg + if imp.Name != nil { + alias = imp.Name.Name + } + if alias == "." { + t.Errorf("%s: dot import obscures error constructors", path) + } + imports[alias] = pkg + } + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + if (imports[ident.Name] == "errors" && sel.Sel.Name == "New") || (imports[ident.Name] == "fmt" && sel.Sel.Name == "Errorf") { + t.Errorf("%s: use backend/pkg/merrors", fset.Position(call.Pos())) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/browser/policy.go b/backend/internal/browser/policy.go deleted file mode 100644 index 2e029a9..0000000 --- a/backend/internal/browser/policy.go +++ /dev/null @@ -1,118 +0,0 @@ -// Adapted from Sendico edge/shared/auth/browser; see backend/PROVENANCE.md. -package browser - -import ( - "errors" - "net/http" - "net/url" - "strings" - "time" -) - -const ( - SessionCookie = "__Host-sxchnge_session" - CSRFHeader = "X-CSRF-Protection" - CSRFValue = "1" -) - -var ( - ErrOrigin = errors.New("browser origin is not allowed") - ErrCSRF = errors.New("browser CSRF protection is required") - ErrSession = errors.New("browser session is missing or invalid") -) - -type Policy struct{ origins map[string]struct{} } - -// New accepts explicit HTTPS origins. Browser session transport is chosen by the -// server route; a client-controlled transport header cannot turn protection off. -func New(allowedOrigins []string) (*Policy, error) { - p := &Policy{origins: make(map[string]struct{}, len(allowedOrigins))} - for _, value := range allowedOrigins { - origin, ok := canonicalOrigin(value) - if !ok { - return nil, ErrOrigin - } - p.origins[origin] = struct{}{} - } - if len(p.origins) == 0 { - return nil, ErrOrigin - } - return p, nil -} - -func canonicalOrigin(value string) (string, bool) { - u, err := url.Parse(value) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" { - return "", false - } - return "https://" + strings.ToLower(u.Host), true -} - -// Validate must guard every browser-session mutation, including login/logout. -// It assumes CORS never grants credentialed access to untrusted origins. -func (p *Policy) Validate(r *http.Request) error { - if r == nil { - return ErrCSRF - } - switch r.Method { - case http.MethodGet, http.MethodHead, http.MethodOptions: - return nil - } - if r.Header.Get(CSRFHeader) != CSRFValue { - return ErrCSRF - } - origin, ok := canonicalOrigin(r.Header.Get("Origin")) - if !ok || p == nil { - return ErrOrigin - } - if _, allowed := p.origins[origin]; !allowed { - return ErrOrigin - } - return nil -} - -func Credential(r *http.Request) (string, error) { - if r == nil { - return "", ErrSession - } - var value string - for _, c := range r.Cookies() { - if c.Name != SessionCookie { - continue - } - if value != "" || c.Value == "" { - return "", ErrSession - } - value = c.Value - } - if value == "" { - return "", ErrSession - } - return value, nil -} - -func SetSession(w http.ResponseWriter, token string, now, expires time.Time) error { - age := int(expires.Sub(now).Seconds()) - if token == "" || age < 1 { - return ErrSession - } - c := cookie(token) - c.Expires = expires.UTC() - c.MaxAge = age - if c.Valid() != nil { - return ErrSession - } - http.SetCookie(w, c) - return nil -} - -func ClearSession(w http.ResponseWriter) { - c := cookie("") - c.Expires = time.Unix(1, 0).UTC() - c.MaxAge = -1 - http.SetCookie(w, c) -} - -func cookie(value string) *http.Cookie { - return &http.Cookie{Name: SessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode} -} diff --git a/backend/internal/browser/policy_test.go b/backend/internal/browser/policy_test.go deleted file mode 100644 index ba01a3c..0000000 --- a/backend/internal/browser/policy_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package browser - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestMutationRequiresOriginAndHeader(t *testing.T) { - p, err := New([]string{"https://app.example.com"}) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - origin, header, transport string - valid bool - }{ - {"https://app.example.com", "1", "", true}, - {"https://APP.example.com", "1", "", true}, - {"https://evil.example.com", "1", "", false}, - {"https://app.example.com.evil.test", "1", "", false}, - {"https://app.example.com/path", "1", "", false}, - {"https://app.example.com?query", "1", "", false}, - {"https://app.example.com#fragment", "1", "", false}, - {"https://app.example.com", "", "bearer", false}, - {"", "1", "bearer", false}, - {"null", "1", "cookie", false}, - } { - r := httptest.NewRequest(http.MethodPost, "https://app.example.com/api/session", nil) - r.Header.Set("Origin", tc.origin) - r.Header.Set(CSRFHeader, tc.header) - r.Header.Set("X-Auth-Transport", tc.transport) - if got := p.Validate(r); (got == nil) != tc.valid { - t.Errorf("%+v: %v", tc, got) - } - } - if p.Validate(httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)) != nil { - t.Fatal("read rejected") - } - for _, origin := range []string{"*", "http://app.example.com", "https://user@app.example.com", "https://app.example.com/"} { - if _, err := New([]string{origin}); err == nil { - t.Errorf("accepted configuration %q", origin) - } - } - if _, err := New(nil); err == nil { - t.Fatal("empty origin policy accepted") - } -} - -func TestSessionCookie(t *testing.T) { - now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC) - w := httptest.NewRecorder() - if err := SetSession(w, "opaque-token", now, now.Add(time.Hour)); err != nil { - t.Fatal(err) - } - c := w.Result().Cookies()[0] - if c.Name != SessionCookie || !c.Secure || !c.HttpOnly || c.Path != "/" || c.Domain != "" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 3600 { - t.Fatalf("bad cookie: %+v", c) - } - r := httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil) - r.AddCookie(c) - if value, err := Credential(r); err != nil || value != "opaque-token" { - t.Fatal("cookie not read") - } - r.AddCookie(c) - if _, err := Credential(r); err == nil { - t.Fatal("ambiguous credential accepted") - } - if err := SetSession(httptest.NewRecorder(), "token", now, now); err == nil { - t.Fatal("expired cookie accepted") - } - w = httptest.NewRecorder() - ClearSession(w) - c = w.Result().Cookies()[0] - if c.MaxAge != -1 || c.Value != "" || c.Path != "/" || !c.Secure { - t.Fatal("cookie not cleared safely") - } -} diff --git a/backend/internal/credential/token.go b/backend/internal/credential/token.go deleted file mode 100644 index e61c2ab..0000000 --- a/backend/internal/credential/token.go +++ /dev/null @@ -1,29 +0,0 @@ -// Adapted from Sendico verification/hash.go; see backend/PROVENANCE.md. -package credential - -import ( - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" -) - -// New returns an opaque credential and its storage digest. Store only the digest. -// This is for high-entropy tokens, never passwords or short numeric OTP codes. -func New() (raw, digest string, err error) { - bytes := make([]byte, 32) - if _, err = rand.Read(bytes); err != nil { - return "", "", err - } - raw = base64.RawURLEncoding.EncodeToString(bytes) - return raw, Digest(raw), nil -} - -func Digest(raw string) string { - sum := sha256.Sum256([]byte(raw)) - return base64.RawURLEncoding.EncodeToString(sum[:]) -} - -func Matches(raw, digest string) bool { - return raw != "" && subtle.ConstantTimeCompare([]byte(Digest(raw)), []byte(digest)) == 1 -} diff --git a/backend/internal/credential/token_test.go b/backend/internal/credential/token_test.go deleted file mode 100644 index 1c5ef23..0000000 --- a/backend/internal/credential/token_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package credential - -import ( - "encoding/base64" - "testing" -) - -func TestOpaqueCredential(t *testing.T) { - raw, digest, err := New() - if err != nil { - t.Fatal(err) - } - decoded, err := base64.RawURLEncoding.DecodeString(raw) - if err != nil || len(decoded) != 32 { - t.Fatal("credential must contain 256 random bits") - } - if raw == digest || !Matches(raw, digest) || Matches("other", digest) || Matches("", Digest("")) { - t.Fatal("invalid digest matching behavior") - } - other, _, err := New() - if err != nil || other == raw { - t.Fatal("credentials must be independently generated") - } -} diff --git a/backend/internal/evidence/key.go b/backend/internal/evidence/key.go deleted file mode 100644 index 287e669..0000000 --- a/backend/internal/evidence/key.go +++ /dev/null @@ -1,43 +0,0 @@ -// Adapted from Sendico edge/shared/storage/s3key; see backend/PROVENANCE.md. -package evidence - -import ( - "errors" - "path" - "strings" - "unicode" -) - -var ErrInvalidKey = errors.New("invalid evidence key") - -// JoinKey confines a server-generated object reference to a relative prefix. -// It is not an ownership check or a local-filesystem path sanitizer. Inputs must -// not be URL-decoded or interpreted as filesystem paths after validation. -func JoinKey(prefix, reference string) (string, error) { - a, err := relative(prefix) - if err != nil { - return "", err - } - b, err := relative(reference) - if err != nil { - return "", err - } - return path.Join(a, b), nil -} - -func relative(value string) (string, error) { - value = strings.TrimSpace(value) - if strings.ContainsAny(value, "\\%") || strings.ContainsFunc(value, unicode.IsControl) { - return "", ErrInvalidKey - } - for _, segment := range strings.Split(value, "/") { - if segment == ".." { - return "", ErrInvalidKey - } - } - clean := path.Clean(value) - if value == "" || clean == "." || path.IsAbs(clean) { - return "", ErrInvalidKey - } - return clean, nil -} diff --git a/backend/internal/evidence/key_test.go b/backend/internal/evidence/key_test.go deleted file mode 100644 index d9a7c85..0000000 --- a/backend/internal/evidence/key_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package evidence - -import "testing" - -func TestKeyConfinement(t *testing.T) { - for _, ref := range []string{"", ".", "..", "../other", "/other", "a/../../other", "a\\..\\other", "%2e%2e/other", "bad\x00key"} { - if _, err := JoinKey("customer/documents", ref); err == nil { - t.Errorf("accepted %q", ref) - } - if _, err := JoinKey(ref, "document"); err == nil { - t.Errorf("accepted prefix %q", ref) - } - } - got, err := JoinKey("customer/documents/", "passport/front") - if err != nil || got != "customer/documents/passport/front" { - t.Fatalf("got %q, %v", got, err) - } -} diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go deleted file mode 100644 index 50a5eba..0000000 --- a/backend/internal/httpapi/router.go +++ /dev/null @@ -1,28 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "net/http" -) - -// New is the operational shell. Product routes will be added only with their -// persistence, authentication and provider adapters, never simulated success. -func New() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - write(w, http.StatusOK, "ok") - }) - mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { - write(w, http.StatusServiceUnavailable, "not_configured") - }) - return mux -} - -func write(w http.ResponseWriter, status int, value string) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(struct { - Status string `json:"status"` - }{value}) -} diff --git a/backend/internal/httpapi/router_test.go b/backend/internal/httpapi/router_test.go deleted file mode 100644 index 3bc4efe..0000000 --- a/backend/internal/httpapi/router_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package httpapi - -import ( - "net/http/httptest" - "testing" -) - -func TestOperationalShellDoesNotClaimReadiness(t *testing.T) { - handler := New() - for path, want := range map[string]int{"/healthz": 200, "/readyz": 503, "/api/v1/session": 404} { - w := httptest.NewRecorder() - handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) - if w.Code != want { - t.Errorf("%s: %d, want %d", path, w.Code, want) - } - } -} diff --git a/backend/internal/profile/runtime.go b/backend/internal/profile/runtime.go new file mode 100644 index 0000000..d6bddf5 --- /dev/null +++ b/backend/internal/profile/runtime.go @@ -0,0 +1,22 @@ +// Package profile selects the locally owned components of the copied BFF. +package profile + +import ( + "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" +) + +const Accounts = "accounts" + +func Runtime() api.RuntimeConfig { + return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap} +} + +func bootstrap(a api.API) (api.RuntimeComponents, error) { + permissions := a.Permissions() + accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), permissions.Enforcer(), permissions.Manager().Role(), &a.Config().Mw.Password) + if err != nil { + return nil, err + } + return api.RuntimeComponents{Accounts: accounts}, nil +} diff --git a/backend/pkg/api/http/methods.go b/backend/pkg/api/http/methods.go new file mode 100644 index 0000000..ec73a29 --- /dev/null +++ b/backend/pkg/api/http/methods.go @@ -0,0 +1,36 @@ +package api + +import "fmt" + +type HTTPMethod int + +const ( + Get HTTPMethod = iota + Post + Put + Patch + Delete + Options + Head +) + +func HTTPMethod2String(method HTTPMethod) string { + switch method { + case Get: + return "GET" + case Post: + return "POST" + case Put: + return "PUT" + case Delete: + return "DELETE" + case Patch: + return "PATCH" + case Options: + return "OPTIONS" + case Head: + return "HEAD" + default: + return fmt.Sprintf("unknown: %d", method) + } +} diff --git a/backend/pkg/api/http/response/response.go b/backend/pkg/api/http/response/response.go new file mode 100644 index 0000000..71681e5 --- /dev/null +++ b/backend/pkg/api/http/response/response.go @@ -0,0 +1,228 @@ +package response + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.uber.org/zap" +) + +// BaseResponse is a general structure for all API responses. +type BaseResponse struct { + Status string `json:"status"` // "success" or "error" + Data any `json:"data"` // The actual data payload or the error details +} + +// ErrorResponse provides more details about an error. +type ErrorResponse struct { + Code int `json:"code"` // A unique identifier for the error type, useful for client handling + Error string `json:"error"` + Source string `json:"source"` + Details any `json:"details"` // Additional details or hints about the error, if necessary +} + +func errMessage(err error) string { + if err != nil { + return err.Error() + } + return "" +} + +func logRequest(logger mlogger.Logger, r *http.Request, message string) { + logger.Debug( + message, + zap.String("host", r.Host), + zap.String("address", r.RemoteAddr), + zap.String("method", r.Method), + zap.String("request_uri", r.RequestURI), + zap.String("proto", r.Proto), + zap.String("user_agent", r.UserAgent()), + ) +} + +func writeJSON(logger mlogger.Logger, w http.ResponseWriter, r *http.Request, code int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(&payload); err != nil { + logger.Warn("Failed to encode JSON response", + zap.Error(err), + zap.String("host", r.Host), + zap.String("address", r.RemoteAddr), + zap.String("method", r.Method), + zap.String("request_uri", r.RequestURI), + zap.String("proto", r.Proto), + zap.String("user_agent", r.UserAgent())) + } +} + +func errorf( + logger mlogger.Logger, + w http.ResponseWriter, r *http.Request, + source mservice.Type, code int, message string, details any, +) { + logRequest(logger, r, message) + + errorMessage := BaseResponse{ + Status: api.MSError, + Data: ErrorResponse{ + Code: code, + Details: details, + Source: source, + Error: message, + }, + } + + writeJSON(logger, w, r, code, errorMessage) +} + +func Accepted(logger mlogger.Logger, data any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := BaseResponse{ + Status: api.MSSuccess, + Data: data, + } + writeJSON(logger, w, r, http.StatusAccepted, resp) + } +} + +func Ok(logger mlogger.Logger, data any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := BaseResponse{ + Status: api.MSSuccess, + Data: data, + } + writeJSON(logger, w, r, http.StatusOK, resp) + } +} + +func Created(logger mlogger.Logger, data any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := BaseResponse{ + Status: api.MSSuccess, + Data: data, + } + writeJSON(logger, w, r, http.StatusCreated, resp) + } +} + +func Auto(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + if err == nil { + return Success(logger) + } + if errors.Is(err, merrors.ErrAccessDenied) { + return AccessDenied(logger, source, errMessage(err)) + } + if errors.Is(err, merrors.ErrDocumentsReportOwned) { + return Error(logger, source, http.StatusConflict, "documents_report_owned", errMessage(err)) + } + if errors.Is(err, merrors.ErrDataConflict) { + return DataConflict(logger, source, errMessage(err)) + } + if errors.Is(err, merrors.ErrInvalidArg) { + return BadRequest(logger, source, "invalid_argument", errMessage(err)) + } + if errors.Is(err, merrors.ErrNoData) { + return NotFound(logger, source, errMessage(err)) + } + if errors.Is(err, merrors.ErrUnauthorized) { + return Unauthorized(logger, source, errMessage(err)) + } + if errors.Is(err, merrors.ErrServiceUnavailable) { + return Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", errMessage(err)) + } + return Internal(logger, source, err) +} + +func Internal(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusInternalServerError, "internal_error", errMessage(err)) + } +} + +func NotImplemented(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusNotImplemented, "not_implemented", hint) + } +} + +func BadRequest(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusBadRequest, err, hint) + } +} + +func Gone(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusGone, err, hint) + } +} + +func BadQueryParam(logger mlogger.Logger, source mservice.Type, param string, err error) http.HandlerFunc { + return BadRequest(logger, source, "invalid_query_parameter", fmt.Sprintf("Failed to parse '%s': %v", param, err)) +} + +func BadReference(logger mlogger.Logger, source mservice.Type, refName, refVal string, err error) http.HandlerFunc { + return BadRequest(logger, source, "broken_reference", + fmt.Sprintf("broken object reference: %s = %s, error: %v", refName, refVal, err)) +} + +func BadPayload(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + return BadRequest(logger, source, "broken_payload", + fmt.Sprintf("broken '%s' object payload, error: %v", source, err)) +} + +func DataConflict(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusConflict, "data_conflict", hint) + } +} + +func Error(logger mlogger.Logger, source mservice.Type, code int, errType, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, code, errType, hint) + } +} + +func ErrorWithDetails(logger mlogger.Logger, source mservice.Type, code int, errType string, details any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, code, errType, details) + } +} + +func AccessDenied(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return Error(logger, source, http.StatusForbidden, "access_denied", hint) +} + +func Forbidden(logger mlogger.Logger, source mservice.Type, errType, hint string) http.HandlerFunc { + return Error(logger, source, http.StatusForbidden, errType, hint) +} + +func TooManyRequests(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return Error(logger, source, http.StatusTooManyRequests, "too_many_requests", hint) +} + +func LicenseRequired(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusPaymentRequired, "license_required", hint) + } +} + +func Unauthorized(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusUnauthorized, "unauthorized", hint) + } +} + +func NotFound(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + errorf(logger, w, r, source, http.StatusNotFound, "not_found", hint) + } +} diff --git a/backend/pkg/api/http/response/response_test.go b/backend/pkg/api/http/response/response_test.go new file mode 100644 index 0000000..b65c101 --- /dev/null +++ b/backend/pkg/api/http/response/response_test.go @@ -0,0 +1,57 @@ +package response + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestAutoMapsServiceUnavailable(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil) + recorder := httptest.NewRecorder() + + Auto(zap.NewNop(), mservice.Type("test"), merrors.ServiceUnavailable("dependency down")).ServeHTTP(recorder, req) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.True(t, strings.Contains(recorder.Body.String(), "service_unavailable")) +} + +func TestAutoMapsDocumentsReportOwned(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/reports", nil) + recorder := httptest.NewRecorder() + + Auto(zap.NewNop(), mservice.Type("documents_report"), merrors.DocumentsReportOwned("report has an active payout")).ServeHTTP(recorder, req) + + var payload struct { + Data ErrorResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) + require.Equal(t, http.StatusConflict, recorder.Code) + require.Equal(t, "documents_report_owned", payload.Data.Error) + require.Equal(t, "report has an active payout", payload.Data.Details) +} + +func TestErrorWithDetailsPreservesStructuredPayload(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/actions", nil) + recorder := httptest.NewRecorder() + + ErrorWithDetails(zap.NewNop(), mservice.Type("test"), http.StatusConflict, "conflict", map[string]string{ + "operation_ref": "operation-1", + }).ServeHTTP(recorder, req) + + var payload struct { + Data struct { + Details map[string]string `json:"details"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) + require.Equal(t, "operation-1", payload.Data.Details["operation_ref"]) +} diff --git a/backend/pkg/api/http/response/result.go b/backend/pkg/api/http/response/result.go new file mode 100644 index 0000000..0e13b93 --- /dev/null +++ b/backend/pkg/api/http/response/result.go @@ -0,0 +1,19 @@ +package response + +import ( + "net/http" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +type Result struct { + Result bool `json:"result"` +} + +func Success(logger mlogger.Logger) http.HandlerFunc { + return Ok(logger, Result{Result: true}) +} + +func Failed(logger mlogger.Logger) http.HandlerFunc { + return Accepted(logger, Result{Result: false}) +} diff --git a/backend/pkg/api/http/status.go b/backend/pkg/api/http/status.go new file mode 100644 index 0000000..76f0b7a --- /dev/null +++ b/backend/pkg/api/http/status.go @@ -0,0 +1,8 @@ +package api + +const ( + MSSuccess string = "success" + MSProcessed string = "processed" + MSError string = "error" + MSRequest string = "request" +) diff --git a/backend/pkg/api/routers/health.go b/backend/pkg/api/routers/health.go new file mode 100644 index 0000000..c03e723 --- /dev/null +++ b/backend/pkg/api/routers/health.go @@ -0,0 +1,18 @@ +package routers + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/internal/healthimp" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" +) + +type Health interface { + SetStatus(status health.ServiceStatus) + SetStatusWithReason(status health.ServiceStatus, reason string) + Finish() +} + +func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) { + return healthimp.NewRouter(logger, router, endpoint), nil +} diff --git a/backend/pkg/api/routers/health/status.go b/backend/pkg/api/routers/health/status.go new file mode 100644 index 0000000..9768bea --- /dev/null +++ b/backend/pkg/api/routers/health/status.go @@ -0,0 +1,10 @@ +package health + +type ServiceStatus string + +const ( + SSCreated ServiceStatus = "created" + SSStarting ServiceStatus = "starting" + SSRunning ServiceStatus = "ok" + SSTerminating ServiceStatus = "deactivating" +) diff --git a/backend/pkg/api/routers/internal/healthimp/health.go b/backend/pkg/api/routers/internal/healthimp/health.go new file mode 100644 index 0000000..86ddb5d --- /dev/null +++ b/backend/pkg/api/routers/internal/healthimp/health.go @@ -0,0 +1,76 @@ +package healthimp + +import ( + "net/http" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +type Router struct { + logger mlogger.Logger + status *Status +} + +func (hr *Router) SetStatus(status health.ServiceStatus) { + hr.SetStatusWithReason(status, defaultReason(status)) +} + +func (hr *Router) SetStatusWithReason(status health.ServiceStatus, reason string) { + previous, changed := hr.status.transition(status) + if !changed { + return + } + reason = strings.TrimSpace(reason) + if reason == "" { + reason = defaultReason(status) + } + hr.logger.Info("Health status changed", + zap.String("previous_status", string(previous)), + zap.String("status", string(status)), + zap.String("reason", reason), + ) +} + +func (hr *Router) Finish() { + hr.status.Finish() + hr.logger.Debug("Stopped") +} + +func (hr *Router) handle(w http.ResponseWriter, r *http.Request) { + hr.status.healthHandler()(w, r) +} + +func NewRouter(logger mlogger.Logger, router chi.Router, endpoint string) *Router { + hr := Router{ + logger: logger.Named("health_check"), + } + hr.status = StatusHandler(hr.logger) + + logger.Debug("Installing healthcheck middleware...") + router.Group(func(r chi.Router) { + ep := endpoint + "/health" + r.Get(ep, hr.handle) + logger.Info("Health handler installed", zap.String("endpoint", ep)) + }) + + return &hr +} + +func defaultReason(status health.ServiceStatus) string { + switch status { + case health.SSCreated: + return "service created" + case health.SSStarting: + return "service is starting" + case health.SSRunning: + return "service is ready" + case health.SSTerminating: + return "service is shutting down" + default: + return "service health status changed" + } +} diff --git a/backend/pkg/api/routers/internal/healthimp/health_test.go b/backend/pkg/api/routers/internal/healthimp/health_test.go new file mode 100644 index 0000000..038f860 --- /dev/null +++ b/backend/pkg/api/routers/internal/healthimp/health_test.go @@ -0,0 +1,53 @@ +package healthimp + +import ( + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestSetStatusLogsOnlyTransitionsWithReason(t *testing.T) { + core, observed := observer.New(zap.InfoLevel) + router := NewRouter(zap.New(core), chi.NewRouter(), "") + + router.SetStatusWithReason(health.SSStarting, "observability endpoint initialized") + router.SetStatusWithReason(health.SSStarting, "duplicate readiness report") + router.SetStatusWithReason(health.SSRunning, "all configured ingestion drivers are ready") + router.SetStatus(health.SSRunning) + + entries := observed.FilterMessage("Health status changed").All() + if len(entries) != 2 { + t.Fatalf("health transition log count = %d, want 2", len(entries)) + } + assertTransition(t, entries[0].ContextMap(), "created", "starting", "observability endpoint initialized") + assertTransition(t, entries[1].ContextMap(), "starting", "ok", "all configured ingestion drivers are ready") +} + +func TestSetStatusUsesNonEmptyDefaultReason(t *testing.T) { + core, observed := observer.New(zap.InfoLevel) + router := NewRouter(zap.New(core), chi.NewRouter(), "") + + router.SetStatusWithReason(health.SSTerminating, " ") + + entries := observed.FilterMessage("Health status changed").All() + if len(entries) != 1 { + t.Fatalf("health transition log count = %d, want 1", len(entries)) + } + assertTransition(t, entries[0].ContextMap(), "created", "deactivating", "service is shutting down") +} + +func assertTransition(t *testing.T, fields map[string]any, previous, status, reason string) { + t.Helper() + for key, want := range map[string]string{ + "previous_status": previous, + "status": status, + "reason": reason, + } { + if got, ok := fields[key].(string); !ok || got != want { + t.Fatalf("%s = %v, want %q", key, fields[key], want) + } + } +} diff --git a/backend/pkg/api/routers/internal/healthimp/status.go b/backend/pkg/api/routers/internal/healthimp/status.go new file mode 100644 index 0000000..6ca268f --- /dev/null +++ b/backend/pkg/api/routers/internal/healthimp/status.go @@ -0,0 +1,50 @@ +package healthimp + +import ( + "net/http" + "sync" + + "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" + "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +type Status struct { + logger mlogger.Logger + mu sync.RWMutex + status health.ServiceStatus +} + +func (hs *Status) healthHandler() http.HandlerFunc { + hs.mu.RLock() + status := hs.status + hs.mu.RUnlock() + return response.Ok(hs.logger, struct { + Status health.ServiceStatus `json:"status"` + }{ + status, + }) +} + +func (hr *Status) Finish() { + hr.logger.Info("Finished") +} + +func (hs *Status) transition(status health.ServiceStatus) (health.ServiceStatus, bool) { + hs.mu.Lock() + defer hs.mu.Unlock() + previous := hs.status + if previous == status { + return previous, false + } + hs.status = status + return previous, true +} + +func StatusHandler(logger mlogger.Logger) *Status { + hs := Status{ + status: health.SSCreated, + logger: logger.Named("status"), + } + return &hs +} diff --git a/backend/pkg/auth/anyobject/anyobject.go b/backend/pkg/auth/anyobject/anyobject.go new file mode 100644 index 0000000..124da0f --- /dev/null +++ b/backend/pkg/auth/anyobject/anyobject.go @@ -0,0 +1,3 @@ +package anyobject + +const ID = "*" diff --git a/backend/pkg/auth/archivable.go b/backend/pkg/auth/archivable.go new file mode 100644 index 0000000..4eebcf1 --- /dev/null +++ b/backend/pkg/auth/archivable.go @@ -0,0 +1,35 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ArchivableDB implements archive operations with permission checking +type ArchivableDB[T model.PermissionBoundStorable] interface { + // SetArchived sets the archived status of an entity with permission checking + SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error + // IsArchived checks if an entity is archived with permission checking + IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) + + // Archive archives an entity with permission checking (sets archived to true) + Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error + + // Unarchive unarchives an entity with permission checking (sets archived to false) + Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error +} + +// NewArchivableDB creates a new auth.ArchivableDB instance +func NewArchivableDB[T model.PermissionBoundStorable]( + dbImp *template.DBImp[T], + logger mlogger.Logger, + enforcer Enforcer, + createEmpty func() T, + getArchivable func(T) model.Archivable, +) ArchivableDB[T] { + return newArchivableDBImp(dbImp, logger, enforcer, createEmpty, getArchivable) +} diff --git a/backend/pkg/auth/archivableimp.go b/backend/pkg/auth/archivableimp.go new file mode 100644 index 0000000..b07be6a --- /dev/null +++ b/backend/pkg/auth/archivableimp.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// ArchivableDB implements archive operations with permission checking +type ArchivableDBImp[T model.PermissionBoundStorable] struct { + dbImp *template.DBImp[T] + logger mlogger.Logger + enforcer Enforcer + createEmpty func() T + getArchivable func(T) model.Archivable +} + +// NewArchivableDB creates a new auth.ArchivableDB instance +func newArchivableDBImp[T model.PermissionBoundStorable]( + dbImp *template.DBImp[T], + logger mlogger.Logger, + enforcer Enforcer, + createEmpty func() T, + getArchivable func(T) model.Archivable, +) ArchivableDB[T] { + return &ArchivableDBImp[T]{ + dbImp: dbImp, + logger: logger.Named("archivable"), + enforcer: enforcer, + createEmpty: createEmpty, + getArchivable: getArchivable, + } +} + +// SetArchived sets the archived status of an entity with permission checking +func (db *ArchivableDBImp[T]) SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error { + // Check permissions using enforceObject helper + if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil { + db.logger.Warn("Failed to enforce object permission", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) + return err + } + + // Get the object to check current archived status + obj := db.createEmpty() + if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { + db.logger.Warn("Failed to get object for setting archived status", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) + return err + } + + // Extract archivable from the object + archivable := db.getArchivable(obj) + currentArchived := archivable.IsArchived() + if currentArchived == archived { + db.logger.Debug("No change needed - same archived status", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) + return nil // No change needed + } + + // Set the archived status + patch := repository.Patch().Set(repository.IsArchivedField(), archived) + if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil { + db.logger.Warn("Failed to set archived status on object", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) + return err + } + + db.logger.Debug("Successfully set archived status on object", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) + return nil +} + +// IsArchived checks if an entity is archived with permission checking +func (db *ArchivableDBImp[T]) IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) { + // // Check permissions using single Enforce + if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil { + db.logger.Debug("Permission denied for checking archived status", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionRead))) + return false, merrors.AccessDenied("read", "object", objectRef) + } + obj := db.createEmpty() + if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { + db.logger.Warn("Failed to get object for checking archived status", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return false, err + } + archivable := db.getArchivable(obj) + return archivable.IsArchived(), nil +} + +// Archive archives an entity with permission checking (sets archived to true) +func (db *ArchivableDBImp[T]) Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + return db.SetArchived(ctx, accountRef, objectRef, true) +} + +// Unarchive unarchives an entity with permission checking (sets archived to false) +func (db *ArchivableDBImp[T]) Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + return db.SetArchived(ctx, accountRef, objectRef, false) +} diff --git a/backend/pkg/auth/authorizer.go b/backend/pkg/auth/authorizer.go new file mode 100644 index 0000000..0f3a295 --- /dev/null +++ b/backend/pkg/auth/authorizer.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type MembershipChecker interface { + IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) +} + +type ScopedAuthorizer struct { + // Logger receives Debug/Warn messages from the enforcement path. + Logger mlogger.Logger + Membership MembershipChecker + Enforcer Enforcer +} + +// NewScopedAuthorizer constructs a ScopedAuthorizer and validates required dependencies. +// Logger defaults to zap.NewNop() if nil. +func NewScopedAuthorizer(membership MembershipChecker, enforcer Enforcer, logger mlogger.Logger) *ScopedAuthorizer { + if membership == nil { + panic("auth.NewScopedAuthorizer: MembershipChecker must not be nil") + } + if enforcer == nil { + panic("auth.NewScopedAuthorizer: Enforcer must not be nil") + } + if logger == nil { + logger = zap.NewNop() + } + return &ScopedAuthorizer{ + Logger: logger, + Membership: membership, + Enforcer: enforcer, + } +} + +func (a *ScopedAuthorizer) Authorize(ctx context.Context, p ScopedAccessParams) error { + a.Logger.Debug("scoped access check started", + mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), + mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) + + // Personal resources are guarded by ownership alone — membership is not consulted. + // Checking membership first would lock the owner out after org membership is revoked. + if p.OwnerKind == model.OwnerKindAccount { + err := enforceOwnershipAccess(ctx, a.Enforcer, p) + if err != nil && errors.Is(err, merrors.ErrAccessDenied) { + a.Logger.Debug("access denied: not the owner", + mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), + zap.String("action", string(p.Action))) + } + return err + } + + ok, err := a.Membership.IsMember(ctx, p.OrgRef, p.CallerRef) + if err != nil { + a.Logger.Warn("membership check failed", + zap.Error(err), mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef)) + return err + } + if !ok { + a.Logger.Debug("access denied: caller is not a member of the organization", + mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) + return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) + } + + err = enforceOwnershipAccess(ctx, a.Enforcer, p) + if err != nil { + if errors.Is(err, merrors.ErrAccessDenied) { + a.Logger.Debug("access denied", + mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), + mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) + } else { + a.Logger.Warn("enforcer returned error during scoped access check", + zap.Error(err), mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), + zap.String("action", string(p.Action))) + } + } + return err +} diff --git a/backend/pkg/auth/authorizer_test.go b/backend/pkg/auth/authorizer_test.go new file mode 100644 index 0000000..bdbaad5 --- /dev/null +++ b/backend/pkg/auth/authorizer_test.go @@ -0,0 +1,147 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type fakeMembershipChecker struct { + isMember bool + err error +} + +func (f *fakeMembershipChecker) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { + return f.isMember, f.err +} + +func TestScopedAuthorizer_Authorize(t *testing.T) { + caller := bson.NewObjectID() + other := bson.NewObjectID() + org := bson.NewObjectID() + object := bson.NewObjectID() + policy := bson.NewObjectID() + + base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { + return ScopedAccessParams{ + Service: mservice.Recipients, + PolicyRef: policy, + CallerRef: caller, + OrgRef: org, + ObjectRef: object, + OwnerKind: kind, + OwnerRef: owner, + Action: model.ActionRead, + } + } + + t.Run("membership checker error propagates for org-owned resource and is not masked as denied", func(t *testing.T) { + boom := merrors.Define("checker down") + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{err: boom}, + Enforcer: &fakeEnforcer{}, + } + err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) + if !errors.Is(err, boom) { + t.Fatalf("expected underlying error, got %v", err) + } + if errors.Is(err, merrors.ErrAccessDenied) { + t.Fatal("checker error must not be reported as access denied") + } + }) + + t.Run("non-member owning account-owned resource is allowed", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: false}, + Enforcer: &fakeEnforcer{}, + } + if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { + t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) + } + }) + + t.Run("non-member is denied for org-owned resource", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: false}, + Enforcer: &fakeEnforcer{allow: true}, + } + err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member on org resource, got %v", err) + } + }) + + t.Run("member owning account-owned resource is allowed", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{}, + } + if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { + t.Fatalf("expected allow, got %v", err) + } + }) + + t.Run("member accessing another account's resource is denied", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{}, + } + err := a.Authorize(context.Background(), base(model.OwnerKindAccount, other)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + }) + + t.Run("member with org-owned resource allowed when enforcer approves", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{allow: true}, + } + if err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)); err != nil { + t.Fatalf("expected allow, got %v", err) + } + }) + + t.Run("member with org-owned resource denied when enforcer refuses", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{allow: false}, + } + err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + }) + + t.Run("enforcer error propagates and is not masked as denied", func(t *testing.T) { + boom := merrors.Define("policy backend down") + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{err: boom}, + } + err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) + if !errors.Is(err, boom) { + t.Fatalf("expected underlying error, got %v", err) + } + if errors.Is(err, merrors.ErrAccessDenied) { + t.Fatal("enforcer error must not be reported as access denied") + } + }) + + t.Run("unknown owner kind is denied", func(t *testing.T) { + a := &ScopedAuthorizer{Logger: zap.NewNop(), + Membership: &fakeMembershipChecker{isMember: true}, + Enforcer: &fakeEnforcer{allow: true}, + } + err := a.Authorize(context.Background(), base(model.OwnerKind("garbage"), caller)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for unknown owner kind, got %v", err) + } + }) +} diff --git a/backend/pkg/auth/config.go b/backend/pkg/auth/config.go new file mode 100644 index 0000000..0891512 --- /dev/null +++ b/backend/pkg/auth/config.go @@ -0,0 +1,12 @@ +package auth + +import "git.sendico.io/tech/sxchnge/bff/pkg/model" + +type EnforcerType string + +const ( + Casbin EnforcerType = "casbin" + Native EnforcerType = "native" +) + +type Config = model.DriverConfig[EnforcerType] diff --git a/backend/pkg/auth/db.go b/backend/pkg/auth/db.go new file mode 100644 index 0000000..f43841c --- /dev/null +++ b/backend/pkg/auth/db.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type ProtectedDB[T model.PermissionBoundStorable] interface { + Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error + InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error + Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error + Update(ctx context.Context, accountRef bson.ObjectID, object T) error + Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error + DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error + Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error + PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) + Unprotected() template.DB[T] + ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) +} + +func CreateDB[T model.PermissionBoundStorable]( + ctx context.Context, + l mlogger.Logger, + pdb policy.DB, + enforcer Enforcer, + collection mservice.Type, + db *mongo.Database, +) (ProtectedDB[T], error) { + return CreateDBImp[T](ctx, l, pdb, enforcer, collection, db) +} diff --git a/backend/pkg/auth/dbab.go b/backend/pkg/auth/dbab.go new file mode 100644 index 0000000..2cbf2f5 --- /dev/null +++ b/backend/pkg/auth/dbab.go @@ -0,0 +1,51 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type AccountBoundDB[T model.AccountBoundStorable] interface { + Create(ctx context.Context, accountRef bson.ObjectID, object T) error + Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error + Update(ctx context.Context, accountRef bson.ObjectID, object T) error + Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error + Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error + DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error + FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error + ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) + ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) +} + +func CreateAccountBound[T model.AccountBoundStorable]( + ctx context.Context, + logger mlogger.Logger, + pdb policy.DB, + enforcer Enforcer, + collection mservice.Type, + db *mongo.Database, +) (AccountBoundDB[T], error) { + logger = logger.Named("account_bound") + var policy model.PolicyDescription + if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { + logger.Warn("Failed to fetch organization policy description", zap.Error(err)) + return nil, err + } + res := &AccountBoundDBImp[T]{ + Logger: logger, + DBImp: template.Create[T](logger, collection, db), + Enforcer: enforcer, + PermissionRef: policy.ID, + Collection: collection, + } + return res, nil +} diff --git a/backend/pkg/auth/dbimp.go b/backend/pkg/auth/dbimp.go new file mode 100644 index 0000000..19876e3 --- /dev/null +++ b/backend/pkg/auth/dbimp.go @@ -0,0 +1,319 @@ +package auth + +import ( + "context" + "errors" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type ProtectedDBImp[T model.PermissionBoundStorable] struct { + DBImp *template.DBImp[T] + Enforcer Enforcer + PermissionRef bson.ObjectID + Collection mservice.Type +} + +func (db *ProtectedDBImp[T]) enforce(ctx context.Context, action model.Action, object model.PermissionBoundStorable, accountRef, objectRef bson.ObjectID) error { + res, err := db.Enforcer.Enforce(ctx, object.GetPermissionRef(), accountRef, object.GetOrganizationRef(), objectRef, action) + if err != nil { + db.DBImp.Logger.Warn("Failed to enforce permission", + zap.Error(err), mzap.PermissionRef(object.GetPermissionRef()), + mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return err + } + if !res { + db.DBImp.Logger.Debug("Access denied", mzap.PermissionRef(object.GetPermissionRef()), + mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return merrors.AccessDenied(db.Collection, string(action), objectRef) + } + return nil +} + +func (db *ProtectedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { + db.DBImp.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) + + if object.GetPermissionRef() == bson.NilObjectID { + object.SetPermissionRef(db.PermissionRef) + } + object.SetOrganizationRef(organizationRef) + + if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { + return err + } + + if err := db.DBImp.Create(ctx, object); err != nil { + db.DBImp.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) + return err + } + + db.DBImp.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) + return nil +} + +func (db *ProtectedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { + if len(objects) == 0 { + return nil + } + + db.DBImp.Logger.Debug("Attempting to insert many objects", mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), + zap.Int("count", len(objects))) + + // Set permission and organization refs for all objects and enforce permissions + for _, object := range objects { + if object.GetPermissionRef() == bson.NilObjectID { + object.SetPermissionRef(db.PermissionRef) + } + object.SetOrganizationRef(organizationRef) + + if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { + return err + } + } + + if err := db.DBImp.InsertMany(ctx, objects); err != nil { + db.DBImp.Logger.Warn("Failed to insert many objects", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), + zap.Int("count", len(objects))) + return err + } + + db.DBImp.Logger.Debug("Successfully inserted many objects", mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), + zap.Int("count", len(objects))) + return nil +} + +func (db *ProtectedDBImp[T]) enforceObject(ctx context.Context, action model.Action, accountRef, objectRef bson.ObjectID) error { + l, err := db.ListIDs(ctx, action, accountRef, repository.IDFilter(objectRef)) + if err != nil { + db.DBImp.Logger.Warn("Error occured while checking access rights", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return err + } + if len(l) == 0 { + db.DBImp.Logger.Debug("Access denied", zap.String("action", string(action)), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return merrors.AccessDenied(db.Collection, string(action), objectRef) + } + return nil +} + +func (db *ProtectedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { + db.DBImp.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if err := db.enforceObject(ctx, model.ActionRead, accountRef, objectRef); err != nil { + return err + } + + if err := db.DBImp.Get(ctx, objectRef, result); err != nil { + db.DBImp.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) + return err + } + + db.DBImp.Logger.Debug("Successfully retrieved object", + mzap.AccRef(accountRef), mzap.OrgRef(result.GetOrganizationRef()), + mzap.StorableRef(result), mzap.PermissionRef(result.GetPermissionRef())) + return nil +} + +func (db *ProtectedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { + db.DBImp.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) + + if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, *object.GetID()); err != nil { + return err + } + + if err := db.DBImp.Update(ctx, object); err != nil { + db.DBImp.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) + return err + } + + db.DBImp.Logger.Debug("Successfully updated object", + mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), + mzap.StorableRef(object), mzap.PermissionRef(object.GetPermissionRef())) + return nil +} + +func (db *ProtectedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + db.DBImp.Logger.Debug("Attempting to delete object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { + return err + } + + if err := db.DBImp.Delete(ctx, objectRef); err != nil { + db.DBImp.Logger.Warn("Failed to delete object", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + + db.DBImp.Logger.Debug("Successfully deleted object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return nil +} + +func (db *ProtectedDBImp[T]) ListIDs( + ctx context.Context, + action model.Action, + accountRef bson.ObjectID, + query builder.Query, +) ([]bson.ObjectID, error) { + db.DBImp.Logger.Debug("Attempting to list object IDs", + mzap.AccRef(accountRef), zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) + + // 1. Fetch all candidate IDs from the underlying DB + allIDs, err := db.DBImp.ListPermissionBound(ctx, query) + if err != nil { + db.DBImp.Logger.Warn("Failed to list object IDs", zap.Error(err), mzap.AccRef(accountRef), + zap.String("collection", db.Collection), zap.String("action", string(action))) + return nil, err + } + if len(allIDs) == 0 { + db.DBImp.Logger.Debug("No objects found matching filter", mzap.AccRef(accountRef), + zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) + return []bson.ObjectID{}, merrors.NoData(fmt.Sprintf("no %s found", db.Collection)) + } + + // 2. Check read permission for each ID + var allowedIDs []bson.ObjectID + for _, desc := range allIDs { + enforceErr := db.enforce(ctx, action, desc, accountRef, *desc.GetID()) + if enforceErr == nil { + allowedIDs = append(allowedIDs, *desc.GetID()) + } else if !errors.Is(enforceErr, merrors.ErrAccessDenied) { + // If the error is something other than AccessDenied, we want to fail + db.DBImp.Logger.Warn("Error while enforcing read permission", zap.Error(enforceErr), + mzap.PermissionRef(desc.GetPermissionRef()), zap.String("action", string(action)), + mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), + mzap.ObjRef("object_ref", *desc.GetID()), zap.String("collection", db.Collection), + ) + return nil, enforceErr + } + // If AccessDenied, we simply skip that ID. + } + + db.DBImp.Logger.Debug("Successfully enforced read permission on IDs", zap.Int("fetched_count", len(allIDs)), + zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef), + zap.String("collection", db.Collection), zap.String("action", string(action))) + + // 3. Return only the IDs that passed permission checks + return allowedIDs, nil +} + +func (db *ProtectedDBImp[T]) Unprotected() template.DB[T] { + return db.DBImp +} + +func (db *ProtectedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { + return err + } + if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { + db.DBImp.Logger.Warn("Failed to delete dependent object", zap.Error(err)) + return err + } + return nil +} + +func CreateDBImp[T model.PermissionBoundStorable]( + ctx context.Context, + l mlogger.Logger, + pdb policy.DB, + enforcer Enforcer, + collection mservice.Type, + db *mongo.Database, +) (*ProtectedDBImp[T], error) { + logger := l.Named("protected") + var policy model.PolicyDescription + if err := pdb.GetBuiltInPolicy(ctx, collection, &policy); err != nil { + logger.Warn("Failed to fetch policy description", zap.Error(err), zap.String("resource_type", collection)) + return nil, err + } + p := &ProtectedDBImp[T]{ + DBImp: template.Create[T](logger, collection, db), + PermissionRef: policy.ID, + Collection: collection, + Enforcer: enforcer, + } + if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: storable.OrganizationRefField, Sort: ri.Asc}}, + }); err != nil { + logger.Warn("Failed to create index", zap.Error(err), zap.String("resource_type", collection)) + return nil, err + } + + return p, nil +} + +func (db *ProtectedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { + db.DBImp.Logger.Debug("Attempting to patch object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, objectRef); err != nil { + return err + } + + if err := db.DBImp.Repository.Patch(ctx, objectRef, patch); err != nil { + db.DBImp.Logger.Warn("Failed to patch object", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + + db.DBImp.Logger.Debug("Successfully patched object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return nil +} + +func (db *ProtectedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { + db.DBImp.Logger.Debug("Attempting to patch many objects", + mzap.AccRef(accountRef), zap.Any("filter", query.BuildQuery())) + + ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) + if err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + values := make([]any, len(ids)) + for i, id := range ids { + values[i] = id + } + idFilter := repository.Query().In(repository.IDField(), values...) + finalQuery := query.And(idFilter) + + modified, err := db.DBImp.Repository.PatchMany(ctx, finalQuery, patch) + if err != nil { + db.DBImp.Logger.Warn("Failed to patch many objects", zap.Error(err), + mzap.AccRef(accountRef)) + return 0, err + } + + db.DBImp.Logger.Debug("Successfully patched many objects", + mzap.AccRef(accountRef), zap.Int("modified_count", modified)) + return modified, nil +} diff --git a/backend/pkg/auth/dbimpab.go b/backend/pkg/auth/dbimpab.go new file mode 100644 index 0000000..0276ec6 --- /dev/null +++ b/backend/pkg/auth/dbimpab.go @@ -0,0 +1,394 @@ +package auth + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type AccountBoundDBImp[T model.AccountBoundStorable] struct { + Logger mlogger.Logger + DBImp *template.DBImp[T] + Enforcer Enforcer + PermissionRef bson.ObjectID + Collection mservice.Type +} + +func (db *AccountBoundDBImp[T]) enforce(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { + // FIRST: Check if the object's AccountRef equals the calling accountRef - if so, ALLOW + objectAccountRef := object.GetAccountRef() + if objectAccountRef != nil && *objectAccountRef == accountRef { + db.Logger.Debug("Access granted - object belongs to calling account", + mzap.ObjRef("object_account_ref", *objectAccountRef), + mzap.ObjRef("calling_account_ref", accountRef), + zap.String("action", string(action))) + return nil + } + + // SECOND: If not owned by calling account, check organization-level permissions + organizationRef := object.GetOrganizationRef() + res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, organizationRef, action) + if err != nil { + db.Logger.Warn("Failed to enforce permission", + zap.Error(err), mzap.PermissionRef(db.PermissionRef), + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("action", string(action))) + return err + } + if !res { + db.Logger.Debug("Access denied", mzap.PermissionRef(db.PermissionRef), + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("action", string(action))) + return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) + } + return nil +} + +func (db *AccountBoundDBImp[T]) enforceInterface(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { + return db.enforce(ctx, action, object, accountRef) +} + +func (db *AccountBoundDBImp[T]) Create(ctx context.Context, accountRef bson.ObjectID, object T) error { + orgRef := object.GetOrganizationRef() + db.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), + mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) + + // Check organization update permission for create operations + if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { + return err + } + + if err := db.DBImp.Create(ctx, object); err != nil { + db.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) + return err + } + + db.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), + mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) + return nil +} + +func (db *AccountBoundDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { + db.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + // First get the object to check its organization + if err := db.DBImp.Get(ctx, objectRef, result); err != nil { + db.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) + return err + } + + // Check organization read permission + if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { + return err + } + + db.Logger.Debug("Successfully retrieved object", mzap.AccRef(accountRef), + mzap.OrgRef(result.GetOrganizationRef()), zap.String("collection", db.Collection)) + return nil +} + +func (db *AccountBoundDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { + db.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) + + // Check organization update permission + if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { + return err + } + + if err := db.DBImp.Update(ctx, object); err != nil { + db.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) + return err + } + + db.Logger.Debug("Successfully updated object", mzap.AccRef(accountRef), + mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) + return nil +} + +func (db *AccountBoundDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { + db.Logger.Debug("Attempting to patch object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + // First get the object to check its organization + objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) + if err != nil { + db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) + return err + } + if len(objs) == 0 { + db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) + return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) + } + + // Check organization update permission + if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { + return err + } + + if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { + db.Logger.Warn("Failed to patch object", zap.Error(err), mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) + return err + } + + db.Logger.Debug("Successfully patched object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return nil +} + +func (db *AccountBoundDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + db.Logger.Debug("Attempting to delete object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + // First get the object to check its organization + objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) + if err != nil { + db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) + return err + } + if len(objs) == 0 { + db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) + return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) + } + // Check organization update permission for delete operations + if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { + return err + } + + if err := db.DBImp.Delete(ctx, objectRef); err != nil { + db.Logger.Warn("Failed to delete object", zap.Error(err), mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) + return err + } + + db.Logger.Debug("Successfully deleted object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return nil +} + +func (db *AccountBoundDBImp[T]) DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error { + db.Logger.Debug("Attempting to delete many objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + // Get all candidate objects for batch permission checking + allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) + if err != nil { + db.Logger.Warn("Failed to list objects for delete many", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // Use batch enforcement for efficiency + allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionUpdate) + if err != nil { + db.Logger.Warn("Failed to enforce batch permissions for delete many", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // Build query for objects that passed permission check + var allowedIDs []bson.ObjectID + for _, obj := range allObjects { + if allowedResults[*obj.GetID()] { + allowedIDs = append(allowedIDs, *obj.GetID()) + } + } + + if len(allowedIDs) == 0 { + db.Logger.Debug("No objects allowed for deletion", mzap.AccRef(accountRef)) + return nil + } + + // Delete only the allowed objects + allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) + if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { + db.Logger.Warn("Failed to delete many objects", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + db.Logger.Debug("Successfully deleted many objects", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) + return nil +} + +func (db *AccountBoundDBImp[T]) FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error { + db.Logger.Debug("Attempting to find one object", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + // For FindOne, we need to check read permission after finding the object + if err := db.DBImp.FindOne(ctx, query, result); err != nil { + db.Logger.Warn("Failed to find one object", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // Check organization read permission for the found object + if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { + return err + } + + db.Logger.Debug("Successfully found one object", mzap.AccRef(accountRef), + mzap.OrgRef(result.GetOrganizationRef())) + return nil +} + +func (db *AccountBoundDBImp[T]) ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { + db.Logger.Debug("Attempting to list object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + // Get all candidate objects for batch permission checking + allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) + if err != nil { + db.Logger.Warn("Failed to list objects for ID filtering", zap.Error(err), mzap.AccRef(accountRef)) + return nil, err + } + + // Use batch enforcement for efficiency + allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionRead) + if err != nil { + db.Logger.Warn("Failed to enforce batch permissions for ID listing", zap.Error(err), mzap.AccRef(accountRef)) + return nil, err + } + + // Filter to only allowed object IDs + var allowedIDs []bson.ObjectID + for _, obj := range allObjects { + if allowedResults[*obj.GetID()] { + allowedIDs = append(allowedIDs, *obj.GetID()) + } + } + + db.Logger.Debug("Successfully filtered object IDs", zap.Int("total_count", len(allObjects)), + zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef)) + return allowedIDs, nil +} + +func (db *AccountBoundDBImp[T]) ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) { + db.Logger.Debug("Attempting to list account bound objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + // Build query to find objects where accountRef matches OR is null/absent + accountQuery := repository.WithOrg(accountRef, organizationRef) + + // Combine with the provided query + finalQuery := query.And(accountQuery) + + // Get all candidate objects + allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, finalQuery) + if err != nil { + db.Logger.Warn("Failed to list account bound objects", zap.Error(err), mzap.AccRef(accountRef)) + return nil, err + } + + // Filter objects based on read permissions (AccountBoundStorable doesn't have permission info, so we check organization level) + var allowedObjects []model.AccountBoundStorable + for _, obj := range allObjects { + if err := db.enforceInterface(ctx, model.ActionRead, obj, accountRef); err == nil { + allowedObjects = append(allowedObjects, obj) + } else if !errors.Is(err, merrors.ErrAccessDenied) { + // If the error is something other than AccessDenied, we want to fail + db.Logger.Warn("Error while enforcing read permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) + return nil, err + } + // If AccessDenied, we simply skip that object + } + + db.Logger.Debug("Successfully filtered account bound objects", zap.Int("total_count", len(allObjects)), + zap.Int("allowed_count", len(allowedObjects)), mzap.AccRef(accountRef)) + return allowedObjects, nil +} + +func (db *AccountBoundDBImp[T]) GetByAccountRef(ctx context.Context, accountRef bson.ObjectID, result T) error { + db.Logger.Debug("Attempting to get object by account ref", mzap.AccRef(accountRef)) + + // Build query to find objects where accountRef matches OR is null/absent + query := repository.WithoutOrg(accountRef) + + if err := db.DBImp.FindOne(ctx, query, result); err != nil { + db.Logger.Warn("Failed to get object by account ref", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // Check organization read permission for the found object + if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { + return err + } + + db.Logger.Debug("Successfully retrieved object by account ref", mzap.AccRef(accountRef), + mzap.OrgRef(result.GetOrganizationRef())) + return nil +} + +func (db *AccountBoundDBImp[T]) DeleteByAccountRef(ctx context.Context, accountRef bson.ObjectID) error { + db.Logger.Debug("Attempting to delete objects by account ref", mzap.AccRef(accountRef)) + + // Build query to find objects where accountRef matches OR is null/absent + query := repository.WithoutOrg(accountRef) + + // Get all candidate objects for individual permission checking + allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, query) + if err != nil { + db.Logger.Warn("Failed to list objects for delete by account ref", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + // Check permissions for each object individually (AccountBoundStorable doesn't have permission info) + var allowedIDs []bson.ObjectID + for _, obj := range allObjects { + if err := db.enforceInterface(ctx, model.ActionUpdate, obj, accountRef); err == nil { + allowedIDs = append(allowedIDs, *obj.GetID()) + } else if !errors.Is(err, merrors.ErrAccessDenied) { + // If the error is something other than AccessDenied, we want to fail + db.Logger.Warn("Error while enforcing update permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) + return err + } + // If AccessDenied, we simply skip that object + } + + if len(allowedIDs) == 0 { + db.Logger.Debug("No objects allowed for deletion by account ref", mzap.AccRef(accountRef)) + return nil + } + + // Delete only the allowed objects + allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) + if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { + db.Logger.Warn("Failed to delete objects by account ref", zap.Error(err), mzap.AccRef(accountRef)) + return err + } + + db.Logger.Debug("Successfully deleted objects by account ref", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) + return nil +} + +func (db *AccountBoundDBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { + return db.DBImp.DeleteCascade(ctx, objectRef) +} + +// CreateAccountBoundImp creates a concrete AccountBoundDBImp instance for internal use +func CreateAccountBoundImp[T model.AccountBoundStorable]( + ctx context.Context, + logger mlogger.Logger, + pdb policy.DB, + enforcer Enforcer, + collection mservice.Type, + db *mongo.Database, +) (*AccountBoundDBImp[T], error) { + logger = logger.Named("account_bound") + var policy model.PolicyDescription + if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { + logger.Warn("Failed to fetch organization policy description", zap.Error(err)) + return nil, err + } + res := &AccountBoundDBImp[T]{ + Logger: logger, + DBImp: template.Create[T](logger, collection, db), + Enforcer: enforcer, + PermissionRef: policy.ID, + Collection: collection, + } + return res, nil +} diff --git a/backend/pkg/auth/dbimpab_test.go b/backend/pkg/auth/dbimpab_test.go new file mode 100644 index 0000000..529bd33 --- /dev/null +++ b/backend/pkg/auth/dbimpab_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// TestAccountBoundDBImp_Enforce tests the enforce method +func TestAccountBoundDBImp_Enforce(t *testing.T) { + logger := zap.NewNop() + db := &AccountBoundDBImp[model.AccountBoundStorable]{ + Logger: logger, + PermissionRef: bson.NewObjectID(), + Collection: "test_collection", + } + + t.Run("EnforceMethodExists", func(t *testing.T) { + // Test that the enforce method exists and can be called + // This is a basic test to ensure the method signature is correct + assert.NotNil(t, db.enforce) + }) + + t.Run("PermissionRefSet", func(t *testing.T) { + // Test that PermissionRef is properly set + assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) + }) + + t.Run("CollectionSet", func(t *testing.T) { + // Test that Collection is properly set + assert.Equal(t, "test_collection", db.Collection) + }) +} + +// TestAccountBoundDBImp_InterfaceCompliance tests that the struct implements required interfaces +func TestAccountBoundDBImp_InterfaceCompliance(t *testing.T) { + logger := zap.NewNop() + db := &AccountBoundDBImp[model.AccountBoundStorable]{ + Logger: logger, + PermissionRef: bson.NewObjectID(), + Collection: "test_collection", + } + + t.Run("StructInitialization", func(t *testing.T) { + // Test that the struct can be initialized + assert.NotNil(t, db) + assert.NotNil(t, db.Logger) + assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) + assert.NotEmpty(t, db.Collection) + }) + + t.Run("LoggerInitialization", func(t *testing.T) { + // Test that logger is properly initialized + assert.NotNil(t, db.Logger) + }) +} + +// TestAccountBoundDBImp_ErrorHandling tests error handling patterns +func TestAccountBoundDBImp_ErrorHandling(t *testing.T) { + t.Run("AccessDeniedError", func(t *testing.T) { + // Test that AccessDenied error is properly created + err := merrors.AccessDenied("test_collection", "read", bson.NilObjectID) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrAccessDenied)) + }) + + t.Run("ErrorTypeChecking", func(t *testing.T) { + // Test error type checking + accessDeniedErr := merrors.AccessDenied("test", "read", bson.NilObjectID) + otherErr := merrors.Define("other error") + + assert.True(t, errors.Is(accessDeniedErr, merrors.ErrAccessDenied)) + assert.False(t, errors.Is(otherErr, merrors.ErrAccessDenied)) + }) +} diff --git a/backend/pkg/auth/dbimpscoped.go b/backend/pkg/auth/dbimpscoped.go new file mode 100644 index 0000000..525c4b9 --- /dev/null +++ b/backend/pkg/auth/dbimpscoped.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// ownerKindField and ownerRefField are the canonical BSON paths produced by +// model.Owner embedded with bson:"owner". Resources managed by ScopedDBImp +// must use these same tags for BuildScopedQuery to produce correct filters. +const ( + ownerKindField = "owner.kind" + ownerRefField = "owner.ref" +) + +// ScopedDBImp provides generic CRUD, patch, and bulk operations for resources +// that follow the personal-vs-org dual-ownership model (ownerKind + ownerRef). +// +// Unlike ProtectedDBImp, it never uses permissionRef as a routing sentinel. +// Every read/mutate operation calls LoadAndEnforce, which reads ownerKind and +// ownerRef from the authoritative database record before making the access +// decision: +// - personal (ownerKind=account): caller must be the owner (no policy consulted) +// - org (ownerKind=organization): caller must pass the org policy check +// +// This eliminates the permissionRef==NilObjectID pattern that would otherwise +// be required for personal-owned resources under ProtectedDBImp. +type ScopedDBImp[T model.ScopedStorable] struct { + DBImp *template.DBImp[T] + scoped *ScopedDB[T] + Enforcer Enforcer + OrgPolicyRef bson.ObjectID + Collection mservice.Type +} + +// NewScopedDBImp wires a ScopedDBImp. Panics on nil required dependencies to +// surface misconfiguration at startup, matching the contract of NewScopedDB +// and CreateDBImp. +func NewScopedDBImp[T model.ScopedStorable]( + dbImp *template.DBImp[T], + enforcer Enforcer, + membership MembershipChecker, + orgPolicyRef bson.ObjectID, + collection mservice.Type, + newFn func() T, + logger mlogger.Logger, +) *ScopedDBImp[T] { + if dbImp == nil { + panic("auth.NewScopedDBImp: DBImp must not be nil") + } + if enforcer == nil { + panic("auth.NewScopedDBImp: Enforcer must not be nil") + } + if membership == nil { + panic("auth.NewScopedDBImp: MembershipChecker must not be nil") + } + if newFn == nil { + panic("auth.NewScopedDBImp: New must not be nil") + } + if logger == nil { + logger = zap.NewNop() + } + return &ScopedDBImp[T]{ + DBImp: dbImp, + Enforcer: enforcer, + OrgPolicyRef: orgPolicyRef, + Collection: collection, + scoped: NewScopedDB( + dbImp, + NewScopedAuthorizer(membership, enforcer, logger.Named("scoped")), + orgPolicyRef, + collection, + newFn, + logger.Named("scoped"), + ), + } +} + +func (db *ScopedDBImp[T]) loadAndEnforce(ctx context.Context, accountRef, objectRef bson.ObjectID, action model.Action) (T, error) { + return db.scoped.LoadAndEnforce(ctx, accountRef, objectRef, action) +} + +// Unprotected returns the raw DBImp for operations that bypass scoped auth. +// Callers are responsible for their own authorization. +func (db *ScopedDBImp[T]) Unprotected() template.DB[T] { + return db.DBImp +} + +// BuildScopedQuery returns the ownership-aware OR-query for use in List +// implementations. It mirrors the access model used by all other ScopedDBImp +// operations: membership is checked first, then org policy read access. +// +// The returned query always includes personal resources owned by accountRef, +// regardless of the organization they were created in — personal resources +// follow their owner across every org the account is a member of. Org-owned +// resources are bounded to organizationRef and added only when the caller is +// a member of the org AND the org policy grants read access. +// +// Callers must compose the returned query with resource-specific filters +// (additional field conditions, cursor pagination, etc.). +// +// Resources must embed model.Owner with bson:"owner" so the generated field +// paths (owner.kind, owner.ref) match the stored documents. +func (db *ScopedDBImp[T]) BuildScopedQuery(ctx context.Context, accountRef, organizationRef bson.ObjectID) (builder.Query, error) { + personalQ := repository.Query(). + Filter(repository.Field(ownerKindField), model.OwnerKindAccount). + Filter(repository.Field(ownerRefField), accountRef) + + conditions := []builder.Query{personalQ} + + isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) + if err != nil { + // Membership backend failure degrades org access but must not prevent + // the caller from seeing their own personal resources. + db.scoped.Logger.Warn("membership check failed, returning personal-only results", + zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + } else if isMember { + hasOrgAccess, enforceErr := db.Enforcer.Enforce( + ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, model.ActionRead, + ) + if enforceErr != nil { + return nil, enforceErr + } + if hasOrgAccess { + conditions = append(conditions, repository.Query().And( + repository.OrgFilter(organizationRef), + repository.Filter(ownerKindField, model.OwnerKindOrganization), + )) + } + } + + return repository.Query().Or(conditions...), nil +} diff --git a/backend/pkg/auth/dbimpscoped_test.go b/backend/pkg/auth/dbimpscoped_test.go new file mode 100644 index 0000000..2a218bb --- /dev/null +++ b/backend/pkg/auth/dbimpscoped_test.go @@ -0,0 +1,848 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// testScopedObj is a minimal model.ScopedStorable used exclusively by +// ScopedDBImp tests. +type testScopedObj struct { + id bson.ObjectID + orgRef bson.ObjectID + ownerKind model.OwnerKind + ownerRef bson.ObjectID + permissionRef bson.ObjectID +} + +func (o *testScopedObj) GetID() *bson.ObjectID { return &o.id } +func (o *testScopedObj) SetID(id bson.ObjectID) { o.id = id } +func (o *testScopedObj) Update() {} +func (o *testScopedObj) Collection() string { return "test_scoped" } +func (o *testScopedObj) GetOrganizationRef() bson.ObjectID { return o.orgRef } +func (o *testScopedObj) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } +func (o *testScopedObj) GetOwnerKind() model.OwnerKind { return o.ownerKind } +func (o *testScopedObj) GetOwnerRef() bson.ObjectID { return o.ownerRef } +func (o *testScopedObj) SetOwnerKind(k model.OwnerKind) { o.ownerKind = k } +func (o *testScopedObj) SetOwnerRef(ref bson.ObjectID) { o.ownerRef = ref } +func (o *testScopedObj) GetPermissionRef() bson.ObjectID { return o.permissionRef } +func (o *testScopedObj) SetPermissionRef(ref bson.ObjectID) { o.permissionRef = ref } + +var _ model.ScopedStorable = (*testScopedObj)(nil) + +// fakeScopedRepository is a minimal in-memory repository.Repository that +// supports Insert, InsertMany, Get, Update, Patch, Delete, and ListIDs. +// All other methods return merrors.NotImplemented. +type fakeScopedRepository struct { + objects map[bson.ObjectID]*testScopedObj + insertErr error + getErr error + updateErr error + patchErr error + deleteErr error + listIDsErr error + patchedIDs []bson.ObjectID + // patchManyIDs is the set of IDs that PatchMany will record as affected. + // Tests using PatchMany must set this to the expected authorized IDs so the + // fake can simulate per-ID tracking without parsing the bulk filter. + patchManyIDs []bson.ObjectID +} + +func newFakeScopedRepository() *fakeScopedRepository { + return &fakeScopedRepository{objects: make(map[bson.ObjectID]*testScopedObj)} +} + +func (f *fakeScopedRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { + if f.insertErr != nil { + return f.insertErr + } + o := obj.(*testScopedObj) + if o.id == bson.NilObjectID { + o.id = bson.NewObjectID() + } + clone := *o + f.objects[o.id] = &clone + return nil +} + +func (f *fakeScopedRepository) InsertMany(_ context.Context, objects []storable.Storable) error { + if f.insertErr != nil { + return f.insertErr + } + for _, obj := range objects { + o := obj.(*testScopedObj) + if o.id == bson.NilObjectID { + o.id = bson.NewObjectID() + } + clone := *o + f.objects[o.id] = &clone + } + return nil +} + +func (f *fakeScopedRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { + if f.getErr != nil { + return f.getErr + } + obj, ok := f.objects[id] + if !ok { + return merrors.NoData(fmt.Sprintf("test_scoped %s not found", id.Hex())) + } + *result.(*testScopedObj) = *obj + return nil +} + +func (f *fakeScopedRepository) Update(_ context.Context, obj storable.Storable) error { + if f.updateErr != nil { + return f.updateErr + } + o := obj.(*testScopedObj) + clone := *o + f.objects[*o.GetID()] = &clone + return nil +} + +func (f *fakeScopedRepository) Patch(_ context.Context, id bson.ObjectID, _ builder.Patch) error { + if f.patchErr != nil { + return f.patchErr + } + f.patchedIDs = append(f.patchedIDs, id) + return nil +} + +func (f *fakeScopedRepository) Delete(_ context.Context, id bson.ObjectID) error { + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.objects, id) + return nil +} + +func (f *fakeScopedRepository) ListIDs(_ context.Context, _ builder.Query) ([]bson.ObjectID, error) { + if f.listIDsErr != nil { + return nil, f.listIDsErr + } + ids := make([]bson.ObjectID, 0, len(f.objects)) + for id := range f.objects { + ids = append(ids, id) + } + return ids, nil +} + +// Unimplemented repository.Repository stubs. +func (f *fakeScopedRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { + return merrors.NotImplemented("aggregate") +} +func (f *fakeScopedRepository) FindOneByFilter(context.Context, builder.Query, storable.Storable) error { + return merrors.NotImplemented("FindOneByFilter") +} +func (f *fakeScopedRepository) FindFirstByFilter(context.Context, builder.Query, storable.Storable) error { + return merrors.NotImplemented("FindFirstByFilter") +} +func (f *fakeScopedRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error { + return merrors.NotImplemented("FindManyByFilter") +} +func (f *fakeScopedRepository) FindAllByFilter(context.Context, builder.Query, any) error { + return merrors.NotImplemented("FindAllByFilter") +} +func (f *fakeScopedRepository) CountByFilter(context.Context, builder.Query) (int64, error) { + return 0, merrors.NotImplemented("CountByFilter") +} +func (f *fakeScopedRepository) ReplaceOneByFilter(context.Context, builder.Query, storable.Storable) (bool, error) { + return false, merrors.NotImplemented("ReplaceOneByFilter") +} +func (f *fakeScopedRepository) Upsert(context.Context, storable.Storable) error { + return merrors.NotImplemented("Upsert") +} +func (f *fakeScopedRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) { + return false, merrors.NotImplemented("PatchOneByFilter") +} +func (f *fakeScopedRepository) PatchMany(_ context.Context, _ builder.Query, _ builder.Patch) (int, error) { + f.patchedIDs = append(f.patchedIDs, f.patchManyIDs...) + return len(f.patchManyIDs), nil +} +func (f *fakeScopedRepository) DeleteMany(context.Context, builder.Query) error { + return merrors.NotImplemented("DeleteMany") +} +func (f *fakeScopedRepository) CreateIndex(*ri.Definition) error { return nil } +func (f *fakeScopedRepository) DropIndex(context.Context, string) error { + return merrors.NotImplemented("DropIndex") +} +func (f *fakeScopedRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { + return nil, merrors.NotImplemented("ListIndexDefinitions") +} +func (f *fakeScopedRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) { + return nil, merrors.NotImplemented("ListPermissionBound") +} +func (f *fakeScopedRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { + return nil, merrors.NotImplemented("ListAccountBound") +} +func (f *fakeScopedRepository) WithTransaction(_ context.Context, cb transaction.Callback) (any, error) { + return cb(context.Background()) +} +func (f *fakeScopedRepository) Collection() string { return "test_scoped" } + +// newTestScopedDBImp wires a ScopedDBImp backed by the given in-memory repo, +// membership, and enforcer. The orgPolicyRef is returned so callers can verify +// it was applied correctly. +func newTestScopedDBImp(repo *fakeScopedRepository, membership *fakeMembership, enforcer Enforcer) (*ScopedDBImp[*testScopedObj], bson.ObjectID) { + orgPolicyRef := bson.NewObjectID() + dbImp := &template.DBImp[*testScopedObj]{ + Logger: zap.NewNop(), + Repository: repo, + } + dbImp.SetDeleter(func(_ context.Context, id bson.ObjectID) error { + return repo.Delete(context.Background(), id) + }) + imp := NewScopedDBImp[*testScopedObj]( + dbImp, + enforcer, + membership, + orgPolicyRef, + mservice.Type("test_scoped"), + func() *testScopedObj { return &testScopedObj{} }, + zap.NewNop(), + ) + return imp, orgPolicyRef +} + +// ── Create ──────────────────────────────────────────────────────────────────── + +func TestScopedDBImp_Create_Personal_SkipsPolicy(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindAccount} + if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { + t.Fatalf("expected success, got %v", err) + } + if enf.called { + t.Fatal("personal create must not consult the enforcer") + } + if obj.GetOwnerRef() != caller { + t.Fatalf("expected ownerRef=%s, got %s", caller.Hex(), obj.GetOwnerRef().Hex()) + } + if obj.GetOrganizationRef() != orgRef { + t.Fatal("organizationRef must be set on create") + } +} + +func TestScopedDBImp_Create_Personal_DefaultsToAccountWhenKindMissing(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + obj := &testScopedObj{} // ownerKind is zero value (invalid) + if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { + t.Fatalf("expected success with defaulted ownerKind, got %v", err) + } + if obj.GetOwnerKind() != model.OwnerKindAccount { + t.Fatalf("expected ownerKind=account, got %s", obj.GetOwnerKind()) + } + if enf.called { + t.Fatal("must not consult enforcer when defaulting to personal") + } +} + +func TestScopedDBImp_Create_Personal_DeniedWhenCallerIsNotMember(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindAccount} + err := db.Create(context.Background(), caller, orgRef, obj) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member personal create, got %v", err) + } + if !membership.called { + t.Fatal("personal create must check organization membership before insert") + } + if enf.called { + t.Fatal("personal create must not consult policy when caller is not an organization member") + } + if len(repo.objects) != 0 { + t.Fatal("object must not be inserted when membership denies") + } +} + +func TestScopedDBImp_Create_Org_CallsPolicyAndSetsFields(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{allow: true} + db, orgPolicyRef := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} + if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { + t.Fatalf("expected success, got %v", err) + } + if !enf.called { + t.Fatal("org create must consult the enforcer") + } + if enf.gotAction != model.ActionCreate { + t.Fatalf("expected ActionCreate, got %s", enf.gotAction) + } + if obj.GetOwnerRef() != orgRef { + t.Fatalf("expected ownerRef=orgRef for org object, got %s", obj.GetOwnerRef().Hex()) + } + if obj.GetPermissionRef() != orgPolicyRef { + t.Fatal("permissionRef must be set to OrgPolicyRef for org objects") + } +} + +func TestScopedDBImp_Create_Org_DeniedByPolicy(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{allow: false} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} + err := db.Create(context.Background(), caller, orgRef, obj) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if len(repo.objects) != 0 { + t.Fatal("object must not be inserted when policy denies") + } +} + +func TestScopedDBImp_Create_Org_DeniedWhenCallerIsNotMember(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} + err := db.Create(context.Background(), caller, orgRef, obj) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member org create, got %v", err) + } + if !membership.called { + t.Fatal("org create must check organization membership before policy") + } + if enf.called { + t.Fatal("org create must not consult policy when caller is not an organization member") + } + if len(repo.objects) != 0 { + t.Fatal("object must not be inserted when membership denies") + } +} + +func TestScopedDBImp_Create_Org_MembershipErrorFailsClosed(t *testing.T) { + boom := merrors.Define("membership backend down") + repo := newFakeScopedRepository() + membership := &fakeMembership{err: boom} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} + err := db.Create(context.Background(), bson.NewObjectID(), bson.NewObjectID(), obj) + if !errors.Is(err, boom) { + t.Fatalf("expected membership backend error propagated, got %v", err) + } + if enf.called { + t.Fatal("org create must fail closed and not consult policy on membership error") + } + if len(repo.objects) != 0 { + t.Fatal("object must not be inserted when the membership check errors") + } +} + +// ── InsertMany ──────────────────────────────────────────────────────────────── + +func TestScopedDBImp_InsertMany_Empty_IsNoOp(t *testing.T) { + repo := newFakeScopedRepository() + enf := &fakeEnforcer{} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + if err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), nil); err != nil { + t.Fatalf("empty InsertMany must succeed, got %v", err) + } + if enf.called { + t.Fatal("empty InsertMany must not call enforcer") + } +} + +func TestScopedDBImp_InsertMany_AllPersonal_SkipsPolicy(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + objects := []*testScopedObj{ + {ownerKind: model.OwnerKindAccount}, + {ownerKind: model.OwnerKindAccount}, + } + if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { + t.Fatalf("expected success, got %v", err) + } + if enf.called { + t.Fatal("all-personal InsertMany must not consult the enforcer") + } + if len(repo.objects) != 2 { + t.Fatalf("expected 2 inserted objects, got %d", len(repo.objects)) + } +} + +func TestScopedDBImp_InsertMany_AllPersonalDeniedWhenCallerIsNotMember(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + objects := []*testScopedObj{ + {ownerKind: model.OwnerKindAccount}, + {ownerKind: model.OwnerKindAccount}, + } + err := db.InsertMany(context.Background(), caller, orgRef, objects) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member personal InsertMany, got %v", err) + } + if !membership.called { + t.Fatal("personal InsertMany must check organization membership before insert") + } + if enf.called { + t.Fatal("personal InsertMany must not consult policy when caller is not an organization member") + } + if len(repo.objects) != 0 { + t.Fatal("objects must not be inserted when membership denies") + } +} + +func TestScopedDBImp_InsertMany_MixedBatch_OrgPolicyCheckedOnce(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + objects := []*testScopedObj{ + {ownerKind: model.OwnerKindAccount}, + {ownerKind: model.OwnerKindOrganization}, + {ownerKind: model.OwnerKindOrganization}, + } + if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { + t.Fatalf("expected success, got %v", err) + } + if !enf.called { + t.Fatal("enforcer must be called for mixed batch with org objects") + } + // Enforcer is called only once regardless of how many org objects are in the batch. + callCount := 0 + if enf.called { + callCount = 1 + } + if callCount != 1 { + t.Fatal("org policy must be checked exactly once for the entire batch") + } +} + +func TestScopedDBImp_InsertMany_OrgDenied_NoObjectsInsertedOrMutated(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + enf := &fakeEnforcer{allow: false} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + personal := &testScopedObj{} + org := &testScopedObj{ + orgRef: bson.NewObjectID(), + ownerKind: model.OwnerKindOrganization, + ownerRef: bson.NewObjectID(), + permissionRef: bson.NewObjectID(), + } + originalPersonal := *personal + originalOrg := *org + objects := []*testScopedObj{personal, org} + err := db.InsertMany(context.Background(), caller, orgRef, objects) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if len(repo.objects) != 0 { + t.Fatal("objects must not be inserted when policy denies") + } + if *personal != originalPersonal { + t.Fatalf("personal object was mutated on denied InsertMany: got %+v want %+v", *personal, originalPersonal) + } + if *org != originalOrg { + t.Fatalf("org object was mutated on denied InsertMany: got %+v want %+v", *org, originalOrg) + } +} + +func TestScopedDBImp_InsertMany_OrgDeniedWhenCallerIsNotMember(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + repo := newFakeScopedRepository() + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} + err := db.InsertMany(context.Background(), caller, orgRef, objects) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member org InsertMany, got %v", err) + } + if !membership.called { + t.Fatal("org InsertMany must check organization membership before policy") + } + if enf.called { + t.Fatal("org InsertMany must not consult policy when caller is not an organization member") + } + if len(repo.objects) != 0 { + t.Fatal("objects must not be inserted when membership denies") + } +} + +func TestScopedDBImp_InsertMany_OrgMembershipErrorFailsClosed(t *testing.T) { + boom := merrors.Define("membership backend down") + repo := newFakeScopedRepository() + membership := &fakeMembership{err: boom} + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(repo, membership, enf) + + objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} + err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), objects) + if !errors.Is(err, boom) { + t.Fatalf("expected membership backend error propagated, got %v", err) + } + if enf.called { + t.Fatal("org InsertMany must fail closed and not consult policy on membership error") + } + if len(repo.objects) != 0 { + t.Fatal("objects must not be inserted when the membership check errors") + } +} + +// ── Update ──────────────────────────────────────────────────────────────────── + +func TestScopedDBImp_Update_RestoresOwnershipFields(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + objectRef := bson.NewObjectID() + orgPolicyRef := bson.NewObjectID() + + // Seed the repo with a personal object + repo := newFakeScopedRepository() + repo.objects[objectRef] = &testScopedObj{ + id: objectRef, + orgRef: orgRef, + ownerKind: model.OwnerKindAccount, + ownerRef: caller, + permissionRef: orgPolicyRef, + } + + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) + + // Submit an update that tries to change ownership + updated := &testScopedObj{ + id: objectRef, + ownerKind: model.OwnerKindOrganization, // attempt to escalate + ownerRef: bson.NewObjectID(), // attempt to hijack owner + } + if err := db.Update(context.Background(), caller, updated); err != nil { + t.Fatalf("expected success, got %v", err) + } + // After update the ownership fields must match the DB, not the submitted payload + if updated.GetOwnerKind() != model.OwnerKindAccount { + t.Fatalf("ownerKind must not change: got %s", updated.GetOwnerKind()) + } + if updated.GetOwnerRef() != caller { + t.Fatalf("ownerRef must not change: got %s", updated.GetOwnerRef().Hex()) + } + if updated.GetOrganizationRef() != orgRef { + t.Fatal("organizationRef must not change") + } + if updated.GetPermissionRef() != orgPolicyRef { + t.Fatal("permissionRef must not change") + } +} + +func TestScopedDBImp_Update_DeniedForNonOwner(t *testing.T) { + owner := bson.NewObjectID() + attacker := bson.NewObjectID() + objectRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + + repo := newFakeScopedRepository() + repo.objects[objectRef] = &testScopedObj{ + id: objectRef, + orgRef: orgRef, + ownerKind: model.OwnerKindAccount, + ownerRef: owner, + } + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) + + err := db.Update(context.Background(), attacker, &testScopedObj{id: objectRef}) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-owner, got %v", err) + } +} + +// ── ListIDs ─────────────────────────────────────────────────────────────────── + +func TestScopedDBImp_ListIDs_FiltersUnauthorized(t *testing.T) { + caller := bson.NewObjectID() + other := bson.NewObjectID() + orgRef := bson.NewObjectID() + + id1 := bson.NewObjectID() + id2 := bson.NewObjectID() + id3 := bson.NewObjectID() + + repo := newFakeScopedRepository() + repo.objects = map[bson.ObjectID]*testScopedObj{ + id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, + id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, // unauthorized + id3: {id: id3, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, + } + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) + + ids, err := db.ListIDs(context.Background(), model.ActionRead, caller, nil) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if len(ids) != 2 { + t.Fatalf("expected 2 authorized IDs, got %d", len(ids)) + } + for _, id := range ids { + if id == id2 { + t.Fatal("unauthorized ID must not be returned") + } + } +} + +func TestScopedDBImp_ListIDs_PropagatesNonAuthError(t *testing.T) { + boom := merrors.Define("enforcer backend down") + objectRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + + repo := newFakeScopedRepository() + repo.objects[objectRef] = &testScopedObj{ + id: objectRef, + orgRef: orgRef, + ownerKind: model.OwnerKindOrganization, + ownerRef: orgRef, + } + enf := &fakeEnforcer{err: boom} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) + if !errors.Is(err, boom) { + t.Fatalf("expected underlying error propagated, got %v", err) + } +} + +func TestScopedDBImp_ListIDs_RawRepoErrorPropagated(t *testing.T) { + boom := merrors.Define("mongo down") + repo := newFakeScopedRepository() + repo.listIDsErr = boom + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) + + _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) + if !errors.Is(err, boom) { + t.Fatalf("expected raw list error propagated, got %v", err) + } +} + +// ── PatchMany ───────────────────────────────────────────────────────────────── + +func TestScopedDBImp_PatchMany_PatchesOnlyAuthorizedIDs(t *testing.T) { + caller := bson.NewObjectID() + other := bson.NewObjectID() + orgRef := bson.NewObjectID() + + id1 := bson.NewObjectID() + id2 := bson.NewObjectID() + + repo := newFakeScopedRepository() + repo.objects = map[bson.ObjectID]*testScopedObj{ + id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, + id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, + } + // patchManyIDs declares which IDs the bulk DB call will report as affected. + // Only id1 is authorized (ownerRef==caller), so the IN filter contains only id1. + repo.patchManyIDs = []bson.ObjectID{id1} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) + + count, err := db.PatchMany(context.Background(), caller, nil, nil) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if count != 1 { + t.Fatalf("expected 1 patched object, got %d", count) + } + if len(repo.patchedIDs) != 1 || repo.patchedIDs[0] != id1 { + t.Fatal("only the authorized ID must be patched") + } +} + +// ── BuildScopedQuery ────────────────────────────────────────────────────────── + +// bsonHasPair reports whether the key/value pair occurs anywhere within the +// bson.D/bson.A tree produced by builder.Query.BuildQuery(). +func bsonHasPair(node any, key string, value any) bool { + switch n := node.(type) { + case bson.D: + for _, e := range n { + if e.Key == key && e.Value == value { + return true + } + if bsonHasPair(e.Value, key, value) { + return true + } + } + case bson.A: + for _, item := range n { + if bsonHasPair(item, key, value) { + return true + } + } + } + return false +} + +// bsonHasKey reports whether the key occurs anywhere within the tree. +func bsonHasKey(node any, key string) bool { + switch n := node.(type) { + case bson.D: + for _, e := range n { + if e.Key == key { + return true + } + if bsonHasKey(e.Value, key) { + return true + } + } + case bson.A: + for _, item := range n { + if bsonHasKey(item, key) { + return true + } + } + } + return false +} + +func TestScopedDBImp_BuildScopedQuery_WithOrgAccess_PersonalBranchIsCrossOrg(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) + + q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if !enf.called { + t.Fatal("enforcer must be called to check org access") + } + + built := q.BuildQuery() + if len(built) != 1 || built[0].Key != "$or" { + t.Fatalf("expected a single top-level $or, got %v", built) + } + branches, ok := built[0].Value.(bson.A) + if !ok || len(branches) != 2 { + t.Fatalf("expected 2 $or branches, got %v", built[0].Value) + } + + personal := branches[0] + if !bsonHasPair(personal, ownerKindField, model.OwnerKindAccount) || + !bsonHasPair(personal, ownerRefField, caller) { + t.Fatalf("personal branch must match owner kind/ref, got %v", personal) + } + if bsonHasKey(personal, storable.OrganizationRefField) { + t.Fatalf("personal branch must not be org-bounded, got %v", personal) + } + + org := branches[1] + if !bsonHasPair(org, storable.OrganizationRefField, orgRef) || + !bsonHasPair(org, ownerKindField, model.OwnerKindOrganization) { + t.Fatalf("org branch must be org-bounded and org-owned, got %v", org) + } +} + +func TestScopedDBImp_BuildScopedQuery_WithoutOrgAccess_PersonalOnly(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + enf := &fakeEnforcer{allow: false} + db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) + + q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + + built := q.BuildQuery() + if bsonHasKey(built, storable.OrganizationRefField) { + t.Fatalf("query must be personal-only without org access, got %v", built) + } + if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || + !bsonHasPair(built, ownerRefField, caller) { + t.Fatalf("query must match personal owner kind/ref, got %v", built) + } +} + +func TestScopedDBImp_BuildScopedQuery_MembershipError_PersonalOnly(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + enf := &fakeEnforcer{allow: true} + db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{err: merrors.Define("membership down")}, enf) + + q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) + if err != nil { + t.Fatalf("membership failure must degrade to personal-only, got %v", err) + } + if enf.called { + t.Fatal("enforcer must not be consulted when the membership check fails") + } + + built := q.BuildQuery() + if bsonHasKey(built, storable.OrganizationRefField) { + t.Fatalf("query must be personal-only on membership failure, got %v", built) + } + if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || + !bsonHasPair(built, ownerRefField, caller) { + t.Fatalf("query must match personal owner kind/ref, got %v", built) + } +} + +func TestScopedDBImp_BuildScopedQuery_EnforcerError_Propagated(t *testing.T) { + boom := merrors.Define("enforcer down") + repo := newFakeScopedRepository() + enf := &fakeEnforcer{err: boom} + db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) + + _, err := db.BuildScopedQuery(context.Background(), bson.NewObjectID(), bson.NewObjectID()) + if !errors.Is(err, boom) { + t.Fatalf("expected enforcer error propagated, got %v", err) + } +} diff --git a/backend/pkg/auth/dbimpscopedcrud.go b/backend/pkg/auth/dbimpscopedcrud.go new file mode 100644 index 0000000..c64afac --- /dev/null +++ b/backend/pkg/auth/dbimpscopedcrud.go @@ -0,0 +1,262 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// Create inserts a new resource. If ownerKind is absent or invalid it defaults +// to account. +// +// Personal path (ownerKind=account): requires the caller to be a member of +// organizationRef (so a non-member cannot plant personal objects in a foreign +// org namespace), then sets ownerRef=accountRef and inserts. The org policy is +// not consulted — after creation, access is governed by ownership alone. +// +// Org path (ownerKind=organization): checks org membership and the org policy +// (via authorizeOrgMutation) first, then stamps ownerRef/permissionRef and +// inserts. Ownership fields are only mutated after the access check so a denied +// caller's object is unchanged. +func (db *ScopedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { + db.DBImp.Logger.Debug("attempting to create scoped object", + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("collection", db.Collection)) + + if !object.GetOwnerKind().IsValid() { + object.SetOwnerKind(model.OwnerKindAccount) + } + + switch object.GetOwnerKind() { + case model.OwnerKindAccount: + if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { + return err + } + object.SetOwnerRef(accountRef) + object.SetOrganizationRef(organizationRef) + if err := db.DBImp.Create(ctx, object); err != nil { + db.DBImp.Logger.Warn("failed to create personal scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return err + } + return nil + + case model.OwnerKindOrganization: + if err := db.authorizeOrgMutation(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { + return err + } + object.SetOwnerRef(organizationRef) + object.SetOrganizationRef(organizationRef) + object.SetPermissionRef(db.OrgPolicyRef) + if err := db.DBImp.Create(ctx, object); err != nil { + db.DBImp.Logger.Warn("failed to create org scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return err + } + return nil + + default: + db.DBImp.Logger.Warn("unsupported ownerKind on create", + zap.String("ownerKind", string(object.GetOwnerKind())), + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return merrors.InvalidArgument("unsupported ownerKind", string(object.GetOwnerKind())) + } +} + +// InsertMany bulk-inserts resources. Ownership fields are normalized per +// object (defaulting to account when ownerKind is absent). Org membership, and +// any required org policy check, run before any object is mutated. +func (db *ScopedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { + if len(objects) == 0 { + return nil + } + + db.DBImp.Logger.Debug("attempting to insert many scoped objects", + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.Int("count", len(objects)), zap.String("collection", db.Collection)) + + if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { + return err + } + + ownerKinds := make([]model.OwnerKind, len(objects)) + var hasOrgObjects bool + for i, obj := range objects { + ownerKind := obj.GetOwnerKind() + if !ownerKind.IsValid() { + ownerKind = model.OwnerKindAccount + } + switch ownerKind { + case model.OwnerKindAccount: + case model.OwnerKindOrganization: + hasOrgObjects = true + default: + return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKind)) + } + ownerKinds[i] = ownerKind + } + + if hasOrgObjects { + if err := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { + return err + } + } + + for i, obj := range objects { + switch ownerKinds[i] { + case model.OwnerKindAccount: + obj.SetOwnerKind(model.OwnerKindAccount) + obj.SetOwnerRef(accountRef) + obj.SetOrganizationRef(organizationRef) + case model.OwnerKindOrganization: + obj.SetOwnerKind(model.OwnerKindOrganization) + obj.SetOwnerRef(organizationRef) + obj.SetOrganizationRef(organizationRef) + obj.SetPermissionRef(db.OrgPolicyRef) + default: + return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKinds[i])) + } + } + + if err := db.DBImp.InsertMany(ctx, objects); err != nil { + db.DBImp.Logger.Warn("failed to insert many scoped objects", + zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.Int("count", len(objects))) + return err + } + return nil +} + +// authorizeOrgMembership requires the caller to be a member of the +// organization. It is the create-time gate shared by the personal and org +// paths: since no record exists yet to read ownership from, membership in the +// target org is the minimum proof that the caller may write into its +// namespace. It fails closed — a membership backend error denies the mutation. +func (db *ScopedDBImp[T]) authorizeOrgMembership(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { + isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) + if err != nil { + db.DBImp.Logger.Warn("membership check failed during create-time mutation", + zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("collection", db.Collection)) + return err + } + if !isMember { + db.DBImp.Logger.Debug("access denied: caller is not a member of the organization", + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("collection", db.Collection), zap.String("action", string(action))) + return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) + } + + return nil +} + +// authorizeOrgPolicy checks that the org policy allows the caller to perform +// the action. It assumes membership has already been verified — callers must +// go through authorizeOrgMembership (or authorizeOrgMutation) first. +func (db *ScopedDBImp[T]) authorizeOrgPolicy(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { + ok, err := db.Enforcer.Enforce(ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, action) + if err != nil { + return err + } + if !ok { + db.DBImp.Logger.Debug("access denied: org mutation policy", + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), + zap.String("collection", db.Collection), zap.String("action", string(action))) + return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) + } + return nil +} + +// authorizeOrgMutation gates a create-time org mutation: the caller must be a +// member of the organization (authorizeOrgMembership) AND the org policy must +// allow the action (authorizeOrgPolicy). Because no record exists yet to read +// ownerKind from, it cannot go through loadAndEnforce; this mirrors the +// membership-first check ScopedAuthorizer applies to read/update/delete of +// org-owned resources. +func (db *ScopedDBImp[T]) authorizeOrgMutation(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { + isMemberErr := db.authorizeOrgMembership(ctx, accountRef, organizationRef, action) + if isMemberErr != nil { + return isMemberErr + } + if isPolicyErr := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, action); isPolicyErr != nil { + return isPolicyErr + } + return nil +} + +// Get fetches a resource after verifying ActionRead via LoadAndEnforce. +func (db *ScopedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { + db.DBImp.Logger.Debug("attempting to get scoped object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionRead); err != nil { + return err + } + if err := db.DBImp.Get(ctx, objectRef, result); err != nil { + db.DBImp.Logger.Warn("failed to get scoped object after auth", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + return nil +} + +// Update replaces a resource after verifying ActionUpdate. Immutable ownership +// fields (ownerKind, ownerRef, orgRef, permissionRef) are always restored from +// the database record so callers cannot change resource ownership via the payload. +func (db *ScopedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { + db.DBImp.Logger.Debug("attempting to update scoped object", + mzap.AccRef(accountRef), mzap.StorableRef(object)) + + existing, err := db.loadAndEnforce(ctx, accountRef, *object.GetID(), model.ActionUpdate) + if err != nil { + return err + } + object.SetOwnerKind(existing.GetOwnerKind()) + object.SetOwnerRef(existing.GetOwnerRef()) + object.SetOrganizationRef(existing.GetOrganizationRef()) + object.SetPermissionRef(existing.GetPermissionRef()) + + if err := db.DBImp.Update(ctx, object); err != nil { + db.DBImp.Logger.Warn("failed to update scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.StorableRef(object)) + return err + } + return nil +} + +// Delete removes a resource after verifying ActionDelete. +func (db *ScopedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + db.DBImp.Logger.Debug("attempting to delete scoped object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { + return err + } + if err := db.DBImp.Delete(ctx, objectRef); err != nil { + db.DBImp.Logger.Warn("failed to delete scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + return nil +} + +// DeleteCascadeAuth removes a resource (with cascade via the registered deleter) +// after verifying ActionDelete. +func (db *ScopedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { + db.DBImp.Logger.Debug("attempting cascade-delete scoped object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { + return err + } + if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { + db.DBImp.Logger.Warn("failed to cascade-delete scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + return nil +} diff --git a/backend/pkg/auth/dbimpscopedpatch.go b/backend/pkg/auth/dbimpscopedpatch.go new file mode 100644 index 0000000..ec9a50c --- /dev/null +++ b/backend/pkg/auth/dbimpscopedpatch.go @@ -0,0 +1,129 @@ +package auth + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// Patch applies a partial update after verifying ActionUpdate. +func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { + db.DBImp.Logger.Debug("attempting to patch scoped object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + + if err := denyImmutablePatch(patch); err != nil { + return err + } + if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { + return err + } + if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { + db.DBImp.Logger.Warn("failed to patch scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + return nil +} + +// SetArchived patches the isArchived field after verifying ActionUpdate. +// +// organizationRef is accepted for interface compatibility but not used for +// enforcement — ownership is read from the DB via LoadAndEnforce. +// cascade is accepted but not applied; resources that require cascading archived +// status must implement cascade logic in a dedicated wrapper. +func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error { + db.DBImp.Logger.Debug("attempting to change archive status of scoped object", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), + zap.Bool("archived", isArchived)) + + if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { + return err + } + patch := repository.Patch().Set(repository.IsArchivedField(), isArchived) + if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { + db.DBImp.Logger.Warn("failed to change archive status of scoped object", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + return nil +} + +// ListIDs returns the authorized subset of IDs matching query for the given +// action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are +// silently skipped. Non-access-denied errors abort the call. +func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { + db.DBImp.Logger.Debug("attempting to list scoped object IDs", + mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + rawIDs, err := db.DBImp.ListIDs(ctx, query) + if err != nil { + db.DBImp.Logger.Warn("failed to list raw object IDs", + zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + return nil, err + } + + allowed := make([]bson.ObjectID, 0, len(rawIDs)) + for _, id := range rawIDs { + if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil { + allowed = append(allowed, id) + } else if !errors.Is(authErr, merrors.ErrAccessDenied) { + db.DBImp.Logger.Warn("enforcer error during ListIDs", + zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id)) + return nil, authErr + } + // ErrAccessDenied: silently skip + } + + db.DBImp.Logger.Debug("completed ListIDs", + mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed))) + return allowed, nil +} + +// PatchMany applies patch to all resources in query that accountRef is +// authorized to update. Authorization is resolved via ListIDs; the write is a +// single bulk operation using originalQuery AND _id IN (authorizedIDs) so +// records that leave the original query between the read and write are not +// inadvertently mutated. +func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { + db.DBImp.Logger.Debug("attempting to patch many scoped objects", + mzap.AccRef(accountRef), zap.String("collection", db.Collection)) + + if err := denyImmutablePatch(patch); err != nil { + return 0, err + } + + ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) + if err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + values := make([]any, len(ids)) + for i, id := range ids { + values[i] = id + } + idFilter := repository.Query().In(repository.IDField(), values...) + finalQuery := idFilter + if query != nil { + finalQuery = query.And(idFilter) + } + modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch) + if err != nil { + db.DBImp.Logger.Warn("failed to patch many scoped objects", + zap.Error(err), mzap.AccRef(accountRef)) + return 0, err + } + + db.DBImp.Logger.Debug("patched many scoped objects", + mzap.AccRef(accountRef), zap.Int("count", modified)) + return modified, nil +} diff --git a/backend/pkg/auth/enforcer.go b/backend/pkg/auth/enforcer.go new file mode 100644 index 0000000..a3815a8 --- /dev/null +++ b/backend/pkg/auth/enforcer.go @@ -0,0 +1,32 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Enforcer interface { + // Enforce checks if accountRef can do `action` on objectRef in an org (domainRef). + Enforce( + ctx context.Context, + permissionRef, accountRef, orgRef, objectRef bson.ObjectID, + action model.Action, + ) (bool, error) + + // Enforce batch of objects + EnforceBatch( + ctx context.Context, + objectRefs []model.PermissionBoundStorable, + accountRef bson.ObjectID, + action model.Action, + ) (map[bson.ObjectID]bool, error) + + // GetRoles returns the user's roles in a given org domain, plus any partial scopes if relevant. + GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) + + // GetPermissions returns all effective permissions (with effect, object scoping) for a user in org domain. + // Merges from all roles the user holds, plus any denies/exceptions. + GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) +} diff --git a/backend/pkg/auth/factory.go b/backend/pkg/auth/factory.go new file mode 100644 index 0000000..7b100c2 --- /dev/null +++ b/backend/pkg/auth/factory.go @@ -0,0 +1,58 @@ +package auth + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +func CreateAuth( + logger mlogger.Logger, + client *mongo.Client, + db *mongo.Database, + pdb policy.DB, + rdb role.DB, + config *Config, +) (Enforcer, Manager, error) { + lg := logger.Named("auth") + if config == nil || config.Driver == "" { + lg.Warn("Permissions enforcer config is missing, defaulting to native enforcer") + config = &Config{ + Driver: Native, + } + } + lg.Debug("Creating enforcer...", zap.String("driver", string(config.Driver))) + l := lg.Named(string(config.Driver)) + if config.Driver == Casbin { + enforcer, err := casbin.NewEnforcer(l, client, config.Settings) + if err != nil { + lg.Warn("Failed to create enforcer", zap.Error(err)) + return nil, nil, err + } + manager, err := casbin.NewManager(l, pdb, rdb, enforcer, config.Settings) + if err != nil { + lg.Warn("Failed to create managment interface", zap.Error(err)) + return nil, nil, err + } + return enforcer, manager, nil + } + if config.Driver == Native { + enforcer, err := native.NewEnforcer(l, db) + if err != nil { + lg.Warn("Failed to create enforcer", zap.Error(err)) + return nil, nil, err + } + manager, err := native.NewManager(l, pdb, rdb, enforcer) + if err != nil { + lg.Warn("Failed to create managment interface", zap.Error(err)) + return nil, nil, err + } + return enforcer, manager, nil + } + return nil, nil, merrors.InvalidArgument("Unknown enforcer type: "+string(config.Driver), "config.driver") +} diff --git a/backend/pkg/auth/helper.go b/backend/pkg/auth/helper.go new file mode 100644 index 0000000..e82c171 --- /dev/null +++ b/backend/pkg/auth/helper.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func enforceObject[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef bson.ObjectID, query builder.Query) error { + l, err := db.ListPermissionBound(ctx, query) + if err != nil { + db.Logger.Warn("Error occured while checking access rights", zap.Error(err), + mzap.AccRef(accountRef), zap.String("action", string(action))) + return err + } + if len(l) == 0 { + db.Logger.Debug("Access denied", mzap.AccRef(accountRef), zap.String("action", string(action))) + return merrors.AccessDenied(db.Repository.Collection(), string(action), bson.NilObjectID) + } + for _, item := range l { + db.Logger.Debug("Object found", mzap.ObjRef("object_ref", *item.GetID()), + mzap.OrgRef(item.GetOrganizationRef()), + mzap.PermissionRef(item.GetPermissionRef()), + zap.String("collection", item.Collection())) + } + res, err := enforcer.EnforceBatch(ctx, l, accountRef, action) + if err != nil { + db.Logger.Warn("Failed to enforce permission", zap.Error(err), + mzap.AccRef(accountRef), zap.String("action", string(action))) + } + for objectRef, hasPermission := range res { + if !hasPermission { + db.Logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) + return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) + } + } + return nil +} + +func enforceObjectByRef[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef, objectRef bson.ObjectID) error { + err := enforceObject(ctx, db, enforcer, action, accountRef, repository.IDFilter(objectRef)) + if err != nil { + if errors.Is(err, merrors.ErrAccessDenied) { + db.Logger.Debug("Access denied", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) + } else { + db.Logger.Warn("Error occurred while checking permissions", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + } + } + return err +} diff --git a/backend/pkg/auth/indexable.go b/backend/pkg/auth/indexable.go new file mode 100644 index 0000000..dd205f2 --- /dev/null +++ b/backend/pkg/auth/indexable.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// IndexableDB implements reordering with permission checking +type IndexableDB[T storable.Storable] interface { + // Reorder implements reordering with permission checking using EnforceBatch + Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error +} + +// NewIndexableDB creates a new auth.IndexableDB instance +func NewIndexableDB[T storable.Storable]( + repo repository.Repository, + logger mlogger.Logger, + enforcer Enforcer, + createEmpty func() T, + getIndexable func(T) *model.Indexable, +) IndexableDB[T] { + return newIndexableDBImp(repo, logger, enforcer, createEmpty, getIndexable) +} diff --git a/backend/pkg/auth/indexableimp.go b/backend/pkg/auth/indexableimp.go new file mode 100644 index 0000000..cb2b4e6 --- /dev/null +++ b/backend/pkg/auth/indexableimp.go @@ -0,0 +1,182 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// IndexableDB implements reordering with permission checking +type indexableDBImp[T storable.Storable] struct { + repo repository.Repository + logger mlogger.Logger + enforcer Enforcer + createEmpty func() T + getIndexable func(T) *model.Indexable +} + +// NewIndexableDB creates a new auth.IndexableDB instance +func newIndexableDBImp[T storable.Storable]( + repo repository.Repository, + logger mlogger.Logger, + enforcer Enforcer, + createEmpty func() T, + getIndexable func(T) *model.Indexable, +) IndexableDB[T] { + return &indexableDBImp[T]{ + repo: repo, + logger: logger.Named("indexable"), + enforcer: enforcer, + createEmpty: createEmpty, + getIndexable: getIndexable, + } +} + +// Reorder implements reordering with permission checking using EnforceBatch +func (db *indexableDBImp[T]) Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error { + // Get current object to find its index + obj := db.createEmpty() + if err := db.repo.Get(ctx, objectRef, obj); err != nil { + db.logger.Warn("Failed to get object for reordering", zap.Error(err), zap.Int("new_index", newIndex), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + + // Extract index from the object + indexable := db.getIndexable(obj) + currentIndex := indexable.Index + if currentIndex == newIndex { + db.logger.Debug("No reordering needed - same index", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) + return nil // No change needed + } + + // Determine which objects will be affected by the reordering + var affectedObjects []model.PermissionBoundStorable + + if currentIndex < newIndex { + // Moving down: items between currentIndex+1 and newIndex will be shifted up by -1 + reorderFilter := filter. + And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). + And(repository.IndexOpFilter(newIndex, builder.Lte)) + + // Get all affected objects using ListPermissionBound + objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) + if err != nil { + db.logger.Warn("Failed to get affected objects for reordering (moving down)", + zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), + zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) + return err + } + affectedObjects = append(affectedObjects, objects...) + db.logger.Debug("Found affected objects for moving down", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) + } else { + // Moving up: items between newIndex and currentIndex-1 will be shifted down by +1 + reorderFilter := filter. + And(repository.IndexOpFilter(newIndex, builder.Gte)). + And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) + + // Get all affected objects using ListPermissionBound + objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) + if err != nil { + db.logger.Warn("Failed to get affected objects for reordering (moving up)", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), + zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) + return err + } + affectedObjects = append(affectedObjects, objects...) + db.logger.Debug("Found affected objects for moving up", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) + } + + // Add the target object to the list of objects that need permission checking + targetObjects, err := db.repo.ListPermissionBound(ctx, repository.IDFilter(objectRef)) + if err != nil { + db.logger.Warn("Failed to get target object for permission checking", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) + return err + } + if len(targetObjects) > 0 { + affectedObjects = append(affectedObjects, targetObjects[0]) + } + + // Check permissions for all affected objects using EnforceBatch + db.logger.Debug("Checking permissions for reordering", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)), + zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) + + permissions, err := db.enforcer.EnforceBatch(ctx, affectedObjects, accountRef, model.ActionUpdate) + if err != nil { + db.logger.Warn("Failed to check permissions for reordering", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects))) + return merrors.Internal("failed to check permissions for reordering") + } + + // Verify all objects have update permission + for resObjectRef, hasPermission := range permissions { + if !hasPermission { + db.logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) + return merrors.AccessDenied(db.repo.Collection(), string(model.ActionUpdate), resObjectRef) + } + } + + db.logger.Debug("All permissions granted, proceeding with reordering", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("permission_count", len(permissions))) + + // All permissions checked, proceed with reordering + if currentIndex < newIndex { + // Moving down: shift items between currentIndex+1 and newIndex up by -1 + patch := repository.Patch().Inc(repository.IndexField(), -1) + reorderFilter := filter. + And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). + And(repository.IndexOpFilter(newIndex, builder.Lte)) + + updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) + if err != nil { + db.logger.Warn("Failed to shift objects during reordering (moving down)", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), + zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) + return err + } + db.logger.Debug("Successfully shifted objects (moving down)", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) + } else { + // Moving up: shift items between newIndex and currentIndex-1 down by +1 + patch := repository.Patch().Inc(repository.IndexField(), 1) + reorderFilter := filter. + And(repository.IndexOpFilter(newIndex, builder.Gte)). + And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) + + updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) + if err != nil { + db.logger.Warn("Failed to shift objects during reordering (moving up)", zap.Error(err), + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), + zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) + return err + } + db.logger.Debug("Successfully shifted objects (moving up)", mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) + } + + // Update the target object to new index + if err := db.repo.Patch(ctx, objectRef, repository.Patch().Set(repository.IndexField(), newIndex)); err != nil { + db.logger.Warn("Failed to update target object index", zap.Error(err), mzap.AccRef(accountRef), + mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) + return err + } + + db.logger.Debug("Successfully reordered object with permission checking", + mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("old_index", currentIndex), + zap.Int("new_index", newIndex), zap.Int("affected_count", len(affectedObjects))) + return nil +} diff --git a/backend/pkg/auth/internal/casbin/config/config.go b/backend/pkg/auth/internal/casbin/config/config.go new file mode 100644 index 0000000..924ced4 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/config/config.go @@ -0,0 +1,127 @@ +package casbin + +import ( + "os" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.uber.org/zap" +) + +type AdapterConfig struct { + DatabaseName *string `mapstructure:"database_name"` + DatabaseNameEnv *string `mapstructure:"database_name_env"` + CollectionName *string `mapstructure:"collection_name"` + CollectionNameEnv *string `mapstructure:"collection_name_env"` + TimeoutSeconds *int `mapstructure:"timeout_seconds"` + TimeoutSecondsEnv *string `mapstructure:"timeout_seconds_env"` + IsFiltered *bool `mapstructure:"is_filtered"` + IsFilteredEnv *string `mapstructure:"is_filtered_env"` +} + +type Config struct { + ModelPath *string `mapstructure:"model_path"` + ModelPathEnv *string `mapstructure:"model_path_env"` + Adapter *AdapterConfig `mapstructure:"adapter"` +} + +type EnforcerConfig struct { + ModelPath string + Adapter *mongoadapter.Config +} + +func getEnvValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + return os.Getenv(*envValue) + } + + if value != nil { + return *value + } + + return "" +} + +func getEnvIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + envStr := os.Getenv(*envValue) + if envStr != "" { + if parsed, err := time.ParseDuration(envStr + "s"); err == nil { + return int(parsed.Seconds()) + } + logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr)) + } + } + + if value != nil { + return *value + } + + return 30 // Default timeout in seconds +} + +func getEnvBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + envStr := os.Getenv(*envValue) + switch envStr { + case "true", "1": + return true + case "false", "0": + return false + } + logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr)) + } + + if value != nil { + return *value + } + + return false // Default for boolean +} + +func PrepareConfig(logger mlogger.Logger, config *Config) (*EnforcerConfig, error) { + if config == nil { + return nil, merrors.Internal("No configuration provided") + } + + adapter := &mongoadapter.Config{ + DatabaseName: getEnvValue(logger, "database_name", "database_name_env", config.Adapter.DatabaseName, config.Adapter.DatabaseNameEnv), + CollectionName: getEnvValue(logger, "collection_name", "collection_name_env", config.Adapter.CollectionName, config.Adapter.CollectionNameEnv), + Timeout: time.Duration(getEnvIntValue(logger, "timeout_seconds", "timeout_seconds_env", config.Adapter.TimeoutSeconds, config.Adapter.TimeoutSecondsEnv)) * time.Second, + IsFiltered: getEnvBoolValue(logger, "is_filtered", "is_filtered_env", config.Adapter.IsFiltered, config.Adapter.IsFilteredEnv), + } + + if len(adapter.DatabaseName) == 0 { + logger.Error("Database name is not set") + return nil, merrors.InvalidArgument("database name must be provided", "adapter.databaseName") + } + + path := getEnvValue(logger, "model_path", "model_path_env", config.ModelPath, config.ModelPathEnv) + + logger.Info("Configuration prepared", + zap.String("model_path", path), + zap.String("database_name", adapter.DatabaseName), + zap.String("collection_name", adapter.CollectionName), + zap.Duration("timeout", adapter.Timeout), + zap.Bool("is_filtered", adapter.IsFiltered), + ) + + return &EnforcerConfig{ModelPath: path, Adapter: adapter}, nil +} diff --git a/backend/pkg/auth/internal/casbin/enforcer.go b/backend/pkg/auth/internal/casbin/enforcer.go new file mode 100644 index 0000000..5b654d3 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/enforcer.go @@ -0,0 +1,206 @@ +// casbin_enforcer.go +package casbin + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" + cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "github.com/casbin/casbin/v3" + "github.com/go-viper/mapstructure/v2" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +// CasbinEnforcer implements the Enforcer interface using Casbin. +type CasbinEnforcer struct { + logger mlogger.Logger + enforcer *casbin.Enforcer + roleSerializer serialization.Role + permissionSerializer serialization.Policy +} + +// NewCasbinEnforcer initializes a new CasbinEnforcer with a MongoDB adapter, logger, and PolicySerializer. +// The 'domain' parameter is no longer stored internally, as the interface requires passing a domainRef per method call. +func NewEnforcer( + logger mlogger.Logger, + client *mongo.Client, + settings model.SettingsT, +) (*CasbinEnforcer, error) { + var config cc.Config + if err := mapstructure.Decode(settings, &config); err != nil { + logger.Warn("Failed to decode Casbin configuration", zap.Error(err), zap.Any("settings", settings)) + return nil, merrors.Internal("failed to decode Casbin configuration") + } + + // Create a Casbin adapter + enforcer from your config and client. + l := logger.Named("enforcer") + e, err := createAdapter(l, &config, client) + if err != nil { + logger.Warn("Failed to create Casbin enforcer", zap.Error(err)) + return nil, merrors.Internal("failed to create Casbin enforcer") + } + + logger.Info("Casbin enforcer created") + return &CasbinEnforcer{ + logger: l, + enforcer: e, + permissionSerializer: serialization.NewPolicySerializer(), + roleSerializer: serialization.NewRoleSerializer(), + }, nil +} + +// Enforce checks if a user has the specified action permission on an object within a domain. +func (c *CasbinEnforcer) Enforce( + _ context.Context, + permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, + action model.Action, +) (bool, error) { + // Convert ObjectIDs to strings for Casbin + account := accountRef.Hex() + organization := organizationRef.Hex() + permission := permissionRef.Hex() + object := anyobject.ID + if objectRef != bson.NilObjectID { + object = objectRef.Hex() + } + act := string(action) + + c.logger.Debug("Enforcing policy", + zap.String("account", account), zap.String("organization", organization), + zap.String("permission", permission), zap.String("object", object), + zap.String("action", act)) + + // Perform the enforcement + result, err := c.enforcer.Enforce(account, organization, permission, object, act) + if err != nil { + c.logger.Warn("Failed to enforce policy", zap.Error(err), + zap.String("account", account), zap.String("organization", organization), + zap.String("permission", permission), zap.String("object", object), + zap.String("action", act)) + return false, err + } + + c.logger.Debug("Policy enforcement result", zap.Bool("result", result)) + return result, nil +} + +// EnforceBatch checks a user’s permission for multiple objects at once. +// It returns a map from objectRef -> boolean indicating whether access is granted. +func (c *CasbinEnforcer) EnforceBatch( + ctx context.Context, + objectRefs []model.PermissionBoundStorable, + accountRef bson.ObjectID, + action model.Action, +) (map[bson.ObjectID]bool, error) { + results := make(map[bson.ObjectID]bool, len(objectRefs)) + for _, desc := range objectRefs { + ok, err := c.Enforce(ctx, desc.GetPermissionRef(), accountRef, desc.GetOrganizationRef(), *desc.GetID(), action) + if err != nil { + c.logger.Warn("Failed to enforce", zap.Error(err), mzap.PermissionRef(desc.GetPermissionRef()), + mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), + mzap.StorableRef(desc), zap.String("action", string(action))) + return nil, err + } + results[*desc.GetID()] = ok + } + + return results, nil +} + +// GetRoles retrieves all roles assigned to the user within the domain. +func (c *CasbinEnforcer) GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) { + sub := accountRef.Hex() + dom := orgRef.Hex() + + c.logger.Debug("Fetching roles for user", zap.String("subject", sub), zap.String("domain", dom)) + + // Get all roles for the user in the domain + sroles, err := c.enforcer.GetFilteredGroupingPolicy(0, sub, "", dom) + if err != nil { + c.logger.Warn("Failed to get roles from policies", zap.Error(err), + zap.String("account_ref", sub), zap.String("organization_ref", dom), + ) + return nil, merrors.Internal("failed to fetch roles from policies") + } + + roles := make([]model.Role, 0, len(sroles)) + for _, srole := range sroles { + role, err := c.roleSerializer.Deserialize(srole) + if err != nil { + c.logger.Warn("Failed to deserialize role", zap.Error(err)) + return nil, err + } + roles = append(roles, *role) + } + + c.logger.Debug("Roles fetched successfully", zap.Int("count", len(roles))) + return roles, nil +} + +// GetPermissions retrieves all effective policies for the user within the domain. +func (c *CasbinEnforcer) GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) { + c.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) + + // Step 1: Retrieve all roles assigned to the user within the domain + roles, err := c.GetRoles(ctx, accountRef, orgRef) + if err != nil { + c.logger.Warn("Failed to get roles", zap.Error(err)) + return nil, nil, err + } + + // Map to hold unique policies + permissionsMap := make(map[string]*model.Permission) + for _, role := range roles { + // Step 2a: Retrieve all policies associated with the role within the domain + policies, err := c.enforcer.GetFilteredPolicy(0, role.DescriptionRef.Hex()) + if err != nil { + c.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) + continue + } + + // Step 2b: Process each policy to extract Permission, Action, and Effect + for _, policy := range policies { + + if len(policy) < 5 { + c.logger.Warn("Incomplete policy encountered", zap.Strings("policy", policy)) + continue // Ensure the policy line has enough fields + } + + // Deserialize the policy using + deserializedPolicy, err := c.permissionSerializer.Deserialize(policy) + if err != nil { + c.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.Strings("policy", policy)) + continue + } + + // Construct a unique key combining Permission ID and Action to prevent duplicates + policyKey := deserializedPolicy.DescriptionRef.Hex() + ":" + string(deserializedPolicy.Effect.Action) + if _, exists := permissionsMap[policyKey]; exists { + continue // Policy-action pair already accounted for + } + + // Add the Policy to the map + permissionsMap[policyKey] = &model.Permission{ + RolePolicy: *deserializedPolicy, + AccountRef: accountRef, + } + c.logger.Debug("Policy added to policyMap", zap.Any("policy_key", policyKey)) + } + } + + // Convert the map to a slice + permissions := make([]model.Permission, 0, len(permissionsMap)) + for _, permission := range permissionsMap { + permissions = append(permissions, *permission) + } + + c.logger.Debug("Permissions fetched successfully", zap.Int("count", len(permissions))) + return roles, permissions, nil +} diff --git a/backend/pkg/auth/internal/casbin/factory.go b/backend/pkg/auth/internal/casbin/factory.go new file mode 100644 index 0000000..4377e22 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/factory.go @@ -0,0 +1,34 @@ +package casbin + +import ( + cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/casbin/casbin/v3" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +func createAdapter(logger mlogger.Logger, config *cc.Config, client *mongo.Client) (*casbin.Enforcer, error) { + dbc, err := cc.PrepareConfig(logger, config) + if err != nil { + logger.Warn("Failed to prepare database configuration", zap.Error(err)) + return nil, err + } + + adapter, err := mongoadapter.New(client, dbc.Adapter) + if err != nil { + logger.Warn("Failed to create DB adapter", zap.Error(err)) + return nil, err + } + + e, err := casbin.NewEnforcer(dbc.ModelPath, adapter, NewCasbinLogger(logger)) + if err != nil { + logger.Warn("Failed to create permissions enforcer", zap.Error(err)) + return nil, err + } + e.EnableAutoSave(true) + + // No need to manually load policy. Casbin does it for us + return e, nil +} diff --git a/backend/pkg/auth/internal/casbin/logger.go b/backend/pkg/auth/internal/casbin/logger.go new file mode 100644 index 0000000..70df728 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/logger.go @@ -0,0 +1,61 @@ +package casbin + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.uber.org/zap" +) + +// CasbinZapLogger wraps a zap.Logger to implement Casbin's Logger interface. +type CasbinZapLogger struct { + logger mlogger.Logger +} + +// NewCasbinLogger constructs a new CasbinZapLogger. +func NewCasbinLogger(logger mlogger.Logger) *CasbinZapLogger { + return &CasbinZapLogger{ + logger: logger.Named("driver"), + } +} + +// EnableLog enables or disables logging. +func (l *CasbinZapLogger) EnableLog(_ bool) { + // ignore +} + +// IsEnabled returns whether logging is currently enabled. +func (l *CasbinZapLogger) IsEnabled() bool { + return true +} + +// LogModel is called by Casbin when loading model settings (you can customize if you want). +func (l *CasbinZapLogger) LogModel(m [][]string) { + l.logger.Info("Model loaded", zap.Any("model", m)) +} + +func (l *CasbinZapLogger) LogPolicy(m map[string][][]string) { + l.logger.Info("Policy loaded", zap.Int("entries", len(m))) +} + +func (l *CasbinZapLogger) LogError(err error, msg ...string) { + // If no custom message was passed, log a generic one + if len(msg) == 0 { + l.logger.Warn("Error occurred", zap.Error(err)) + return + } + + // Otherwise, join any provided messages and include them + l.logger.Warn(strings.Join(msg, " "), zap.Error(err)) +} + +// LogEnforce is called by Casbin to log each Enforce() call if logging is enabled. +func (l *CasbinZapLogger) LogEnforce(matcher string, request []any, result bool, explains [][]string) { + l.logger.Debug("Enforcing policy...", zap.String("matcher", matcher), zap.Any("request", request), + zap.Bool("result", result), zap.Any("explains", explains)) +} + +// LogRole is called by Casbin when role manager adds or deletes a role. +func (l *CasbinZapLogger) LogRole(roles []string) { + l.logger.Debug("Changing roles...", zap.Strings("roles", roles)) +} diff --git a/backend/pkg/auth/internal/casbin/manager.go b/backend/pkg/auth/internal/casbin/manager.go new file mode 100644 index 0000000..d15926c --- /dev/null +++ b/backend/pkg/auth/internal/casbin/manager.go @@ -0,0 +1,61 @@ +// package casbin + +package casbin + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.uber.org/zap" +) + +// CasbinManager implements the auth.Manager interface by aggregating Role and Permission managers. +type CasbinManager struct { + logger mlogger.Logger + roleManager management.Role + permManager management.Permission +} + +// NewManager creates a new CasbinManager with specified domains and role-domain mappings. +func NewManager( + l mlogger.Logger, + pdb policy.DB, + rdb role.DB, + enforcer *CasbinEnforcer, + settings model.SettingsT, +) (*CasbinManager, error) { + logger := l.Named("manager") + + var pdesc model.PolicyDescription + if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { + logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) + return nil, err + } + + return &CasbinManager{ + logger: logger, + roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), + permManager: NewPermissionManager(logger, enforcer), + }, nil +} + +// Permission returns the Permission manager. +func (m *CasbinManager) Permission() management.Permission { + return m.permManager +} + +// Role returns the Role manager. +func (m *CasbinManager) Role() management.Role { + return m.roleManager +} + +// TransactionalGrants reports that casbin grants live in the enforcer's memory +// until Save rewrites the whole policy store: they neither respect a Mongo +// transaction context nor survive concurrent Save calls from other instances. +func (m *CasbinManager) TransactionalGrants() bool { + return false +} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go new file mode 100644 index 0000000..4427fa4 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go @@ -0,0 +1,357 @@ +package mongoadapter + +import ( + "context" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "time" + + "github.com/casbin/casbin/v3/model" + "github.com/casbin/casbin/v3/persist" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const ( + defaultDatabaseName = "casbin" + defaultCollectionName = "casbin_rule" + defaultTimeout = 30 * time.Second +) + +var ( + _ persist.BatchAdapter = (*Adapter)(nil) + _ persist.FilteredAdapter = (*Adapter)(nil) + _ persist.UpdatableAdapter = (*Adapter)(nil) +) + +type Config struct { + DatabaseName string + CollectionName string + Timeout time.Duration + IsFiltered bool +} + +type Rule struct { + PType string + V0 string + V1 string + V2 string + V3 string + V4 string + V5 string +} + +type Adapter struct { + collection *mongo.Collection + timeout time.Duration + filtered bool +} + +func New(client *mongo.Client, config *Config) (*Adapter, error) { + if config == nil { + config = &Config{} + } + + databaseName := config.DatabaseName + if databaseName == "" { + databaseName = defaultDatabaseName + } + collectionName := config.CollectionName + if collectionName == "" { + collectionName = defaultCollectionName + } + timeout := config.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + adapter := &Adapter{ + collection: client.Database(databaseName).Collection(collectionName), + timeout: timeout, + filtered: config.IsFiltered, + } + if err := adapter.prepareIndexes(); err != nil { + return nil, err + } + return adapter, nil +} + +func (a *Adapter) prepareIndexes() error { + keys := bson.D{ + {Key: "ptype", Value: 1}, + {Key: "v0", Value: 1}, + {Key: "v1", Value: 1}, + {Key: "v2", Value: 1}, + {Key: "v3", Value: 1}, + {Key: "v4", Value: 1}, + {Key: "v5", Value: 1}, + } + + _, err := a.collection.Indexes().CreateOne( + context.Background(), + mongo.IndexModel{ + Keys: keys, + Options: options.Index().SetUnique(true), + }, + ) + return err +} + +func (a *Adapter) withTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), a.timeout) +} + +func loadPolicyLine(line Rule, m model.Model) error { + policy := line.policyWithType() + for len(policy) > 0 && policy[len(policy)-1] == "" { + policy = policy[:len(policy)-1] + } + if len(policy) == 0 { + return nil + } + return persist.LoadPolicyArray(policy, m) +} + +func (r Rule) policyWithType() []string { + return []string{r.PType, r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} +} + +func (r Rule) policyValues() []string { + values := []string{r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} + for len(values) > 0 && values[len(values)-1] == "" { + values = values[:len(values)-1] + } + return values +} + +func ruleFromPolicy(ptype string, policy []string) Rule { + rule := Rule{PType: ptype} + if len(policy) > 0 { + rule.V0 = policy[0] + } + if len(policy) > 1 { + rule.V1 = policy[1] + } + if len(policy) > 2 { + rule.V2 = policy[2] + } + if len(policy) > 3 { + rule.V3 = policy[3] + } + if len(policy) > 4 { + rule.V4 = policy[4] + } + if len(policy) > 5 { + rule.V5 = policy[5] + } + return rule +} + +func rulesFromPolicies(ptype string, policies [][]string) []Rule { + rules := make([]Rule, 0, len(policies)) + for _, policy := range policies { + rules = append(rules, ruleFromPolicy(ptype, policy)) + } + return rules +} + +func rulesAsDocuments(rules []Rule) []any { + docs := make([]any, 0, len(rules)) + for _, rule := range rules { + docs = append(docs, rule) + } + return docs +} + +func selectorForPolicy(ptype string, fieldIndex int, fieldValues ...string) bson.M { + selector := bson.M{"ptype": ptype} + fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"} + for i, fieldValue := range fieldValues { + if fieldValue == "" { + continue + } + field := fieldIndex + i + if field < 0 || field >= len(fields) { + continue + } + selector[fields[field]] = fieldValue + } + return selector +} + +func (a *Adapter) LoadPolicy(m model.Model) error { + return a.LoadFilteredPolicy(m, nil) +} + +func (a *Adapter) LoadFilteredPolicy(m model.Model, filter any) error { + if filter == nil { + a.filtered = false + filter = bson.D{} + } else { + a.filtered = true + } + + ctx, cancel := a.withTimeout() + defer cancel() + + cursor, err := a.collection.Find(ctx, filter) + if err != nil { + return err + } + defer func() { + _ = cursor.Close(ctx) + }() + + for cursor.Next(ctx) { + var line Rule + if err := cursor.Decode(&line); err != nil { + return err + } + if err := loadPolicyLine(line, m); err != nil { + return err + } + } + return cursor.Err() +} + +func (a *Adapter) IsFiltered() bool { + return a.filtered +} + +func (a *Adapter) SavePolicy(m model.Model) error { + if a.filtered { + return merrors.Define("cannot save a filtered policy") + } + + ctx, cancel := a.withTimeout() + defer cancel() + + if _, err := a.collection.DeleteMany(ctx, bson.D{}); err != nil { + return err + } + + var docs []any + for ptype, ast := range m["p"] { + docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) + } + for ptype, ast := range m["g"] { + docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) + } + if len(docs) == 0 { + return nil + } + + _, err := a.collection.InsertMany(ctx, docs) + return err +} + +func (a *Adapter) AddPolicy(_ string, ptype string, policy []string) error { + ctx, cancel := a.withTimeout() + defer cancel() + + _, err := a.collection.InsertOne(ctx, ruleFromPolicy(ptype, policy)) + return err +} + +func (a *Adapter) AddPolicies(_ string, ptype string, policies [][]string) error { + if len(policies) == 0 { + return nil + } + + ctx, cancel := a.withTimeout() + defer cancel() + + _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, policies))) + return err +} + +func (a *Adapter) RemovePolicy(_ string, ptype string, policy []string) error { + ctx, cancel := a.withTimeout() + defer cancel() + + _, err := a.collection.DeleteOne(ctx, ruleFromPolicy(ptype, policy)) + return err +} + +func (a *Adapter) RemovePolicies(_ string, ptype string, policies [][]string) error { + ctx, cancel := a.withTimeout() + defer cancel() + + for _, rule := range rulesFromPolicies(ptype, policies) { + if _, err := a.collection.DeleteOne(ctx, rule); err != nil { + return err + } + } + return nil +} + +func (a *Adapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error { + ctx, cancel := a.withTimeout() + defer cancel() + + _, err := a.collection.DeleteMany(ctx, selectorForPolicy(ptype, fieldIndex, fieldValues...)) + return err +} + +func (a *Adapter) UpdatePolicy(_ string, ptype string, oldPolicy, newPolicy []string) error { + ctx, cancel := a.withTimeout() + defer cancel() + + _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicy), ruleFromPolicy(ptype, newPolicy)) + return err +} + +func (a *Adapter) UpdatePolicies(_ string, ptype string, oldPolicies, newPolicies [][]string) error { + if len(oldPolicies) != len(newPolicies) { + return merrors.Define("old and new policy counts differ") + } + + ctx, cancel := a.withTimeout() + defer cancel() + + for i := range oldPolicies { + if _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicies[i]), ruleFromPolicy(ptype, newPolicies[i])); err != nil { + return err + } + } + return nil +} + +func (a *Adapter) UpdateFilteredPolicies(_ string, ptype string, newPolicies [][]string, fieldIndex int, fieldValues ...string) ([][]string, error) { + ctx, cancel := a.withTimeout() + defer cancel() + + selector := selectorForPolicy(ptype, fieldIndex, fieldValues...) + cursor, err := a.collection.Find(ctx, selector) + if err != nil { + return nil, err + } + + var oldPolicies [][]string + for cursor.Next(ctx) { + var line Rule + if err := cursor.Decode(&line); err != nil { + _ = cursor.Close(ctx) + return nil, err + } + oldPolicies = append(oldPolicies, line.policyValues()) + } + if err := cursor.Close(ctx); err != nil { + return nil, err + } + if err := cursor.Err(); err != nil { + return nil, err + } + + if _, err := a.collection.DeleteMany(ctx, selector); err != nil { + return nil, err + } + if len(newPolicies) == 0 { + return oldPolicies, nil + } + + if _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, newPolicies))); err != nil { + return nil, err + } + return oldPolicies, nil +} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go new file mode 100644 index 0000000..36ea81d --- /dev/null +++ b/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go @@ -0,0 +1,68 @@ +package mongoadapter + +import ( + "reflect" + "testing" + + "github.com/casbin/casbin/v3/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestRuleFromPolicyTrimsOnlyTrailingEmptyValues(t *testing.T) { + rule := ruleFromPolicy("p", []string{"role", "org", "", "object", ""}) + + if got, want := rule.PType, "p"; got != want { + t.Fatalf("PType = %q, want %q", got, want) + } + if got, want := rule.policyValues(), []string{"role", "org", "", "object"}; !reflect.DeepEqual(got, want) { + t.Fatalf("policyValues() = %#v, want %#v", got, want) + } +} + +func TestSelectorForPolicySkipsEmptyAndOutOfRangeFields(t *testing.T) { + got := selectorForPolicy("p", 1, "role", "", "object", "read", "ignored", "ignored") + want := bson.M{ + "ptype": "p", + "v1": "role", + "v3": "object", + "v4": "read", + "v5": "ignored", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("selectorForPolicy() = %#v, want %#v", got, want) + } +} + +func TestLoadPolicyLineAddsPolicyToModel(t *testing.T) { + m, err := model.NewModelFromString(` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && r.obj == p.obj && r.act == p.act +`) + if err != nil { + t.Fatalf("NewModelFromString() error = %v", err) + } + + err = loadPolicyLine(Rule{PType: "p", V0: "alice", V1: "data1", V2: "read"}, m) + if err != nil { + t.Fatalf("loadPolicyLine() error = %v", err) + } + + got, err := m.GetPolicy("p", "p") + if err != nil { + t.Fatalf("GetPolicy() error = %v", err) + } + want := [][]string{{"alice", "data1", "read"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("GetPolicy() = %#v, want %#v", got, want) + } +} diff --git a/backend/pkg/auth/internal/casbin/permissions.go b/backend/pkg/auth/internal/casbin/permissions.go new file mode 100644 index 0000000..a36f0a8 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/permissions.go @@ -0,0 +1,167 @@ +package casbin + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// CasbinPermissionManager manages permissions using Casbin. +type CasbinPermissionManager struct { + logger mlogger.Logger // Logger for logging operations + enforcer *CasbinEnforcer // Casbin enforcer for managing policies + serializer serialization.Policy // Serializer for converting policies to/from Casbin +} + +// GrantToRole adds a permission to a role in Casbin. +func (m *CasbinPermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { + objRef := anyobject.ID + if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { + objRef = policy.ObjectRef.Hex() + } + + m.logger.Debug("Granting permission to role", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), + zap.String("effect", string(policy.Effect.Effect)), + ) + + // Serialize permission + serializedPolicy, err := m.serializer.Serialize(policy) + if err != nil { + m.logger.Error("Failed to serialize permission while granting permission", zap.Error(err), + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + mzap.OrgRef(policy.OrganizationRef), + ) + return err + } + + // Add policy to Casbin + added, err := m.enforcer.enforcer.AddPolicy(serializedPolicy...) + if err != nil { + m.logger.Error("Failed to add policy to Casbin", zap.Error(err)) + return err + } + if added { + m.logger.Info("Policy added to Casbin", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + ) + } else { + m.logger.Warn("Policy already exists in Casbin", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + ) + } + + return nil +} + +// RevokeFromRole removes a permission from a role in Casbin. +func (m *CasbinPermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { + objRef := anyobject.ID + if policy.ObjectRef != nil { + objRef = policy.ObjectRef.Hex() + } + m.logger.Debug("Revoking permission from role", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), + zap.String("effect", string(policy.Effect.Effect)), + ) + + // Serialize policy + serializedPolicy, err := m.serializer.Serialize(policy) + if err != nil { + m.logger.Error("Failed to serialize policy while revoking permission from role", + zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.ObjRef("policy_ref", policy.DescriptionRef)) + return err + } + + // Remove policy from Casbin + removed, err := m.enforcer.enforcer.RemovePolicy(serializedPolicy...) + if err != nil { + m.logger.Error("Failed to remove policy from Casbin", zap.Error(err)) + return err + } + if removed { + m.logger.Info("Policy removed from Casbin", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + ) + } else { + m.logger.Warn("Policy does not exist in Casbin", + mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), + zap.String("object_ref", objRef), + ) + } + + return nil +} + +// GetPolicies retrieves all policies for a specific role. +func (m *CasbinPermissionManager) GetPolicies( + ctx context.Context, + roleRef bson.ObjectID, +) ([]model.RolePolicy, error) { + m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) + + // Retrieve Casbin policies for the role + policies, err := m.enforcer.enforcer.GetFilteredPolicy(0, roleRef.Hex()) + if err != nil { + m.logger.Warn("Failed to get policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) + return nil, err + } + if len(policies) == 0 { + m.logger.Info("No policies found for role", mzap.ObjRef("role_ref", roleRef)) + return nil, merrors.NoData("no policies") + } + + // Deserialize policies + var result []model.RolePolicy + for _, policy := range policies { + permission, err := m.serializer.Deserialize(policy) + if err != nil { + m.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.String("policy", policy[0])) + continue + } + result = append(result, *permission) + } + + m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(result))) + return result, nil +} + +// Save persists changes to the Casbin policy store. +func (m *CasbinPermissionManager) Save() error { + if err := m.enforcer.enforcer.SavePolicy(); err != nil { + m.logger.Error("Failed to save policies in Casbin", zap.Error(err)) + return err + } + m.logger.Info("Policies successfully saved in Casbin") + return nil +} + +func NewPermissionManager(logger mlogger.Logger, enforcer *CasbinEnforcer) *CasbinPermissionManager { + return &CasbinPermissionManager{ + logger: logger.Named("permission"), + enforcer: enforcer, + serializer: serialization.NewPolicySerializer(), + } +} diff --git a/backend/pkg/auth/internal/casbin/role.go b/backend/pkg/auth/internal/casbin/role.go new file mode 100644 index 0000000..69f3919 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/role.go @@ -0,0 +1,209 @@ +package casbin + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// RoleManager manages roles using Casbin. +type RoleManager struct { + logger mlogger.Logger + enforcer *CasbinEnforcer + rdb role.DB + rolePermissionRef bson.ObjectID +} + +// NewRoleManager creates a new RoleManager. +func NewRoleManager(logger mlogger.Logger, enforcer *CasbinEnforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { + return &RoleManager{ + logger: logger.Named("role"), + enforcer: enforcer, + rdb: rdb, + rolePermissionRef: rolePermissionRef, + } +} + +// validateObjectIDs ensures that all provided ObjectIDs are non-zero. +func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { + for _, id := range ids { + if id.IsZero() { + return merrors.InvalidArgument("Object references cannot be zero", "objectRef") + } + } + return nil +} + +// removePolicies removes policies based on the provided filter and logs the results. +func (rm *RoleManager) removePolicies(policyType, role string, roleRef bson.ObjectID) error { + filterIndex := 1 + if policyType == "permission" { + filterIndex = 0 + } + policies, err := rm.enforcer.enforcer.GetFilteredPolicy(filterIndex, role) + if err != nil { + rm.logger.Warn("Failed to fetch "+policyType+" policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) + return err + } + + for _, policy := range policies { + args := make([]any, len(policy)) + for i, v := range policy { + args[i] = v + } + var removed bool + var removeErr error + if policyType == "grouping" { + removed, removeErr = rm.enforcer.enforcer.RemoveGroupingPolicy(args...) + } else { + removed, removeErr = rm.enforcer.enforcer.RemovePolicy(args...) + } + + if removeErr != nil { + rm.logger.Warn("Failed to remove "+policyType+" policy for role", zap.Error(removeErr), mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) + return removeErr + } + if removed { + rm.logger.Info("Removed "+policyType+" policy for role", mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) + } + } + return nil +} + +// fetchRolesFromPolicies retrieves and converts policies to roles. +func (rm *RoleManager) fetchRolesFromPolicies(policies [][]string, orgRef bson.ObjectID) []model.RoleDescription { + roles := make([]model.RoleDescription, 0, len(policies)) + for _, policy := range policies { + if len(policy) < 2 { + continue + } + + roleID, err := bson.ObjectIDFromHex(policy[1]) + if err != nil { + rm.logger.Warn("Invalid role ID", zap.String("roleID", policy[1])) + continue + } + roles = append(roles, model.RoleDescription{Base: storable.Base{ID: roleID}, OrganizationRef: orgRef}) + } + return roles +} + +// Create creates a new role in an organization. +func (rm *RoleManager) Create(ctx context.Context, orgRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { + if err := rm.validateObjectIDs(orgRef); err != nil { + return nil, err + } + + role := &model.RoleDescription{ + Describable: *description, + OrganizationRef: orgRef, + } + if err := rm.rdb.Create(ctx, role); err != nil { + rm.logger.Warn("Failed to create role", zap.Error(err), mzap.ObjRef("organiztion_ref", orgRef)) + return nil, err + } + + rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(orgRef)) + return role, nil +} + +// Assign assigns a role to a user in the given organization. +func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { + if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { + return err + } + + sub := role.AccountRef.Hex() + roleID := role.DescriptionRef.Hex() + domain := role.OrganizationRef.Hex() + + added, err := rm.enforcer.enforcer.AddGroupingPolicy(sub, roleID, domain) + return rm.logPolicyResult("assign", added, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) +} + +// Delete removes a role entirely and cleans up associated Casbin policies. +func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { + if err := rm.validateObjectIDs(roleRef); err != nil { + rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) + return err + } + + if err := rm.rdb.Delete(ctx, roleRef); err != nil { + rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) + return err + } + + role := roleRef.Hex() + + // Remove grouping policies + if err := rm.removePolicies("grouping", role, roleRef); err != nil { + return err + } + + // Remove permission policies + if err := rm.removePolicies("permission", role, roleRef); err != nil { + return err + } + + // // Save changes + // if err := rm.enforcer.enforcer.SavePolicy(); err != nil { + // rm.logger.Warn("Failed to save Casbin policies after role deletion", + // zap.Error(err), + // mzap.ObjRef("role_ref", roleRef), + // ) + // return err + // } + + rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) + return nil +} + +// Revoke removes a role from a user. +func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, orgRef bson.ObjectID) error { + if err := rm.validateObjectIDs(roleRef, accountRef, orgRef); err != nil { + return err + } + + sub := accountRef.Hex() + role := roleRef.Hex() + domain := orgRef.Hex() + + removed, err := rm.enforcer.enforcer.RemoveGroupingPolicy(sub, role, domain) + return rm.logPolicyResult("revoke", removed, err, roleRef, accountRef, orgRef) +} + +// logPolicyResult logs results for Assign and Revoke. +func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, orgRef bson.ObjectID) error { + if err != nil { + rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) + return err + } + msg := "Role " + action + "ed successfully" + if !result { + msg = "Role already " + action + "ed" + } + rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) + return nil +} + +// List retrieves all roles in an organization or all roles if orgRef is zero. +func (rm *RoleManager) List(ctx context.Context, orgRef bson.ObjectID) ([]model.RoleDescription, error) { + domain := orgRef.Hex() + groupingPolicies, err := rm.enforcer.enforcer.GetFilteredGroupingPolicy(2, domain) + if err != nil { + rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(orgRef)) + return nil, err + } + + roles := rm.fetchRolesFromPolicies(groupingPolicies, orgRef) + + rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(orgRef), zap.Int("count", len(roles))) + return roles, nil +} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/policy.go b/backend/pkg/auth/internal/casbin/serialization/internal/policy.go new file mode 100644 index 0000000..a1096eb --- /dev/null +++ b/backend/pkg/auth/internal/casbin/serialization/internal/policy.go @@ -0,0 +1,81 @@ +package serializationimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// PolicySerializer implements CasbinSerializer for Permission. +type PolicySerializer struct{} + +// Serialize converts a Permission object into a Casbin policy. +func (s *PolicySerializer) Serialize(entity *model.RolePolicy) ([]any, error) { + if entity.RoleDescriptionRef.IsZero() || + entity.OrganizationRef.IsZero() || + entity.DescriptionRef.IsZero() || // Ensure permissionRef is valid + entity.Effect.Action == "" || // Ensure action is not empty + entity.Effect.Effect == "" { // Ensure effect (eft) is not empty + return nil, merrors.InvalidArgument("permission contains invalid object references or missing fields") + } + + objectRef := anyobject.ID + if entity.ObjectRef != nil { + objectRef = entity.ObjectRef.Hex() + } + + return []any{ + entity.RoleDescriptionRef.Hex(), // Maps to p.roleRef + entity.OrganizationRef.Hex(), // Maps to p.organizationRef + entity.DescriptionRef.Hex(), // Maps to p.permissionRef + objectRef, // Maps to p.objectRef (wildcard if empty) + string(entity.Effect.Action), // Maps to p.action + string(entity.Effect.Effect), // Maps to p.eft + }, nil +} + +// Deserialize converts a Casbin policy into a Permission object. +func (s *PolicySerializer) Deserialize(policy []string) (*model.RolePolicy, error) { + if len(policy) != 6 { // Ensure policy has the correct number of fields + return nil, merrors.Internal("invalid policy format") + } + + roleRef, err := bson.ObjectIDFromHex(policy[0]) + if err != nil { + return nil, merrors.InvalidArgument("invalid roleRef in policy") + } + + organizationRef, err := bson.ObjectIDFromHex(policy[1]) + if err != nil { + return nil, merrors.InvalidArgument("invalid organizationRef in policy") + } + + permissionRef, err := bson.ObjectIDFromHex(policy[2]) + if err != nil { + return nil, merrors.InvalidArgument("invalid permissionRef in policy") + } + + // Handle wildcard for ObjectRef + var objectRef *bson.ObjectID + if policy[3] != anyobject.ID { + ref, err := bson.ObjectIDFromHex(policy[3]) + if err != nil { + return nil, merrors.InvalidArgument("invalid objectRef in policy") + } + objectRef = &ref + } + + return &model.RolePolicy{ + RoleDescriptionRef: roleRef, + Policy: model.Policy{ + OrganizationRef: organizationRef, + DescriptionRef: permissionRef, + ObjectRef: objectRef, + Effect: model.ActionEffect{ + Action: model.Action(policy[4]), + Effect: model.Effect(policy[5]), + }, + }, + }, nil +} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/role.go b/backend/pkg/auth/internal/casbin/serialization/internal/role.go new file mode 100644 index 0000000..c829235 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/serialization/internal/role.go @@ -0,0 +1,57 @@ +package serializationimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// RoleSerializer implements CasbinSerializer for Role. +type RoleSerializer struct{} + +// Serialize converts a Role object into a Casbin grouping policy. +func (s *RoleSerializer) Serialize(entity *model.Role) ([]any, error) { + // Validate required fields + if entity.AccountRef.IsZero() || entity.DescriptionRef.IsZero() || entity.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("role contains invalid object references") + } + + return []any{ + entity.AccountRef.Hex(), // Maps to g(_, _, _) accountRef + entity.DescriptionRef.Hex(), // Maps to g(_, _, _) roleRef + entity.OrganizationRef.Hex(), // Maps to g(_, _, _) organizationRef + }, nil +} + +// Deserialize converts a Casbin grouping policy into a Role object. +func (s *RoleSerializer) Deserialize(policy []string) (*model.Role, error) { + // Ensure the policy has exactly 3 fields + if len(policy) != 3 { + return nil, merrors.Internal("invalid grouping policy format") + } + + // Parse accountRef + accountRef, err := bson.ObjectIDFromHex(policy[0]) + if err != nil { + return nil, merrors.InvalidArgument("invalid accountRef in grouping policy") + } + + // Parse roleDescriptionRef (roleRef) + roleDescriptionRef, err := bson.ObjectIDFromHex(policy[1]) + if err != nil { + return nil, merrors.InvalidArgument("invalid roleRef in grouping policy") + } + + // Parse organizationRef + organizationRef, err := bson.ObjectIDFromHex(policy[2]) + if err != nil { + return nil, merrors.InvalidArgument("invalid organizationRef in grouping policy") + } + + // Return the constructed Role object + return &model.Role{ + AccountRef: accountRef, + DescriptionRef: roleDescriptionRef, + OrganizationRef: organizationRef, + }, nil +} diff --git a/backend/pkg/auth/internal/casbin/serialization/policy.go b/backend/pkg/auth/internal/casbin/serialization/policy.go new file mode 100644 index 0000000..a6ce963 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/serialization/policy.go @@ -0,0 +1,12 @@ +package serialization + +import ( + serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type Policy = CasbinSerializer[model.RolePolicy] + +func NewPolicySerializer() Policy { + return &serializationimp.PolicySerializer{} +} diff --git a/backend/pkg/auth/internal/casbin/serialization/role.go b/backend/pkg/auth/internal/casbin/serialization/role.go new file mode 100644 index 0000000..4b04288 --- /dev/null +++ b/backend/pkg/auth/internal/casbin/serialization/role.go @@ -0,0 +1,12 @@ +package serialization + +import ( + serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type Role = CasbinSerializer[model.Role] + +func NewRoleSerializer() Role { + return &serializationimp.RoleSerializer{} +} diff --git a/backend/pkg/auth/internal/casbin/serialization/serializer.go b/backend/pkg/auth/internal/casbin/serialization/serializer.go new file mode 100644 index 0000000..36d11db --- /dev/null +++ b/backend/pkg/auth/internal/casbin/serialization/serializer.go @@ -0,0 +1,10 @@ +package serialization + +// CasbinSerializer defines methods for serializing and deserializing any Casbin-compatible entity. +type CasbinSerializer[T any] interface { + // Serialize converts an entity (Role or Permission) into a Casbin policy. + Serialize(entity *T) ([]any, error) + + // Deserialize converts a Casbin policy into an entity (Role or Permission). + Deserialize(policy []string) (*T, error) +} diff --git a/backend/pkg/auth/internal/native/db/policies.go b/backend/pkg/auth/internal/native/db/policies.go new file mode 100644 index 0000000..3d9b16a --- /dev/null +++ b/backend/pkg/auth/internal/native/db/policies.go @@ -0,0 +1,151 @@ +package db + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type PermissionsDBImp struct { + template.DBImp[*nstructures.PolicyAssignment] +} + +func (db *PermissionsDBImp) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { + return mutil.GetObjects[nstructures.PolicyAssignment]( + ctx, + db.Logger, + repository.Query().And( + repository.Filter("policy.organizationRef", object.GetOrganizationRef()), + repository.Filter("policy.descriptionRef", object.GetPermissionRef()), + repository.Filter("policy.effect.action", action), + repository.Query().Or( + repository.Filter("policy.objectRef", *object.GetID()), + repository.Filter("policy.objectRef", nil), + ), + ), + nil, + db.Repository, + ) +} + +func (db *PermissionsDBImp) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { + return mutil.GetObjects[nstructures.PolicyAssignment]( + ctx, + db.Logger, + repository.Query().And( + repository.Filter("roleRef", roleRef), + repository.Filter("policy.descriptionRef", permissionRef), + repository.Filter("policy.effect.action", action), + ), + nil, + db.Repository, + ) +} + +func (db *PermissionsDBImp) Remove(ctx context.Context, policy *model.RolePolicy) error { + objRefFilter := repository.Query().Or( + repository.Filter("policy.objectRef", nil), + repository.Filter("policy.objectRef", bson.NilObjectID), + ) + if policy.ObjectRef != nil { + objRefFilter = repository.Filter("policy.objectRef", *policy.ObjectRef) + } + return db.Repository.DeleteMany( + ctx, + repository.Query().And( + repository.Filter("roleRef", policy.RoleDescriptionRef), + repository.Filter("policy.organizationRef", policy.OrganizationRef), + repository.Filter("policy.descriptionRef", policy.DescriptionRef), + objRefFilter, + repository.Filter("policy.effect.action", policy.Effect.Action), + repository.Filter("policy.effect.effect", policy.Effect.Effect), + ), + ) +} + +func (db *PermissionsDBImp) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { + return mutil.GetObjects[nstructures.PolicyAssignment]( + ctx, + db.Logger, + repository.Filter("roleRef", roleRef), + nil, + db.Repository, + ) +} + +func (db *PermissionsDBImp) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { + if len(roleRefs) == 0 { + db.Logger.Debug("Empty role references list provided, returning empty resposnse") + return []nstructures.PolicyAssignment{}, nil + } + return mutil.GetObjects[nstructures.PolicyAssignment]( + ctx, + db.Logger, + repository.Query().And( + repository.Query().In(repository.Field("roleRef"), roleRefs), + repository.Filter("policy.effect.action", action), + ), + nil, + db.Repository, + ) +} + +func NewPoliciesDB(logger mlogger.Logger, db *mongo.Database) (*PermissionsDBImp, error) { + p := &PermissionsDBImp{ + DBImp: *template.Create[*nstructures.PolicyAssignment](logger, mservice.PolicyAssignements, db), + } + + // faster + // harder + // index + policiesQueryIndex := &ri.Definition{ + Keys: []ri.Key{ + {Field: "policy.organizationRef", Sort: ri.Asc}, + {Field: "policy.descriptionRef", Sort: ri.Asc}, + {Field: "policy.effect.action", Sort: ri.Asc}, + {Field: "policy.objectRef", Sort: ri.Asc}, + }, + } + if err := p.Repository.CreateIndex(policiesQueryIndex); err != nil { + p.Logger.Warn("Failed to prepare policies query index", zap.Error(err)) + return nil, err + } + + roleBasedQueriesIndex := &ri.Definition{ + Keys: []ri.Key{ + {Field: "roleRef", Sort: ri.Asc}, + {Field: "policy.effect.action", Sort: ri.Asc}, + }, + } + if err := p.Repository.CreateIndex(roleBasedQueriesIndex); err != nil { + p.Logger.Warn("Failed to prepare role based query index", zap.Error(err)) + return nil, err + } + + uniquePolicyConstaint := &ri.Definition{ + Keys: []ri.Key{ + {Field: "policy.organizationRef", Sort: ri.Asc}, + {Field: "roleRef", Sort: ri.Asc}, + {Field: "policy.descriptionRef", Sort: ri.Asc}, + {Field: "policy.effect.action", Sort: ri.Asc}, + {Field: "policy.objectRef", Sort: ri.Asc}, + }, + Unique: true, + } + if err := p.Repository.CreateIndex(uniquePolicyConstaint); err != nil { + p.Logger.Warn("Failed to unique policy assignment index", zap.Error(err)) + return nil, err + } + + return p, nil +} diff --git a/backend/pkg/auth/internal/native/db/roles.go b/backend/pkg/auth/internal/native/db/roles.go new file mode 100644 index 0000000..1152a2a --- /dev/null +++ b/backend/pkg/auth/internal/native/db/roles.go @@ -0,0 +1,99 @@ +package db + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type RolesDBImp struct { + template.DBImp[*nstructures.RoleAssignment] +} + +func (db *RolesDBImp) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { + return mutil.GetObjects[nstructures.RoleAssignment]( + ctx, + db.Logger, + repository.Query().And( + repository.Filter("role.accountRef", accountRef), + repository.Filter("role.organizationRef", organizationRef), + ), + nil, + db.Repository, + ) +} + +func (db *RolesDBImp) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { + return mutil.GetObjects[nstructures.RoleAssignment]( + ctx, + db.Logger, + repository.Query().And( + repository.Filter("role.organizationRef", organizationRef), + ), + nil, + db.Repository, + ) +} + +func (db *RolesDBImp) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { + return db.DeleteMany( + ctx, + repository.Query().And( + repository.Filter("role.descriptionRef", roleRef), + ), + ) +} + +func (db *RolesDBImp) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { + return db.DeleteMany( + ctx, + repository.Query().And( + repository.Filter("role.accountRef", accountRef), + repository.Filter("role.organizationRef", organizationRef), + repository.Filter("role.descriptionRef", roleRef), + ), + ) +} + +func NewRolesDB(logger mlogger.Logger, db *mongo.Database) (*RolesDBImp, error) { + p := &RolesDBImp{ + DBImp: *template.Create[*nstructures.RoleAssignment](logger, "role_assignments", db), + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "role.organizationRef", Sort: ri.Asc}}, + }); err != nil { + p.Logger.Warn("Failed to prepare venue index", zap.Error(err)) + return nil, err + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "role.descriptionRef", Sort: ri.Asc}}, + }); err != nil { + p.Logger.Warn("Failed to prepare role description index", zap.Error(err)) + return nil, err + } + + uniqueRoleConstaint := &ri.Definition{ + Keys: []ri.Key{ + {Field: "role.organizationRef", Sort: ri.Asc}, + {Field: "role.accountRef", Sort: ri.Asc}, + {Field: "role.descriptionRef", Sort: ri.Asc}, + }, + Unique: true, + } + if err := p.Repository.CreateIndex(uniqueRoleConstaint); err != nil { + p.Logger.Warn("Failed to prepare role assignment index", zap.Error(err)) + return nil, err + } + + return p, nil +} diff --git a/backend/pkg/auth/internal/native/dbpolicies.go b/backend/pkg/auth/internal/native/dbpolicies.go new file mode 100644 index 0000000..bd4b3af --- /dev/null +++ b/backend/pkg/auth/internal/native/dbpolicies.go @@ -0,0 +1,27 @@ +package native + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type PoliciesDB interface { + template.DB[*nstructures.PolicyAssignment] + // plenty of interfaces for performance reasons + Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) + PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) + PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) + PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) + Remove(ctx context.Context, policy *model.RolePolicy) error +} + +func NewPoliciesDBDB(logger mlogger.Logger, conn *mongo.Database) (PoliciesDB, error) { + return db.NewPoliciesDB(logger, conn) +} diff --git a/backend/pkg/auth/internal/native/dbroles.go b/backend/pkg/auth/internal/native/dbroles.go new file mode 100644 index 0000000..a97ff9e --- /dev/null +++ b/backend/pkg/auth/internal/native/dbroles.go @@ -0,0 +1,24 @@ +package native + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type RolesDB interface { + template.DB[*nstructures.RoleAssignment] + Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) + RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) + RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error + DeleteRole(ctx context.Context, roleRef bson.ObjectID) error +} + +func NewRolesDB(logger mlogger.Logger, conn *mongo.Database) (RolesDB, error) { + return db.NewRolesDB(logger, conn) +} diff --git a/backend/pkg/auth/internal/native/enforcer.go b/backend/pkg/auth/internal/native/enforcer.go new file mode 100644 index 0000000..eaa728a --- /dev/null +++ b/backend/pkg/auth/internal/native/enforcer.go @@ -0,0 +1,282 @@ +package native + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type Enforcer struct { + logger mlogger.Logger + pdb PoliciesDB + rdb RolesDB +} + +func NewEnforcer( + logger mlogger.Logger, + db *mongo.Database, +) (*Enforcer, error) { + e := &Enforcer{logger: logger.Named("enforcer")} + + var err error + if e.pdb, err = NewPoliciesDBDB(e.logger, db); err != nil { + e.logger.Warn("Failed to create permission assignments database", zap.Error(err)) + return nil, err + } + + if e.rdb, err = NewRolesDB(e.logger, db); err != nil { + e.logger.Warn("Failed to create role assignments database", zap.Error(err)) + return nil, err + } + + logger.Info("Native enforcer created") + return e, nil +} + +// Enforce checks if a user has the specified action permission on an object within a domain. +func (n *Enforcer) Enforce( + ctx context.Context, + permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, + action model.Action, +) (bool, error) { + if organizationRef.IsZero() { + n.logger.Warn("Missing organization context", mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object", objectRef), zap.String("action", string(action))) + return false, merrors.InvalidArgument("organization context missing", "organizationRef") + } + roleAssignments, err := n.rdb.Roles(ctx, accountRef, organizationRef) + if errors.Is(err, merrors.ErrNoData) { + n.logger.Debug("No roles defined for account", mzap.AccRef(accountRef)) + return false, nil + } + if err != nil { + n.logger.Warn("Failed to fetch roles while checking permissions", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object", objectRef), zap.String("action", string(action))) + return false, err + } + if len(roleAssignments) == 0 { + n.logger.Warn("No roles found for account", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return false, merrors.Internal("No roles found for account " + accountRef.Hex()) + } + allowFound := false // Track if any allow is found across roles + + for _, roleAssignment := range roleAssignments { + policies, err := n.pdb.PoliciesForPermissionAction(ctx, roleAssignment.DescriptionRef, permissionRef, action) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + n.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return false, err + } + + for _, permission := range policies { + if !policyAppliesToObject(permission.ObjectRef, objectRef) { + continue // Scoped to a different object; it says nothing about this one. + } + + if permission.Effect.Effect == model.EffectDeny { + n.logger.Debug("Found denying policy", mzap.ObjRef("account", accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + return false, nil // Deny takes precedence immediately + } + + if permission.Effect.Effect == model.EffectAllow { + n.logger.Debug("Allowing policy found", mzap.ObjRef("account", accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + allowFound = true // At least one allow found + } else { + n.logger.Warn("Corrupted policy", mzap.StorableRef(&permission)) + return false, merrors.Internal("Corrupted action effect data for permissions entry " + permission.ID.Hex() + ": " + string(permission.Effect.Effect)) + } + } + } + + // Final decision based on whether any allow was found + if allowFound { + return true, nil // At least one allow and no deny + } + + n.logger.Debug("No allowing policy found", mzap.ObjRef("account", accountRef), + mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), + mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) + + return false, nil // No allow found, default deny +} + +// EnforceBatch checks a user’s permission for multiple objects at once. +// It returns a map from objectRef -> boolean indicating whether access is granted. +func (n *Enforcer) EnforceBatch( + ctx context.Context, + objectRefs []model.PermissionBoundStorable, + accountRef bson.ObjectID, + action model.Action, +) (map[bson.ObjectID]bool, error) { + results := make(map[bson.ObjectID]bool, len(objectRefs)) + + // Group objectRefs by organizationRef. + objectsByVenue := make(map[bson.ObjectID][]model.PermissionBoundStorable) + for _, obj := range objectRefs { + organizationRef := obj.GetOrganizationRef() + objectsByVenue[organizationRef] = append(objectsByVenue[organizationRef], obj) + } + + // Process each venue group separately. + for organizationRef, objs := range objectsByVenue { + // 1. Fetch roles once for this account and venue. + roles, err := n.rdb.Roles(ctx, accountRef, organizationRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + n.logger.Debug("No roles defined for account", zap.Error(err), + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + // With no roles, mark all objects in this venue as denied. + for _, obj := range objs { + results[*obj.GetID()] = false + } + // Continue to next venue + continue + } + n.logger.Warn("Failed to fetch roles", zap.Error(err), + mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return nil, err + } + + // 2. Extract role description references + var roleRefs []bson.ObjectID + for _, role := range roles { + roleRefs = append(roleRefs, role.DescriptionRef) + } + + // 3. Fetch all policies for these roles and the given action in one call. + allPolicies, err := n.pdb.PoliciesForRoles(ctx, roleRefs, action) + if err != nil { + n.logger.Warn("Failed to fetch policies", zap.Error(err)) + return nil, err + } + + // 4. Build a lookup map keyed by PermissionRef. + policyMap := make(map[bson.ObjectID][]nstructures.PolicyAssignment) + for _, policy := range allPolicies { + policyMap[policy.DescriptionRef] = append(policyMap[policy.DescriptionRef], policy) + } + + // 5. Evaluate permissions for each object in this venue group. + for _, obj := range objs { + permRef := obj.GetPermissionRef() + allow := false + if policies, ok := policyMap[permRef]; ok { + for _, policy := range policies { + if !policyAppliesToObject(policy.ObjectRef, *obj.GetID()) { + continue // Scoped to a different object. + } + // Deny takes precedence. + if policy.Effect.Effect == model.EffectDeny { + allow = false + break + } + if policy.Effect.Effect == model.EffectAllow { + allow = true + // Continue checking in case a deny exists among policies. + } else { + // should never get here + return nil, merrors.Internal("Corrupted permissions effect in policy assignment '" + policy.GetID().Hex() + "': " + string(policy.Effect.Effect)) + } + } + } + results[*obj.GetID()] = allow + } + } + + return results, nil +} + +// GetRoles retrieves all roles assigned to the user within the domain. +func (n *Enforcer) GetRoles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, error) { + n.logger.Debug("Fetching roles for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + ra, err := n.rdb.Roles(ctx, accountRef, organizationRef) + if errors.Is(err, merrors.ErrNoData) { + n.logger.Debug("No roles assigned to user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return []model.Role{}, nil + } + if err != nil { + n.logger.Warn("Failed to fetch roles", zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return nil, err + } + + roles := make([]model.Role, len(ra)) + for i, roleAssignement := range ra { + roles[i] = roleAssignement.Role + } + + n.logger.Debug("Fetched roles", zap.Int("roles_count", len(roles))) + return roles, nil +} + +func (n *Enforcer) Reload() error { + n.logger.Info("Policies reloaded") // do nothing actually + return nil +} + +// GetPermissions retrieves all effective policies for the user within the domain. +func (n *Enforcer) GetPermissions(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, []model.Permission, error) { + n.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + + roles, err := n.GetRoles(ctx, accountRef, organizationRef) + if err != nil { + n.logger.Warn("Failed to get roles", zap.Error(err)) + return nil, nil, err + } + + uniquePermissions := make(map[bson.ObjectID]model.Permission) + for _, role := range roles { + perms, err := n.pdb.PoliciesForRole(ctx, role.DescriptionRef) + if err != nil { + n.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) + continue + } + n.logger.Debug("Policies fetched for role", mzap.ObjRef("role_ref", role.DescriptionRef), zap.Int("count", len(perms))) + for _, p := range perms { + uniquePermissions[*p.GetID()] = model.Permission{ + RolePolicy: model.RolePolicy{ + Policy: p.Policy, + RoleDescriptionRef: p.RoleRef, + }, + AccountRef: accountRef, + } + } + } + + permissionsSlice := make([]model.Permission, 0, len(uniquePermissions)) + for _, permission := range uniquePermissions { + permissionsSlice = append(permissionsSlice, permission) + } + + n.logger.Debug("Policies fetched successfully", zap.Int("count", len(permissionsSlice))) + return roles, permissionsSlice, nil +} + +// policyAppliesToObject reports whether a policy governs the object being +// checked. A policy without an object reference — nil or the zero value — is a +// wildcard covering every object of its resource type. A policy naming a +// specific object says nothing about any other one, so it is skipped rather +// than counted: otherwise an allow on one object would leak onto its siblings, +// and a deny on one object would block them. +func policyAppliesToObject(policyObjectRef *bson.ObjectID, objectRef bson.ObjectID) bool { + if policyObjectRef == nil || policyObjectRef.IsZero() { + return true + } + return *policyObjectRef == objectRef +} diff --git a/backend/pkg/auth/internal/native/enforcer_test.go b/backend/pkg/auth/internal/native/enforcer_test.go new file mode 100644 index 0000000..6dc8c2b --- /dev/null +++ b/backend/pkg/auth/internal/native/enforcer_test.go @@ -0,0 +1,746 @@ +package native + +import ( + "context" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Mock implementations for testing +type MockPoliciesDB struct { + mock.Mock +} + +func (m *MockPoliciesDB) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, roleRef, permissionRef, action) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, roleRef) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, roleRefs, action) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, object, action) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) Remove(ctx context.Context, policy *model.RolePolicy) error { + args := m.Called(ctx, policy) + return args.Error(0) +} + +// Template DB methods - implement as needed for testing +func (m *MockPoliciesDB) Create(ctx context.Context, assignment *nstructures.PolicyAssignment) error { + args := m.Called(ctx, assignment) + return args.Error(0) +} + +func (m *MockPoliciesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.PolicyAssignment) error { + args := m.Called(ctx, id, assignment) + return args.Error(0) +} + +func (m *MockPoliciesDB) Update(ctx context.Context, assignment *nstructures.PolicyAssignment) error { + args := m.Called(ctx, assignment) + return args.Error(0) +} + +func (m *MockPoliciesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { + args := m.Called(ctx, objectRef, patch) + return args.Error(0) +} + +func (m *MockPoliciesDB) Delete(ctx context.Context, id bson.ObjectID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockPoliciesDB) DeleteMany(ctx context.Context, query builder.Query) error { + args := m.Called(ctx, query) + return args.Error(0) +} + +func (m *MockPoliciesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, accountRef, organizationRef) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { + args := m.Called(ctx, query) + return args.Get(0).([]bson.ObjectID), args.Error(1) +} + +func (m *MockPoliciesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.PolicyAssignment) error { + args := m.Called(ctx, query, assignment) + return args.Error(0) +} + +func (m *MockPoliciesDB) List(ctx context.Context, query builder.Query) ([]nstructures.PolicyAssignment, error) { + args := m.Called(ctx, query) + return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) +} + +func (m *MockPoliciesDB) Name() string { + return "mock_policies" +} + +func (m *MockPoliciesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockPoliciesDB) InsertMany(ctx context.Context, objects []*nstructures.PolicyAssignment) error { + args := m.Called(ctx, objects) + return args.Error(0) +} + +type MockRolesDB struct { + mock.Mock +} + +func (m *MockRolesDB) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { + args := m.Called(ctx, accountRef, organizationRef) + return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) +} + +func (m *MockRolesDB) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { + args := m.Called(ctx, organizationRef) + return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) +} + +func (m *MockRolesDB) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { + args := m.Called(ctx, roleRef, organizationRef, accountRef) + return args.Error(0) +} + +func (m *MockRolesDB) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { + args := m.Called(ctx, roleRef) + return args.Error(0) +} + +// Template DB methods - implement as needed for testing +func (m *MockRolesDB) Create(ctx context.Context, assignment *nstructures.RoleAssignment) error { + args := m.Called(ctx, assignment) + return args.Error(0) +} + +func (m *MockRolesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.RoleAssignment) error { + args := m.Called(ctx, id, assignment) + return args.Error(0) +} + +func (m *MockRolesDB) Update(ctx context.Context, assignment *nstructures.RoleAssignment) error { + args := m.Called(ctx, assignment) + return args.Error(0) +} + +func (m *MockRolesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { + args := m.Called(ctx, objectRef, patch) + return args.Error(0) +} + +func (m *MockRolesDB) Delete(ctx context.Context, id bson.ObjectID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockRolesDB) DeleteMany(ctx context.Context, query builder.Query) error { + args := m.Called(ctx, query) + return args.Error(0) +} + +func (m *MockRolesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { + args := m.Called(ctx, accountRef, organizationRef) + return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) +} + +func (m *MockRolesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { + args := m.Called(ctx, query) + return args.Get(0).([]bson.ObjectID), args.Error(1) +} + +func (m *MockRolesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.RoleAssignment) error { + args := m.Called(ctx, query, assignment) + return args.Error(0) +} + +func (m *MockRolesDB) List(ctx context.Context, query builder.Query) ([]nstructures.RoleAssignment, error) { + args := m.Called(ctx, query) + return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) +} + +func (m *MockRolesDB) Name() string { + return "mock_roles" +} + +func (m *MockRolesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockRolesDB) InsertMany(ctx context.Context, objects []*nstructures.RoleAssignment) error { + args := m.Called(ctx, objects) + return args.Error(0) +} + +// Test helper functions +func createTestObjectID() bson.ObjectID { + return bson.NewObjectID() +} + +func createTestRoleAssignment(roleRef, accountRef, organizationRef bson.ObjectID) nstructures.RoleAssignment { + return nstructures.RoleAssignment{ + Role: model.Role{ + AccountRef: accountRef, + DescriptionRef: roleRef, + OrganizationRef: organizationRef, + }, + } +} + +func createTestPolicyAssignment(roleRef bson.ObjectID, effect model.Effect, organizationRef, descriptionRef bson.ObjectID, objectRef *bson.ObjectID) nstructures.PolicyAssignment { + return nstructures.PolicyAssignment{ + Policy: model.Policy{ + OrganizationRef: organizationRef, + DescriptionRef: descriptionRef, + ObjectRef: objectRef, + Effect: model.ActionEffect{ + Action: model.ActionRead, + Effect: effect, + }, + }, + RoleRef: roleRef, + } +} + +func createTestEnforcer(pdb PoliciesDB, rdb RolesDB) *Enforcer { + logger := factory.NewLogger(true) + enforcer := &Enforcer{ + logger: logger.Named("test"), + pdb: pdb, + rdb: rdb, + } + return enforcer +} + +func TestEnforcer_Enforce(t *testing.T) { + ctx := context.Background() + + // Test data + accountRef := createTestObjectID() + organizationRef := createTestObjectID() + permissionRef := createTestObjectID() + objectRef := createTestObjectID() + roleRef := createTestObjectID() + + t.Run("Allow_SingleRole_SinglePolicy", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock policy assignment with ALLOW effect + policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) + + // Create enforcer + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.True(t, allowed) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("Deny_SingleRole_SinglePolicy", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock policy assignment with DENY effect + policyAssignment := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("DenyTakesPrecedence_MultipleRoles", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + role1Ref := createTestObjectID() + role2Ref := createTestObjectID() + + // Mock multiple role assignments + roleAssignment1 := createTestRoleAssignment(role1Ref, accountRef, organizationRef) + roleAssignment2 := createTestRoleAssignment(role2Ref, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment1, roleAssignment2}, nil) + + // First role has ALLOW policy + allowPolicy := createTestPolicyAssignment(role1Ref, model.EffectAllow, organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, role1Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy}, nil) + + // Second role has DENY policy - should take precedence + denyPolicy := createTestPolicyAssignment(role2Ref, model.EffectDeny, organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, role2Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{denyPolicy}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify - DENY should take precedence + require.NoError(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("NoRoles_ReturnsFalse", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock no roles found + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + }) + + t.Run("EmptyRoles_ReturnsError", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock empty roles list (not NoData error) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.Error(t, err) + assert.False(t, allowed) + assert.Contains(t, err.Error(), "No roles found for account") + mockRDB.AssertExpectations(t) + }) + + t.Run("DatabaseError_RolesDB", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock database error + dbError := merrors.Define("database connection failed") + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.Error(t, err) + assert.False(t, allowed) + assert.Equal(t, dbError, err) + mockRDB.AssertExpectations(t) + }) + + t.Run("DatabaseError_PoliciesDB", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock database error in policies + dbError := merrors.Define("policies database error") + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, dbError) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.Error(t, err) + assert.False(t, allowed) + assert.Equal(t, dbError, err) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("NoPolicies_ReturnsFalse", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock no policies found + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, merrors.ErrNoData) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("CorruptedPolicy_ReturnsError", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock corrupted policy with invalid effect + corruptedPolicy := createTestPolicyAssignment(roleRef, "invalid_effect", organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{corruptedPolicy}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify + require.Error(t, err) + assert.False(t, allowed) + assert.Contains(t, err.Error(), "Corrupted action effect data") + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) +} + +// Mock implementation for PermissionBoundStorable +type MockPermissionBoundStorable struct { + id bson.ObjectID + permissionRef bson.ObjectID + organizationRef bson.ObjectID +} + +func (m *MockPermissionBoundStorable) GetID() *bson.ObjectID { + return &m.id +} + +func (m *MockPermissionBoundStorable) GetPermissionRef() bson.ObjectID { + return m.permissionRef +} + +func (m *MockPermissionBoundStorable) GetOrganizationRef() bson.ObjectID { + return m.organizationRef +} + +func (m *MockPermissionBoundStorable) Collection() string { + return "test_objects" +} + +func (m *MockPermissionBoundStorable) SetID(objID bson.ObjectID) { + m.id = objID +} + +func (m *MockPermissionBoundStorable) Update() { + // Do nothing for mock +} + +func (m *MockPermissionBoundStorable) SetPermissionRef(permissionRef bson.ObjectID) { + m.permissionRef = permissionRef +} + +func (m *MockPermissionBoundStorable) SetOrganizationRef(organizationRef bson.ObjectID) { + m.organizationRef = organizationRef +} + +func (m *MockPermissionBoundStorable) IsArchived() bool { + return false // Default to not archived for testing +} + +func (m *MockPermissionBoundStorable) SetArchived(archived bool) { + // No-op for testing +} + +func TestEnforcer_EnforceBatch(t *testing.T) { + ctx := context.Background() + + // Test data + accountRef := createTestObjectID() + organizationRef := createTestObjectID() + permissionRef := createTestObjectID() + roleRef := createTestObjectID() + + // Create test objects + object1 := &MockPermissionBoundStorable{ + id: createTestObjectID(), + permissionRef: permissionRef, + organizationRef: organizationRef, + } + object2 := &MockPermissionBoundStorable{ + id: createTestObjectID(), + permissionRef: permissionRef, + organizationRef: organizationRef, + } + + t.Run("BatchEnforce_MultipleObjects_SameVenue", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock policy assignment with ALLOW effect + policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil) + mockPDB.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute batch enforcement + objects := []model.PermissionBoundStorable{object1, object2} + results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.Len(t, results, 2) + assert.True(t, results[object1.id]) + assert.True(t, results[object2.id]) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("BatchEnforce_NoRoles_AllObjectsDenied", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock no roles found + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute batch enforcement + objects := []model.PermissionBoundStorable{object1, object2} + results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) + + // Verify + require.NoError(t, err) + assert.Len(t, results, 2) + assert.False(t, results[object1.id]) + assert.False(t, results[object2.id]) + mockRDB.AssertExpectations(t) + }) + + t.Run("BatchEnforce_DatabaseError", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock database error + dbError := merrors.Define("database connection failed") + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute batch enforcement + objects := []model.PermissionBoundStorable{object1, object2} + results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) + + // Verify + require.Error(t, err) + assert.Nil(t, results) + assert.Equal(t, dbError, err) + mockRDB.AssertExpectations(t) + }) +} + +func TestEnforcer_GetRoles(t *testing.T) { + ctx := context.Background() + + // Test data + accountRef := createTestObjectID() + organizationRef := createTestObjectID() + roleRef := createTestObjectID() + + t.Run("GetRoles_Success", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) + + // Verify + require.NoError(t, err) + assert.Len(t, roles, 1) + assert.Equal(t, roleRef, roles[0].DescriptionRef) + mockRDB.AssertExpectations(t) + }) + + t.Run("GetRoles_NoRoles", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock no roles found + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) + + // Verify + require.NoError(t, err) + assert.Len(t, roles, 0) + mockRDB.AssertExpectations(t) + }) +} + +func TestEnforcer_GetPermissions(t *testing.T) { + ctx := context.Background() + + // Test data + accountRef := createTestObjectID() + organizationRef := createTestObjectID() + roleRef := createTestObjectID() + + t.Run("GetPermissions_Success", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock policy assignment + policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, createTestObjectID(), nil) + mockPDB.On("PoliciesForRole", ctx, roleRef).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + roles, permissions, err := enforcer.GetPermissions(ctx, accountRef, organizationRef) + + // Verify + require.NoError(t, err) + assert.Len(t, roles, 1) + assert.Len(t, permissions, 1) + assert.Equal(t, accountRef, permissions[0].AccountRef) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) +} + +// Security-focused test scenarios +func TestEnforcer_SecurityScenarios(t *testing.T) { + ctx := context.Background() + + // Test data + accountRef := createTestObjectID() + organizationRef := createTestObjectID() + permissionRef := createTestObjectID() + objectRef := createTestObjectID() + roleRef := createTestObjectID() + + t.Run("Security_DenyAlwaysWins", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock role assignment + roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) + + // Mock multiple policies: both ALLOW and DENY + allowPolicy := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) + denyPolicy := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) + mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy, denyPolicy}, nil) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify - DENY should always win + require.NoError(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + mockPDB.AssertExpectations(t) + }) + + t.Run("Security_InvalidObjectID", func(t *testing.T) { + mockPDB := &MockPoliciesDB{} + mockRDB := &MockRolesDB{} + + // Mock database error for invalid ObjectID + dbError := merrors.Define("invalid ObjectID") + mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) + + enforcer := createTestEnforcer(mockPDB, mockRDB) + + // Execute with invalid ObjectID + allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) + + // Verify - should fail securely + require.Error(t, err) + assert.False(t, allowed) + mockRDB.AssertExpectations(t) + }) +} + +// Note: This test provides comprehensive coverage of the native enforcer including: +// 1. Basic enforcement logic with deny-takes-precedence +// 2. Batch operations for performance +// 3. Role and permission retrieval +// 4. Security scenarios and edge cases +// 5. Error handling and database failures +// 6. All critical security paths are tested diff --git a/backend/pkg/auth/internal/native/manager.go b/backend/pkg/auth/internal/native/manager.go new file mode 100644 index 0000000..523ab5d --- /dev/null +++ b/backend/pkg/auth/internal/native/manager.go @@ -0,0 +1,57 @@ +package native + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.uber.org/zap" +) + +// NativeManager implements the auth.Manager interface by aggregating Role and Permission managers. +type NativeManager struct { + logger mlogger.Logger + roleManager management.Role + permManager management.Permission +} + +// NewManager creates a new CasbinManager with specified domains and role-domain mappings. +func NewManager( + l mlogger.Logger, + pdb policy.DB, + rdb role.DB, + enforcer *Enforcer, +) (*NativeManager, error) { + logger := l.Named("manager") + + var pdesc model.PolicyDescription + if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { + logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) + return nil, err + } + + return &NativeManager{ + logger: logger, + roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), + permManager: NewPermissionManager(logger, enforcer), + }, nil +} + +// Permission returns the Permission manager. +func (m *NativeManager) Permission() management.Permission { + return m.permManager +} + +// Role returns the Role manager. +func (m *NativeManager) Role() management.Role { + return m.roleManager +} + +// TransactionalGrants reports that native grants are plain database writes: +// they join the caller's transaction context and roll back with it. +func (m *NativeManager) TransactionalGrants() bool { + return true +} diff --git a/backend/pkg/auth/internal/native/nstructures/policies.go b/backend/pkg/auth/internal/native/nstructures/policies.go new file mode 100644 index 0000000..43c9aa5 --- /dev/null +++ b/backend/pkg/auth/internal/native/nstructures/policies.go @@ -0,0 +1,17 @@ +package nstructures + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type PolicyAssignment struct { + storable.Base `bson:",inline" json:",inline"` + model.Policy `bson:"policy" json:"policy"` + RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` +} + +func (*PolicyAssignment) Collection() string { + return "permission_assignments" +} diff --git a/backend/pkg/auth/internal/native/nstructures/role.go b/backend/pkg/auth/internal/native/nstructures/role.go new file mode 100644 index 0000000..ee2cc68 --- /dev/null +++ b/backend/pkg/auth/internal/native/nstructures/role.go @@ -0,0 +1,15 @@ +package nstructures + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +type RoleAssignment struct { + storable.Base `bson:",inline" json:",inline"` + model.Role `bson:"role" json:"role"` +} + +func (*RoleAssignment) Collection() string { + return "role_assignments" +} diff --git a/backend/pkg/auth/internal/native/object_scope_test.go b/backend/pkg/auth/internal/native/object_scope_test.go new file mode 100644 index 0000000..b04048e --- /dev/null +++ b/backend/pkg/auth/internal/native/object_scope_test.go @@ -0,0 +1,175 @@ +package native + +import ( + "context" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestEnforcer_EnforceHonorsObjectScope(t *testing.T) { + ctx := context.Background() + accountRef := bson.NewObjectID() + organizationRef := bson.NewObjectID() + permissionRef := bson.NewObjectID() + roleRef := bson.NewObjectID() + objectA := bson.NewObjectID() + objectB := bson.NewObjectID() + wildcard := bson.NilObjectID + + tests := []struct { + name string + policies []nstructures.PolicyAssignment + objectRef bson.ObjectID + wantAccess bool + }{ + { + name: "matching object allow", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), + }, + objectRef: objectA, + wantAccess: true, + }, + { + name: "different object does not inherit allow", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), + }, + objectRef: objectB, + wantAccess: false, + }, + { + name: "nil object is wildcard", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), + }, + objectRef: objectB, + wantAccess: true, + }, + { + name: "zero object is wildcard", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &wildcard), + }, + objectRef: objectB, + wantAccess: true, + }, + { + name: "unrelated object deny does not override wildcard allow", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), + createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), + }, + objectRef: objectB, + wantAccess: true, + }, + { + name: "matching object deny overrides wildcard allow", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), + createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), + }, + objectRef: objectA, + wantAccess: false, + }, + { + name: "wildcard deny overrides matching object allow", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), + createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, nil), + }, + objectRef: objectA, + wantAccess: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + roles := &MockRolesDB{} + policies := &MockPoliciesDB{} + role := createTestRoleAssignment(roleRef, accountRef, organizationRef) + roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) + policies.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return(test.policies, nil) + + allowed, err := createTestEnforcer(policies, roles).Enforce( + ctx, + permissionRef, + accountRef, + organizationRef, + test.objectRef, + model.ActionRead, + ) + + require.NoError(t, err) + require.Equal(t, test.wantAccess, allowed) + roles.AssertExpectations(t) + policies.AssertExpectations(t) + }) + } +} + +func TestEnforcer_EnforceBatchHonorsObjectScope(t *testing.T) { + ctx := context.Background() + accountRef := bson.NewObjectID() + organizationRef := bson.NewObjectID() + permissionRef := bson.NewObjectID() + roleRef := bson.NewObjectID() + objectA := &MockPermissionBoundStorable{ + id: bson.NewObjectID(), + permissionRef: permissionRef, + organizationRef: organizationRef, + } + objectB := &MockPermissionBoundStorable{ + id: bson.NewObjectID(), + permissionRef: permissionRef, + organizationRef: organizationRef, + } + + tests := []struct { + name string + policies []nstructures.PolicyAssignment + want map[bson.ObjectID]bool + }{ + { + name: "specific allow applies only to matching object", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA.id), + }, + want: map[bson.ObjectID]bool{objectA.id: true, objectB.id: false}, + }, + { + name: "specific deny affects only matching object", + policies: []nstructures.PolicyAssignment{ + createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), + createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA.id), + }, + want: map[bson.ObjectID]bool{objectA.id: false, objectB.id: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + roles := &MockRolesDB{} + policies := &MockPoliciesDB{} + role := createTestRoleAssignment(roleRef, accountRef, organizationRef) + roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) + policies.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return(test.policies, nil) + + result, err := createTestEnforcer(policies, roles).EnforceBatch( + ctx, + []model.PermissionBoundStorable{objectA, objectB}, + accountRef, + model.ActionRead, + ) + + require.NoError(t, err) + require.Equal(t, test.want, result) + roles.AssertExpectations(t) + policies.AssertExpectations(t) + }) + } +} diff --git a/backend/pkg/auth/internal/native/permission.go b/backend/pkg/auth/internal/native/permission.go new file mode 100644 index 0000000..ed7fa45 --- /dev/null +++ b/backend/pkg/auth/internal/native/permission.go @@ -0,0 +1,101 @@ +package native + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// PermissionManager manages permissions using Casbin. +type PermissionManager struct { + logger mlogger.Logger + enforcer *Enforcer +} + +// GrantToRole adds a permission to a role in Casbin. +func (m *PermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { + objRef := "any" + if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { + objRef = policy.ObjectRef.Hex() + } + + m.logger.Debug("Granting permission to role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), + ) + + assignment := nstructures.PolicyAssignment{ + Policy: policy.Policy, + RoleRef: policy.RoleDescriptionRef, + } + if err := m.enforcer.pdb.Create(ctx, &assignment); err != nil { + m.logger.Warn("Failed to grant policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) + return err + } + return nil +} + +// RevokeFromRole removes a permission from a role in Casbin. +func (m *PermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { + objRef := "*" + if policy.ObjectRef != nil { + objRef = policy.ObjectRef.Hex() + } + m.logger.Debug("Revoking permission from role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), + ) + if err := m.enforcer.pdb.Remove(ctx, policy); err != nil { + m.logger.Warn("Failed to revoke policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), + mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), + zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) + return err + } + + return nil +} + +// GetPolicies retrieves all policies for a specific role. +func (m *PermissionManager) GetPolicies( + ctx context.Context, + roleRef bson.ObjectID, +) ([]model.RolePolicy, error) { + m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) + + assinments, err := m.enforcer.pdb.PoliciesForRole(ctx, roleRef) + if errors.Is(err, merrors.ErrNoData) { + m.logger.Debug("No policies found", mzap.ObjRef("role_ref", roleRef)) + return []model.RolePolicy{}, nil + } + policies := make([]model.RolePolicy, len(assinments)) + for i, assinment := range assinments { + policies[i] = model.RolePolicy{ + Policy: assinment.Policy, + RoleDescriptionRef: assinment.RoleRef, + } + } + m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(policies))) + return policies, nil +} + +// Save persists changes to the Casbin policy store. +func (m *PermissionManager) Save() error { + m.logger.Info("Policies successfully saved") // do nothing + return nil +} + +func NewPermissionManager(logger mlogger.Logger, enforcer *Enforcer) *PermissionManager { + return &PermissionManager{ + logger: logger.Named("permission"), + enforcer: enforcer, + } +} diff --git a/backend/pkg/auth/internal/native/role.go b/backend/pkg/auth/internal/native/role.go new file mode 100644 index 0000000..d7599aa --- /dev/null +++ b/backend/pkg/auth/internal/native/role.go @@ -0,0 +1,142 @@ +package native + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// RoleManager manages roles using Casbin. +type RoleManager struct { + logger mlogger.Logger + enforcer *Enforcer + rdb role.DB + rolePermissionRef bson.ObjectID +} + +// NewRoleManager creates a new RoleManager. +func NewRoleManager(logger mlogger.Logger, enforcer *Enforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { + return &RoleManager{ + logger: logger.Named("role"), + enforcer: enforcer, + rdb: rdb, + rolePermissionRef: rolePermissionRef, + } +} + +// validateObjectIDs ensures that all provided ObjectIDs are non-zero. +func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { + for _, id := range ids { + if id.IsZero() { + return merrors.InvalidArgument("Object references cannot be zero", "objectRef") + } + } + return nil +} + +// fetchRolesFromPolicies retrieves and converts policies to roles. +func (rm *RoleManager) fetchRolesFromPolicies(roles []nstructures.RoleAssignment, organizationRef bson.ObjectID) []model.RoleDescription { + result := make([]model.RoleDescription, len(roles)) + for i, role := range roles { + result[i] = model.RoleDescription{ + Base: storable.Base{ID: *role.GetID()}, + OrganizationRef: organizationRef, + } + } + return result +} + +// Create creates a new role in an organization. +func (rm *RoleManager) Create(ctx context.Context, organizationRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { + if err := rm.validateObjectIDs(organizationRef); err != nil { + return nil, err + } + + role := &model.RoleDescription{ + OrganizationRef: organizationRef, + Describable: *description, + } + if err := rm.rdb.Create(ctx, role); err != nil { + rm.logger.Warn("Failed to create role", zap.Error(err), mzap.OrgRef(organizationRef)) + return nil, err + } + + rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(organizationRef)) + return role, nil +} + +// Assign assigns a role to a user in the given organization. +func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { + if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { + return err + } + assogment := nstructures.RoleAssignment{Role: *role} + err := rm.enforcer.rdb.Create(ctx, &assogment) + return rm.logPolicyResult("assign", err == nil, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) +} + +// Delete removes a role entirely and cleans up associated Casbin policies. +func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { + if err := rm.validateObjectIDs(roleRef); err != nil { + rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) + return err + } + + if err := rm.rdb.Delete(ctx, roleRef); err != nil { + rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) + return err + } + + if err := rm.enforcer.rdb.DeleteRole(ctx, roleRef); err != nil { + rm.logger.Warn("Failed to remove role", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) + return err + } + + rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) + return nil +} + +// Revoke removes a role from a user. +func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, organizationRef bson.ObjectID) error { + if err := rm.validateObjectIDs(roleRef, accountRef, organizationRef); err != nil { + return err + } + + err := rm.enforcer.rdb.RemoveRole(ctx, roleRef, organizationRef, accountRef) + return rm.logPolicyResult("revoke", err == nil, err, roleRef, accountRef, organizationRef) +} + +// logPolicyResult logs results for Assign and Revoke. +func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, organizationRef bson.ObjectID) error { + if err != nil { + rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return err + } + msg := "Role " + action + "ed successfully" + if !result { + msg = "Role already " + action + "ed" + } + rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) + return nil +} + +// List retrieves all roles in an organization or all roles if organizationRef is zero. +func (rm *RoleManager) List(ctx context.Context, organizationRef bson.ObjectID) ([]model.RoleDescription, error) { + roles4Venues, err := rm.enforcer.rdb.RolesForVenue(ctx, organizationRef) + if err != nil { + rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(organizationRef)) + return nil, err + } + + roles := rm.fetchRolesFromPolicies(roles4Venues, organizationRef) + rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(organizationRef), zap.Int("count", len(roles))) + return roles, nil +} diff --git a/backend/pkg/auth/management/permission.go b/backend/pkg/auth/management/permission.go new file mode 100644 index 0000000..4721d20 --- /dev/null +++ b/backend/pkg/auth/management/permission.go @@ -0,0 +1,27 @@ +package management + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Permission interface { + // Grant a permission to a role with an optional object scope and specified effect. + // Use bson.NilObjectID for 'any' objectRef. + GrantToRole(ctx context.Context, policy *model.RolePolicy) error + + // Revoke a permission from a role with an optional object scope and specified effect. + // Use bson.NilObjectID for 'any' objectRef. + RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error + + // Retrieve all policies assigned to a specific role, including scope and effects. + GetPolicies( + ctx context.Context, + roleRef bson.ObjectID, + ) ([]model.RolePolicy, error) + + // Persist any changes made to permissions. + Save() error +} diff --git a/backend/pkg/auth/management/role.go b/backend/pkg/auth/management/role.go new file mode 100644 index 0000000..8a1709b --- /dev/null +++ b/backend/pkg/auth/management/role.go @@ -0,0 +1,41 @@ +package management + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Role interface { + // Create a new role in an organization (returns the created Role with its ID). + Create( + ctx context.Context, + orgRef bson.ObjectID, + description *model.Describable, + ) (*model.RoleDescription, error) + + // Delete a role entirely. This will cascade and remove all associated + Delete( + ctx context.Context, + roleRef bson.ObjectID, + ) error + + // Assign a role to a user in a specific organization. + Assign( + ctx context.Context, + role *model.Role, + ) error + + // Revoke a role from a user in a specific organization. + Revoke( + ctx context.Context, + roleRef, accountRef, orgRef bson.ObjectID, + ) error + + // List all roles in an organization or globally if orgRef is bson.NilObjectID. + List( + ctx context.Context, + orgRef bson.ObjectID, + ) ([]model.RoleDescription, error) +} diff --git a/backend/pkg/auth/manager.go b/backend/pkg/auth/manager.go new file mode 100644 index 0000000..7b678a8 --- /dev/null +++ b/backend/pkg/auth/manager.go @@ -0,0 +1,15 @@ +package auth + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" +) + +// Manager provides access to domain-aware Permission and Role managers. +type Manager interface { + // Permission returns a manager that handles permission grants/revokes + // for a specific resource type. (You might add domainRef here if desired.) + Permission() management.Permission + + // Role returns the domain-aware Role manager. + Role() management.Role +} diff --git a/backend/pkg/auth/patchguard.go b/backend/pkg/auth/patchguard.go new file mode 100644 index 0000000..360e86b --- /dev/null +++ b/backend/pkg/auth/patchguard.go @@ -0,0 +1,30 @@ +package auth + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// denyImmutablePatch returns ErrInvalidArg if patch touches any field whose +// mutation would silently undermine future access decisions. +func denyImmutablePatch(patch builder.Patch) error { + if patch == nil { + return nil + } + for _, op := range patch.Build() { + fields, ok := op.Value.(bson.D) + if !ok { + continue + } + for _, f := range fields { + if f.Key == "owner" || strings.HasPrefix(f.Key, "owner.") || + f.Key == "organizationRef" || f.Key == "permissionRef" { + return merrors.InvalidArgumentf("patch field %q is immutable", f.Key) + } + } + } + return nil +} diff --git a/backend/pkg/auth/provider.go b/backend/pkg/auth/provider.go new file mode 100644 index 0000000..c775f5c --- /dev/null +++ b/backend/pkg/auth/provider.go @@ -0,0 +1,14 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type Provider interface { + Enforcer() Enforcer + Manager() Manager + GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) +} diff --git a/backend/pkg/auth/scoped.go b/backend/pkg/auth/scoped.go new file mode 100644 index 0000000..043bd48 --- /dev/null +++ b/backend/pkg/auth/scoped.go @@ -0,0 +1,71 @@ +package auth + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ScopedAccessParams bundles everything needed to authorize an action against a +// resource that follows the personal-vs-org ownership model. +// +// It is deliberately a plain struct (not an interface over a model) so it works +// both for locally-stored objects and for objects whose ownership is learned +// from a downstream service response. +type ScopedAccessParams struct { + // Service is the resource type, used only to build the access-denied error. + Service mservice.Type + // PolicyRef is the org policy consulted when the object is org-owned. + PolicyRef bson.ObjectID + // CallerRef is the subject taken from the auth token. Never from the request body. + CallerRef bson.ObjectID + // OrgRef is the organization the object belongs to. For org-owned objects + // caller membership in it is checked before policy enforcement. Personal + // objects are deliberately exempt from the membership check so the owner + // keeps access after leaving the organization; membership for personal + // objects is enforced once, at create time (see ScopedDBImp.Create). + OrgRef bson.ObjectID + // ObjectRef is the concrete object being accessed. Passing it enables + // object-level checks in the Enforcer; use bson.NilObjectID for scope-only. + ObjectRef bson.ObjectID + // OwnerKind / OwnerRef describe the object's actual ownership, read FROM the + // object — not derived from the request. + OwnerKind model.OwnerKind + OwnerRef bson.ObjectID + // Action is the operation being authorized. + Action model.Action +} + +// enforceOwnershipAccess decides access based on ownership and policy only. +// It does NOT check organization membership — the caller must verify membership +// before invoking this. Use ScopedAuthorizer.Authorize for the full +// membership-aware access check. +// +// - personal (OwnerKindAccount): allowed iff OwnerRef == CallerRef. +// - org (OwnerKindOrganization): allowed iff the Enforcer grants Action within OrgRef. +// - anything else: denied by default. +func enforceOwnershipAccess(ctx context.Context, enf Enforcer, p ScopedAccessParams) error { + switch p.OwnerKind { + case model.OwnerKindAccount: + if p.OwnerRef == p.CallerRef { + return nil + } + return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) + + case model.OwnerKindOrganization: + ok, err := enf.Enforce(ctx, p.PolicyRef, p.CallerRef, p.OrgRef, p.ObjectRef, p.Action) + if err != nil { + return err + } + if !ok { + return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) + } + return nil + + default: + return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) + } +} diff --git a/backend/pkg/auth/scoped_test.go b/backend/pkg/auth/scoped_test.go new file mode 100644 index 0000000..c9ee79d --- /dev/null +++ b/backend/pkg/auth/scoped_test.go @@ -0,0 +1,130 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// fakeEnforcer records the last call and returns canned results, so the scoped +// access rules can be exercised without a real policy backend. +type fakeEnforcer struct { + allow bool + err error + called bool + + gotPolicyRef bson.ObjectID + gotAccount bson.ObjectID + gotOrg bson.ObjectID + gotObject bson.ObjectID + gotAction model.Action +} + +func (f *fakeEnforcer) Enforce(_ context.Context, permissionRef, accountRef, orgRef, objectRef bson.ObjectID, action model.Action) (bool, error) { + f.called = true + f.gotPolicyRef, f.gotAccount, f.gotOrg, f.gotObject, f.gotAction = permissionRef, accountRef, orgRef, objectRef, action + return f.allow, f.err +} + +func (f *fakeEnforcer) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) { + return map[bson.ObjectID]bool{}, nil +} + +func (f *fakeEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { + return nil, nil +} + +func (f *fakeEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { + return nil, nil, nil +} + +func TestEnforceOwnershipAccess(t *testing.T) { + caller := bson.NewObjectID() + other := bson.NewObjectID() + org := bson.NewObjectID() + object := bson.NewObjectID() + policy := bson.NewObjectID() + + base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { + return ScopedAccessParams{ + Service: mservice.Recipients, + PolicyRef: policy, + CallerRef: caller, + OrgRef: org, + ObjectRef: object, + OwnerKind: kind, + OwnerRef: owner, + Action: model.ActionRead, + } + } + + t.Run("personal owner is allowed without policy", func(t *testing.T) { + enf := &fakeEnforcer{} + if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, caller)); err != nil { + t.Fatalf("expected allow, got %v", err) + } + if enf.called { + t.Fatal("personal access must not consult the enforcer") + } + }) + + t.Run("personal non-owner is denied without policy", func(t *testing.T) { + enf := &fakeEnforcer{} + err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, other)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if enf.called { + t.Fatal("personal access must not consult the enforcer") + } + }) + + t.Run("org owner consults policy and passes object ref", func(t *testing.T) { + enf := &fakeEnforcer{allow: true} + if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)); err != nil { + t.Fatalf("expected allow, got %v", err) + } + if !enf.called { + t.Fatal("org access must consult the enforcer") + } + if enf.gotObject != object || enf.gotOrg != org || enf.gotAccount != caller || enf.gotPolicyRef != policy { + t.Fatalf("enforcer received unexpected args: %+v", enf) + } + }) + + t.Run("org owner denied when policy refuses", func(t *testing.T) { + enf := &fakeEnforcer{allow: false} + err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + }) + + t.Run("org policy error is propagated, not masked as denial", func(t *testing.T) { + boom := merrors.Define("backend down") + enf := &fakeEnforcer{err: boom} + err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) + if !errors.Is(err, boom) { + t.Fatalf("expected underlying error, got %v", err) + } + if errors.Is(err, merrors.ErrAccessDenied) { + t.Fatal("internal error must not be reported as access denied") + } + }) + + t.Run("unknown owner kind is denied by default", func(t *testing.T) { + enf := &fakeEnforcer{allow: true} + err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKind("garbage"), caller)) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if enf.called { + t.Fatal("unknown kind must not consult the enforcer") + } + }) +} diff --git a/backend/pkg/auth/scopeddb.go b/backend/pkg/auth/scopeddb.go new file mode 100644 index 0000000..860c14a --- /dev/null +++ b/backend/pkg/auth/scopeddb.go @@ -0,0 +1,129 @@ +package auth + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// ownerLoader is the minimal DB contract that ScopedDB needs: fetch a resource +// by ID and populate the caller-supplied result. *template.DBImp[T] satisfies +// this interface without any adapter code. +type ownerLoader[T any] interface { + Get(ctx context.Context, objectRef bson.ObjectID, result T) error +} + +// ScopedDB[T] provides loadAndEnforce semantics for resources that implement +// model.OwnerBound. It loads the resource from the database (so ownership is +// always read from the authoritative source, never from request fields) and +// then delegates access control to ScopedAuthorizer, which checks membership +// before ownership/policy enforcement. +// +// Use ProtectedDB for resources that carry no ownerKind/ownerRef and rely +// solely on org-level policy checks. +type ScopedDB[T model.OwnerBound] struct { + // DB is the raw repository accessor. *template.DBImp[T] satisfies this. + DB ownerLoader[T] + // Logger receives Debug/Warn messages from the enforcement path. + Logger mlogger.Logger + // Authorizer performs the full membership-aware access check. + Authorizer *ScopedAuthorizer + // OrgPolicyRef is the built-in policy checked when ownerKind==organization. + OrgPolicyRef bson.ObjectID + // Collection identifies the resource type in access-denied errors and logs. + Collection mservice.Type + // New allocates a zero-value T ready to be filled by DB.Get. + New func() T +} + +// NewScopedDB constructs a ScopedDB and validates required dependencies. +// Logger defaults to zap.NewNop() if nil. +func NewScopedDB[T model.OwnerBound]( + db ownerLoader[T], + authorizer *ScopedAuthorizer, + orgPolicyRef bson.ObjectID, + collection mservice.Type, + newFn func() T, + logger mlogger.Logger, +) *ScopedDB[T] { + if db == nil { + panic("auth.NewScopedDB: DB must not be nil") + } + if authorizer == nil { + panic("auth.NewScopedDB: Authorizer must not be nil") + } + if newFn == nil { + panic("auth.NewScopedDB: New must not be nil") + } + if logger == nil { + logger = zap.NewNop() + } + return &ScopedDB[T]{ + DB: db, + Logger: logger, + Authorizer: authorizer, + OrgPolicyRef: orgPolicyRef, + Collection: collection, + New: newFn, + } +} + +// LoadAndEnforce fetches the resource identified by objectRef, reads the +// ownerKind and ownerRef fields from the database record, and enforces access +// via ScopedAuthorizer (membership check → ownership/policy check). It never +// trusts ownership data from the caller. +// +// Logging contract: +// - Debug on access-check start and after ownership is loaded from DB. +// - Debug when access is denied (ErrAccessDenied) — intentional, not an error. +// - Warn when the Enforcer itself returns a non-nil error (unexpected failure). +// - No log on success. +func (s *ScopedDB[T]) LoadAndEnforce(ctx context.Context, callerRef, objectRef bson.ObjectID, action model.Action) (T, error) { + var zero T + + s.Logger.Debug("access check started", + mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), + zap.String("collection", s.Collection), zap.String("action", string(action))) + + obj := s.New() + if err := s.DB.Get(ctx, objectRef, obj); err != nil { + return zero, err + } + + s.Logger.Debug("ownership loaded from db", + mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), + zap.String("ownerKind", string(obj.GetOwnerKind()))) + + err := s.Authorizer.Authorize(ctx, ScopedAccessParams{ + Service: s.Collection, + PolicyRef: s.OrgPolicyRef, + CallerRef: callerRef, + OrgRef: obj.GetOrganizationRef(), + ObjectRef: objectRef, + OwnerKind: obj.GetOwnerKind(), + OwnerRef: obj.GetOwnerRef(), + Action: action, + }) + if err == nil { + return obj, nil + } + + if errors.Is(err, merrors.ErrAccessDenied) { + s.Logger.Debug("access denied", + mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), + zap.String("ownerKind", string(obj.GetOwnerKind())), + zap.String("action", string(action))) + } else { + s.Logger.Warn("enforcer returned error during scoped access check", + zap.Error(err), mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), + zap.String("action", string(action))) + } + return zero, err +} diff --git a/backend/pkg/auth/scopeddb_test.go b/backend/pkg/auth/scopeddb_test.go new file mode 100644 index 0000000..240c6d1 --- /dev/null +++ b/backend/pkg/auth/scopeddb_test.go @@ -0,0 +1,256 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// testOwned is a minimal model.OwnerBound used exclusively by ScopedDB tests. +type testOwned struct { + id bson.ObjectID + orgRef bson.ObjectID + kind model.OwnerKind + ownerRef bson.ObjectID +} + +func (o *testOwned) GetID() *bson.ObjectID { return &o.id } +func (o *testOwned) SetID(id bson.ObjectID) { o.id = id } +func (o *testOwned) Update() {} +func (o *testOwned) Collection() string { return "test_owned" } +func (o *testOwned) GetOrganizationRef() bson.ObjectID { return o.orgRef } +func (o *testOwned) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } +func (o *testOwned) GetOwnerKind() model.OwnerKind { return o.kind } +func (o *testOwned) GetOwnerRef() bson.ObjectID { return o.ownerRef } + +var _ model.OwnerBound = (*testOwned)(nil) + +// fakeOwnerLoader returns the pre-built object or the configured error. +type fakeOwnerLoader struct { + obj *testOwned + err error +} + +func (f *fakeOwnerLoader) Get(_ context.Context, _ bson.ObjectID, result *testOwned) error { + if f.err != nil { + return f.err + } + *result = *f.obj + return nil +} + +// fakeMembership controls whether IsMember returns true/false/error. +type fakeMembership struct { + member bool + err error + called bool +} + +func (f *fakeMembership) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { + f.called = true + return f.member, f.err +} + +func newTestScopedDB(loader *fakeOwnerLoader, membership *fakeMembership, enf Enforcer) *ScopedDB[*testOwned] { + authorizer := &ScopedAuthorizer{ + Logger: zap.NewNop(), + Membership: membership, + Enforcer: enf, + } + return &ScopedDB[*testOwned]{ + DB: loader, + Logger: zap.NewNop(), + Authorizer: authorizer, + OrgPolicyRef: bson.NewObjectID(), + Collection: mservice.Type("test_owned"), + New: func() *testOwned { return &testOwned{} }, + } +} + +func TestScopedDB_PersonalOwner_Allowed(t *testing.T) { + caller := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: bson.NewObjectID(), + kind: model.OwnerKindAccount, + ownerRef: caller, // owner == caller + } + enf := &fakeEnforcer{} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) + + result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if err != nil { + t.Fatalf("expected allow, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result on success") + } + if enf.called { + t.Fatal("personal access must not consult the enforcer") + } +} + +func TestScopedDB_PersonalOwner_Denied(t *testing.T) { + caller := bson.NewObjectID() + other := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: bson.NewObjectID(), + kind: model.OwnerKindAccount, + ownerRef: other, // owner != caller + } + enf := &fakeEnforcer{} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) + + _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if enf.called { + t.Fatal("personal access must not consult the enforcer") + } +} + +func TestScopedDB_OrgOwner_Allowed(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: orgRef, + kind: model.OwnerKindOrganization, + ownerRef: orgRef, + } + enf := &fakeEnforcer{allow: true} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) + + result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if err != nil { + t.Fatalf("expected allow, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result on success") + } + if !enf.called { + t.Fatal("org access must consult the enforcer") + } +} + +func TestScopedDB_OrgOwner_Denied(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: orgRef, + kind: model.OwnerKindOrganization, + ownerRef: orgRef, + } + enf := &fakeEnforcer{allow: false} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) + + _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied, got %v", err) + } + if !enf.called { + t.Fatal("org access must consult the enforcer") + } +} + +func TestScopedDB_OrgOwner_EnforcerError_Propagated(t *testing.T) { + boom := merrors.Define("enforcer backend down") + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: orgRef, + kind: model.OwnerKindOrganization, + ownerRef: orgRef, + } + enf := &fakeEnforcer{err: boom} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) + + _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if !errors.Is(err, boom) { + t.Fatalf("expected underlying error propagated, got %v", err) + } + if errors.Is(err, merrors.ErrAccessDenied) { + t.Fatal("enforcer internal error must not be masked as access denied") + } +} + +func TestScopedDB_DBError_Propagated(t *testing.T) { + dbErr := merrors.Define("mongo unavailable") + db := newTestScopedDB(&fakeOwnerLoader{err: dbErr}, &fakeMembership{member: true}, &fakeEnforcer{}) + + _, err := db.LoadAndEnforce(context.Background(), bson.NewObjectID(), bson.NewObjectID(), model.ActionRead) + if !errors.Is(err, dbErr) { + t.Fatalf("expected DB error propagated, got %v", err) + } +} + +// TestScopedDB_PersonalOwner_NonMember_Denied is the main regression case: +// even when ownerRef == callerRef, a non-member of the organization must be +// denied before ownership is checked, so the tenant boundary is enforced. +// TestScopedDB_PersonalOwner_NonMember_Allowed: personal resource, caller is the +// owner but is NOT a member of the org. Membership is not consulted for +// account-owned resources — ownership alone is sufficient. +func TestScopedDB_PersonalOwner_NonMember_Allowed(t *testing.T) { + caller := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: bson.NewObjectID(), + kind: model.OwnerKindAccount, + ownerRef: caller, // owner == caller, membership is irrelevant + } + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{} + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) + + result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if err != nil { + t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result on success") + } + if enf.called { + t.Fatal("enforcer must not be called for account-owned resource") + } +} + +// TestScopedDB_OrgOwner_NonMember_Denied: org-owned object, caller is not a +// member — must be denied before the policy enforcer is consulted. +func TestScopedDB_OrgOwner_NonMember_Denied(t *testing.T) { + caller := bson.NewObjectID() + orgRef := bson.NewObjectID() + objectRef := bson.NewObjectID() + obj := &testOwned{ + id: objectRef, + orgRef: orgRef, + kind: model.OwnerKindOrganization, + ownerRef: orgRef, + } + membership := &fakeMembership{member: false} + enf := &fakeEnforcer{allow: true} // would allow if reached + db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) + + _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) + if !errors.Is(err, merrors.ErrAccessDenied) { + t.Fatalf("expected access denied for non-member org-owned object, got %v", err) + } + if enf.called { + t.Fatal("enforcer must not be called when membership check fails") + } +} diff --git a/backend/pkg/db/account/account.go b/backend/pkg/db/account/account.go new file mode 100644 index 0000000..335b1f0 --- /dev/null +++ b/backend/pkg/db/account/account.go @@ -0,0 +1,16 @@ +package account + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// DB is the interface which must be implemented by all db drivers +type DB interface { + template.DB[*model.Account] + GetByEmail(ctx context.Context, email string) (*model.Account, error) + GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) +} diff --git a/backend/pkg/db/config.go b/backend/pkg/db/config.go new file mode 100644 index 0000000..015d0e5 --- /dev/null +++ b/backend/pkg/db/config.go @@ -0,0 +1,103 @@ +package db + +import ( + "strings" + + mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/go-viper/mapstructure/v2" + "go.uber.org/zap" +) + +type DBDriver string + +const ( + Mongo DBDriver = "mongodb" +) + +type Config = model.DriverConfig[DBDriver] + +// PermissionsDatabaseConfig is the explicit public contract for services that +// read authorization metadata from the central permissions database. +type PermissionsDatabaseConfig Config + +// AsDatabaseConfig converts the permissions DB contract to the existing DB +// config type without duplicating driver parsing or connection behavior. +func (c *PermissionsDatabaseConfig) AsDatabaseConfig() *Config { + if c == nil { + return nil + } + converted := Config(*c) + return &converted +} + +// ResolvedDatabaseName identifies the database selected by a DB config. +type ResolvedDatabaseName struct { + Name string + Source string +} + +// ResolveDatabaseName resolves only the database name portion of a DB config. +// It intentionally mirrors the existing env-over-literal behavior and leaves +// low-level connection validation to the normal DB connection path. +func ResolveDatabaseName(config *Config) ResolvedDatabaseName { + if config == nil { + return ResolvedDatabaseName{} + } + return resolveDatabaseName(config) +} + +// ResolvePermissionsDatabaseName resolves only the database name portion of a +// permissions DB config. +func ResolvePermissionsDatabaseName(config *PermissionsDatabaseConfig) ResolvedDatabaseName { + return ResolveDatabaseName(config.AsDatabaseConfig()) +} + +// ValidateSplitDomainPermissionsDatabase verifies the generic split-domain +// invariant for services that must keep authorization metadata separate from +// domain storage. +func ValidateSplitDomainPermissionsDatabase(service string, domain *Config, permissions *PermissionsDatabaseConfig) (ResolvedDatabaseName, ResolvedDatabaseName, error) { + domainName := ResolveDatabaseName(domain) + permissionsName := ResolvePermissionsDatabaseName(permissions) + + if permissions == nil { + return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database configuration is required", "permissions_database") + } + if strings.TrimSpace(domainName.Name) == "" { + return domainName, permissionsName, merrors.InvalidArgument(service+" domain database name is required for split-domain validation", "database") + } + if strings.TrimSpace(permissionsName.Name) == "" { + return domainName, permissionsName, merrors.InvalidArgument(service+" permissions database name is required for split-domain validation", "permissions_database") + } + if domainName.Name == permissionsName.Name { + return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database must resolve to a different database than the domain database", "permissions_database") + } + + return domainName, permissionsName, nil +} + +func resolveDatabaseName(config *Config) ResolvedDatabaseName { + if config == nil || config.Driver != Mongo { + return ResolvedDatabaseName{} + } + var mongoConfig mongoimpl.Config + if err := mapstructure.Decode(config.Settings, &mongoConfig); err != nil { + return ResolvedDatabaseName{} + } + dbSettings := mongoimpl.Config2DBSettings(zap.NewNop(), &mongoConfig) + source := "" + if mongoConfig.DatabaseEnv != nil { + source = "env:" + *mongoConfig.DatabaseEnv + } else if mongoConfig.Database != nil { + source = "settings.database" + } + if source != "" { + return ResolvedDatabaseName{ + Name: dbSettings.Database, + Source: source, + } + } + + return ResolvedDatabaseName{} +} diff --git a/backend/pkg/db/connection.go b/backend/pkg/db/connection.go new file mode 100644 index 0000000..10cc975 --- /dev/null +++ b/backend/pkg/db/connection.go @@ -0,0 +1,59 @@ +package db + +import ( + "context" + + mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" +) + +// Connection represents a low-level database connection lifecycle. +type Connection interface { + Disconnect(ctx context.Context) error + Ping(ctx context.Context) error +} + +// MongoConnection provides direct access to the underlying mongo client. +type MongoConnection struct { + client *mongo.Client + database string +} + +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) Ping(ctx context.Context) error { + return c.client.Ping(ctx, readpref.Primary()) +} + +// ConnectMongo returns a low-level MongoDB connection without constructing repositories. +func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) { + if config == nil { + return nil, merrors.InvalidArgument("database configuration is nil", "config") + } + if config.Driver != Mongo { + return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver") + } + + client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings) + if err != nil { + return nil, err + } + + return &MongoConnection{ + client: client, + database: settings.Database, + }, nil +} diff --git a/backend/pkg/db/factory.go b/backend/pkg/db/factory.go new file mode 100644 index 0000000..a3d42e8 --- /dev/null +++ b/backend/pkg/db/factory.go @@ -0,0 +1,53 @@ +package db + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" + "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +// Factory exposes high-level repositories used by application services. +type Factory interface { + NewAccountDB() (account.DB, error) + NewAccountDBWithCollection(collection string) (account.DB, error) + NewInvitationsDB() (invitation.DB, error) + + NewRolesDB() (role.DB, error) + NewPoliciesDB() (policy.DB, error) + + TransactionFactory() transaction.Factory + + Permissions() auth.Provider + + CloseConnection() +} + +// NewConnection builds a Factory backed by the configured driver. +func NewConnection(logger mlogger.Logger, config *Config) (Factory, error) { + if config == nil { + return nil, merrors.InvalidArgument("database configuration is required", "config") + } + if config.Driver == Mongo { + return mongoimpl.NewConnection(logger, config.Settings) + } + return nil, merrors.InvalidArgument("unknown database driver: "+string(config.Driver), "config.driver") +} + +// NewCentralPermissionsConnection opens the explicitly configured central +// permissions database. It intentionally has no fallback to a service domain DB. +func NewCentralPermissionsConnection(logger mlogger.Logger, config *PermissionsDatabaseConfig) (Factory, error) { + if config == nil { + return nil, merrors.InvalidArgument("permissions_database configuration is required", "permissions_database") + } + conn, err := NewConnection(logger, config.AsDatabaseConfig()) + if err != nil { + return nil, merrors.Wrap(err, "permissions_database") + } + return conn, nil +} diff --git a/backend/pkg/db/internal/mongo/accountdb/db.go b/backend/pkg/db/internal/mongo/accountdb/db.go new file mode 100644 index 0000000..c83bf6b --- /dev/null +++ b/backend/pkg/db/internal/mongo/accountdb/db.go @@ -0,0 +1,42 @@ +package accountdb + +import ( + "strings" + + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type AccountDB struct { + template.DBImp[*model.Account] +} + +func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) { + return CreateWithCollection(logger, db, mservice.Accounts) +} + +func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) { + if normalized := strings.TrimSpace(collection); normalized != "" { + collection = normalized + } else { + collection = mservice.Accounts + } + + p := &AccountDB{ + DBImp: *template.Create[*model.Account](logger, collection, db), + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "login", Sort: ri.Asc}}, + Unique: true, + }); err != nil { + p.Logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", collection)) + return nil, err + } + return p, nil +} diff --git a/backend/pkg/db/internal/mongo/accountdb/user.go b/backend/pkg/db/internal/mongo/accountdb/user.go new file mode 100644 index 0000000..434f01f --- /dev/null +++ b/backend/pkg/db/internal/mongo/accountdb/user.go @@ -0,0 +1,21 @@ +package accountdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *AccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) { + filter := repository.Query().Comparison(repository.IDField(), builder.In, refs) + return mutil.GetObjects[model.Account](ctx, db.Logger, filter, nil, db.Repository) +} + +func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { + var account model.Account + return &account, db.FindOne(ctx, repository.Filter("login", email), &account) +} diff --git a/backend/pkg/db/internal/mongo/db.go b/backend/pkg/db/internal/mongo/db.go new file mode 100644 index 0000000..b57113a --- /dev/null +++ b/backend/pkg/db/internal/mongo/db.go @@ -0,0 +1,328 @@ +package mongo + +import ( + "context" + "fmt" + "net" + "os" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/accountdb" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/invitationdb" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/rolesdb" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/transactionimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/role" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/config" + "github.com/go-viper/mapstructure/v2" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" + "go.uber.org/zap" +) + +// Config represents configuration +type Config struct { + Port *string `mapstructure:"port"` + PortEnv *string `mapstructure:"port_env"` + User *string `mapstructure:"user"` + UserEnv *string `mapstructure:"user_env"` + PasswordEnv string `mapstructure:"password_env"` + Database *string `mapstructure:"database"` + DatabaseEnv *string `mapstructure:"database_env"` + Host *string `mapstructure:"host"` + HostEnv *string `mapstructure:"host_env"` + Hosts []string `mapstructure:"hosts,omitempty"` + HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"` + HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"` + PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"` + PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"` + URI *string `mapstructure:"uri,omitempty"` + URIEnv *string `mapstructure:"uri_env,omitempty"` + AuthSource *string `mapstructure:"auth_source,omitempty"` + AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"` + AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"` + AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"` + ReplicaSet *string `mapstructure:"replica_set,omitempty"` + ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"` + Enforcer *auth.Config `mapstructure:"enforcer"` +} + +type DBSettings struct { + Host string + Hosts []string + Port string + User string + Password string + Database string + AuthSource string + AuthMechanism string + ReplicaSet string + URI string +} + +func newProtectedDB[T any]( + db *DB, + create func(ctx context.Context, logger mlogger.Logger, enforcer auth.Enforcer, pdb policy.DB, client *mongo.Database) (T, error), +) (T, error) { + pdb, err := db.NewPoliciesDB() + if err != nil { + db.logger.Warn("Failed to create policies database", zap.Error(err)) + var zero T + return zero, err + } + return create(context.Background(), db.logger, db.Enforcer(), pdb, db.db()) +} + +func Config2DBSettings(logger mlogger.Logger, config *Config) *DBSettings { + p := new(DBSettings) + p.Port = mutil.GetConfigValue(logger, "port", "port_env", config.Port, config.PortEnv) + p.Database = mutil.GetConfigValue(logger, "database", "database_env", config.Database, config.DatabaseEnv) + p.Password = os.Getenv(config.PasswordEnv) + p.User = mutil.GetConfigValue(logger, "user", "user_env", config.User, config.UserEnv) + p.Host = mutil.GetConfigValue(logger, "host", "host_env", config.Host, config.HostEnv) + p.AuthSource = mutil.GetConfigValue(logger, "auth_source", "auth_source_env", config.AuthSource, config.AuthSourceEnv) + p.AuthMechanism = mutil.GetConfigValue(logger, "auth_mechanism", "auth_mechanism_env", config.AuthMechanism, config.AuthMechanismEnv) + p.ReplicaSet = mutil.GetConfigValue(logger, "replica_set", "replica_set_env", config.ReplicaSet, config.ReplicaSetEnv) + p.URI = mutil.GetConfigValue(logger, "uri", "uri_env", config.URI, config.URIEnv) + + hostPrefix := mutil.GetConfigValue(logger, "hosts_env_prefix", "hosts_env_prefix_env", config.HostsEnvPrefix, config.HostsEnvPrefixEnv) + portPrefix := mutil.GetConfigValue(logger, "ports_env_prefix", "ports_env_prefix_env", config.PortsEnvPrefix, config.PortsEnvPrefixEnv) + + if hostPrefix == "" && p.ReplicaSet != "" { + hostPrefix = "MONGO_HOSTS_" + } + if portPrefix == "" && p.ReplicaSet != "" { + portPrefix = "MONGO_PORTS_" + } + + p.Hosts = collectReplicaHosts(config.Hosts, p.ReplicaSet, p.Port, hostPrefix, portPrefix) + return p +} + +func decodeConfig(logger mlogger.Logger, settings model.SettingsT) (*Config, *DBSettings, error) { + var config Config + if err := mapstructure.Decode(settings, &config); err != nil { + logger.Warn("Failed to decode settings", zap.Error(err)) + return nil, nil, err + } + dbSettings := Config2DBSettings(logger, &config) + return &config, dbSettings, nil +} + +func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, error) { + opts := buildOptions(dbSettings) + + client, err := mongo.Connect(opts) + if err != nil { + logger.Error("Unable to connect to database", zap.Error(err)) + return nil, err + } + + if dbSettings.URI != "" { + logger.Info("Connected successfully", zap.Bool("uri_provided", true)) + } else { + logger.Info("Connected successfully", zap.String("user", dbSettings.User), + zap.Strings("hosts", opts.Hosts), zap.String("port", dbSettings.Port), + zap.String("database", dbSettings.Database), zap.String("replica_set", dbSettings.ReplicaSet)) + } + + if err := client.Ping(context.Background(), readpref.Primary()); err != nil { + logger.Error("Unable to ping database", zap.Error(err)) + _ = client.Disconnect(context.Background()) + return nil, err + } + + return client, nil +} + +func ConnectClient(logger mlogger.Logger, settings model.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 +type DB struct { + logger mlogger.Logger + config *DBSettings + client *mongo.Client + enforcer auth.Enforcer + manager auth.Manager + pdb policy.DB +} + +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) NewAccountDB() (account.DB, error) { + return accountdb.Create(db.logger, db.db()) +} + +func (db *DB) NewAccountDBWithCollection(collection string) (account.DB, error) { + return accountdb.CreateWithCollection(db.logger, db.db(), collection) +} + +func (db *DB) NewInvitationsDB() (invitation.DB, error) { + return newProtectedDB(db, invitationdb.Create) +} + +func (db *DB) NewPoliciesDB() (policy.DB, error) { + return db.pdb, nil +} + +func (db *DB) NewRolesDB() (role.DB, error) { + return rolesdb.Create(db.logger, db.db()) +} + +func (db *DB) TransactionFactory() transaction.Factory { + return transactionimp.CreateFactory(db.client) +} + +func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string { + normalize := func(host, port string) string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + if host == "" { + return "" + } + // If host already has a port, keep it; otherwise apply provided/default port. + if _, _, err := net.SplitHostPort(host); err == nil { + return host + } + if port != "" { + return net.JoinHostPort(host, port) + } + if defaultPort != "" { + return net.JoinHostPort(host, defaultPort) + } + return host + } + + appendHost := func(list []string, host, port string) []string { + if normalized := normalize(host, port); normalized != "" { + return append(list, normalized) + } + return list + } + + var hosts []string + for _, h := range configuredHosts { + hosts = appendHost(hosts, h, "") + } + + if replicaSet == "" || hostPrefix == "" { + return hosts + } + + index := 0 + for { + hostEnv := os.Getenv(fmt.Sprintf("%s%d", hostPrefix, index)) + portEnv := "" + if portPrefix != "" { + portEnv = os.Getenv(fmt.Sprintf("%s%d", portPrefix, index)) + } + + if hostEnv == "" && index == 0 { + hostEnv = os.Getenv(fmt.Sprintf("%s%d", hostPrefix, 1)) + if portPrefix != "" { + portEnv = os.Getenv(fmt.Sprintf("%s%d", portPrefix, 1)) + } + if hostEnv == "" { + break + } + index = 1 + } else if hostEnv == "" { + break + } + + hosts = appendHost(hosts, hostEnv, portEnv) + index++ + } + + return hosts +} + +func (db *DB) Permissions() auth.Provider { + return db +} + +func (db *DB) Manager() auth.Manager { + return db.manager +} + +func (db *DB) Enforcer() auth.Enforcer { + return db.enforcer +} + +func (db *DB) GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) { + var policyDescription model.PolicyDescription + return &policyDescription, db.pdb.FindOne(ctx, repository.Filter("resourceTypes", resource), &policyDescription) +} + +func (db *DB) CloseConnection() { + if err := db.client.Disconnect(context.Background()); err != nil { + db.logger.Warn("Failed to close connection", zap.Error(err)) + } + db.logger.Info("Database connection closed") +} + +// NewConnection creates a new database connection +func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) { + client, config, dbSettings, err := ConnectClient(logger, settings) + if err != nil { + return nil, err + } + + db := &DB{ + logger: logger.Named("db"), + config: dbSettings, + client: client, + } + + cleanup := func(ctx context.Context) { + if err := client.Disconnect(ctx); err != nil { + logger.Warn("Failed to close MongoDB connection", zap.Error(err)) + } + } + + rdb, err := db.NewRolesDB() + if err != nil { + db.logger.Warn("Failed to create roles database", zap.Error(err)) + cleanup(context.Background()) + return nil, err + } + if db.pdb, err = policiesdb.Create(db.logger, db.db()); err != nil { + db.logger.Warn("Failed to create policies database", zap.Error(err)) + cleanup(context.Background()) + return nil, err + } + if db.enforcer, db.manager, err = auth.CreateAuth(logger, db.client, db.db(), db.pdb, rdb, config.Enforcer); err != nil { + db.logger.Warn("Failed to create permissions enforcer", zap.Error(err)) + cleanup(context.Background()) + return nil, err + } + + return db, nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/accept.go b/backend/pkg/db/internal/mongo/invitationdb/accept.go new file mode 100644 index 0000000..b493ea9 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/accept.go @@ -0,0 +1,12 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *InvitationDB) Accept(ctx context.Context, invitationRef bson.ObjectID) error { + return db.updateStatus(ctx, invitationRef, model.InvitationAccepted) +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/archived.go b/backend/pkg/db/internal/mongo/invitationdb/archived.go new file mode 100644 index 0000000..0b95b25 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/archived.go @@ -0,0 +1,49 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// SetArchived sets the archived status of an invitation +// Invitation supports archiving through PermissionBound embedding ArchivableBase +func (db *InvitationDB) SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error { + db.DBImp.Logger.Debug("Setting invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade)) + res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, invitationRef, model.ActionUpdate) + if err != nil { + db.DBImp.Logger.Warn("Failed to enforce archivation permission", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) + return err + } + if !res { + db.DBImp.Logger.Debug("Permission denied for archivation", mzap.ObjRef("invitation_ref", invitationRef)) + return merrors.AccessDenied(db.Collection, string(model.ActionUpdate), invitationRef) + } + + // Get the invitation first + var invitation model.Invitation + if err := db.Get(ctx, accountRef, invitationRef, &invitation); err != nil { + db.DBImp.Logger.Warn("Error retrieving invitation for archival", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) + return err + } + + // Update the invitation's archived status + invitation.SetArchived(archived) + if err := db.Update(ctx, accountRef, &invitation); err != nil { + db.DBImp.Logger.Warn("Error updating invitation archived status", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) + return err + } + + // Note: Currently no cascade dependencies for invitations + // If cascade is enabled, we could add logic here for any future dependencies + if cascade { + db.DBImp.Logger.Debug("Cascade archiving requested but no dependencies to archive for invitation", mzap.ObjRef("invitation_ref", invitationRef)) + } + + db.DBImp.Logger.Debug("Successfully set invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived)) + return nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/cascade.go b/backend/pkg/db/internal/mongo/invitationdb/cascade.go new file mode 100644 index 0000000..08cce22 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/cascade.go @@ -0,0 +1,24 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// DeleteCascade deletes an invitation +// Invitations don't have cascade dependencies, so this is a simple deletion +func (db *InvitationDB) DeleteCascade(ctx context.Context, accountRef, invitationRef bson.ObjectID) error { + db.DBImp.Logger.Debug("Starting invitation cascade deletion", mzap.ObjRef("invitation_ref", invitationRef)) + + // Delete the invitation itself (no dependencies to cascade delete) + if err := db.Delete(ctx, accountRef, invitationRef); err != nil { + db.DBImp.Logger.Error("Error deleting invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) + return err + } + + db.DBImp.Logger.Debug("Successfully deleted invitation", mzap.ObjRef("invitation_ref", invitationRef)) + return nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db.go b/backend/pkg/db/internal/mongo/invitationdb/db.go new file mode 100644 index 0000000..3293103 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/db.go @@ -0,0 +1,48 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type InvitationDB struct { + auth.ProtectedDBImp[*model.Invitation] +} + +func Create( + ctx context.Context, + logger mlogger.Logger, + enforcer auth.Enforcer, + pdb policy.DB, + db *mongo.Database, +) (*InvitationDB, error) { + p, err := auth.CreateDBImp[*model.Invitation](ctx, logger, pdb, enforcer, mservice.Invitations, db) + if err != nil { + return nil, err + } + + if err := p.DBImp.Repository.CreateIndex(pendingInvitationEmailIndexDefinition()); err != nil { + p.DBImp.Logger.Error("Failed to create unique pending invitation email index", zap.Error(err)) + return nil, err + } + + // ttl index + ttl := int32(0) // zero ttl means expiration on date preset when inserting data + if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, + TTL: &ttl, + }); err != nil { + p.DBImp.Logger.Error("Failed to create ttl index in the invitations", zap.Error(err)) + return nil, err + } + + return &InvitationDB{ProtectedDBImp: *p}, nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go new file mode 100644 index 0000000..6d59c91 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go @@ -0,0 +1,108 @@ +//go:build integration +// +build integration + +package invitationdb + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestPendingInvitationEmailIndexBehavior(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)), + ) + require.NoError(t, err) + defer func() { + termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer termCancel() + _ = mongoContainer.Terminate(termCtx) + }() + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err) + + client, err := mongo.Connect(options.Client().ApplyURI(mongoURI)) + require.NoError(t, err) + defer func() { _ = client.Disconnect(context.Background()) }() + + database := client.Database("test_invitation_pending_email_index") + defer func() { _ = database.Drop(context.Background()) }() + + repo := repository.CreateMongoRepository(database, "invitations") + require.NoError(t, repo.CreateIndex(pendingInvitationEmailIndexDefinition())) + assertPendingEmailIndexDefinition(t, ctx, database.Collection("invitations")) + + orgRef := bson.NewObjectID() + email := "invitee@example.com" + require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationCreated), nil)) + err = repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationSent), nil) + require.ErrorIs(t, err, merrors.ErrDataConflict, "duplicate pending invitation must conflict") + + for _, status := range []model.InvitationStatus{model.InvitationRevoked, model.InvitationDeclined, model.InvitationAccepted} { + statusEmail := string(status) + "@example.com" + require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, status), nil)) + require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, model.InvitationCreated), nil)) + } + + otherOrgRef := bson.NewObjectID() + require.NoError(t, repo.Insert(ctx, testInvitation(otherOrgRef, email, model.InvitationCreated), nil)) +} + +func testInvitation(orgRef bson.ObjectID, email string, status model.InvitationStatus) *model.Invitation { + inv := &model.Invitation{ + OrganizationRef: orgRef, + Status: status, + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + inv.SetID(bson.NewObjectID()) + inv.SetOrganizationRef(orgRef) + inv.Content.Email = email + return inv +} + +func assertPendingEmailIndexDefinition(t *testing.T, ctx context.Context, collection *mongo.Collection) { + t.Helper() + cursor, err := collection.Indexes().List(ctx) + require.NoError(t, err) + defer func() { _ = cursor.Close(ctx) }() + + for cursor.Next(ctx) { + var index bson.M + require.NoError(t, cursor.Decode(&index)) + if index["name"] != uniquePendingInvitationEmailIndexName { + continue + } + + require.Equal(t, true, index["unique"]) + require.Equal(t, bson.D{ + {Key: "organizationRef", Value: int32(1)}, + {Key: "description.email", Value: int32(1)}, + }, index["key"]) + require.Equal(t, bson.D{ + {Key: "status", Value: bson.D{{Key: "$in", Value: bson.A{"created", "sent"}}}}, + {Key: "description.email", Value: bson.D{{Key: "$type", Value: "string"}}}, + }, index["partialFilterExpression"]) + return + } + require.NoError(t, cursor.Err()) + t.Fatalf("index %q was not created", uniquePendingInvitationEmailIndexName) +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_test.go new file mode 100644 index 0000000..726b8cb --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/db_test.go @@ -0,0 +1,81 @@ +package invitationdb + +import ( + "reflect" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestPendingInvitationEmailIndexDefinition(t *testing.T) { + def := pendingInvitationEmailIndexDefinition() + + expectedKeys := []ri.Key{ + {Field: "organizationRef", Sort: ri.Asc}, + {Field: "description.email", Sort: ri.Asc}, + } + if !reflect.DeepEqual(def.Keys, expectedKeys) { + t.Fatalf("keys mismatch: got %#v want %#v", def.Keys, expectedKeys) + } + if !def.Unique { + t.Fatal("index must be unique") + } + if def.Name != uniquePendingInvitationEmailIndexName { + t.Fatalf("index name mismatch: got %q want %q", def.Name, uniquePendingInvitationEmailIndexName) + } + + expectedFilter := repository.Query(). + In(repository.Field("status"), "created", "sent"). + Comparison(repository.Field("description.email"), builder.Type, "string"). + BuildQuery() + if !reflect.DeepEqual(def.PartialFilter.BuildQuery(), expectedFilter) { + t.Fatalf("partial filter mismatch: got %#v want %#v", def.PartialFilter.BuildQuery(), expectedFilter) + } +} + +func TestPendingInvitationEmailIndexUsesModelPendingStatuses(t *testing.T) { + def := pendingInvitationEmailIndexDefinition() + filter := def.PartialFilter.BuildQuery() + statuses := statusInValues(t, filter) + + expected := model.PendingInvitationStatusValues() + if !reflect.DeepEqual(statuses, expected) { + t.Fatalf("index statuses drifted from model: got %#v want %#v", statuses, expected) + } +} + +func statusInValues(t *testing.T, filter bson.D) []string { + t.Helper() + for _, item := range filter { + if item.Key != "status" { + continue + } + comparison, ok := item.Value.(bson.M) + if !ok { + t.Fatalf("status filter has unexpected type %T", item.Value) + } + rawValues, ok := comparison["$in"] + if !ok { + t.Fatal("status filter must use $in") + } + values, ok := rawValues.([]any) + if !ok { + t.Fatalf("status $in has unexpected type %T", rawValues) + } + statuses := make([]string, 0, len(values)) + for _, value := range values { + status, ok := value.(string) + if !ok { + t.Fatalf("status value has unexpected type %T", value) + } + statuses = append(statuses, status) + } + return statuses + } + t.Fatal("status filter not found") + return nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/decline.go b/backend/pkg/db/internal/mongo/invitationdb/decline.go new file mode 100644 index 0000000..520bbdf --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/decline.go @@ -0,0 +1,12 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *InvitationDB) Decline(ctx context.Context, invitationRef bson.ObjectID) error { + return db.updateStatus(ctx, invitationRef, model.InvitationDeclined) +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/getpublic.go b/backend/pkg/db/internal/mongo/invitationdb/getpublic.go new file mode 100644 index 0000000..12d91f9 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/getpublic.go @@ -0,0 +1,121 @@ +package invitationdb + +import ( + "context" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +func (db *InvitationDB) GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) { + roleField := repository.Field("role") + orgField := repository.Field("organization") + accField := repository.Field("account") + empField := repository.Field("employee") + regField := repository.Field("registrationAcc") + descEmailField := repository.Field("description").Dot("email") + pipeline := repository.Pipeline(). + // 0) Filter to exactly the invitation(s) you want + Match(repository.IDFilter(invitationRef).And(repository.Filter("status", model.InvitationCreated))). + // 1) Lookup the role document + Lookup( + mservice.Roles, + repository.Field("roleRef"), + repository.IDField(), + roleField, + ). + Unwind(repository.Ref(roleField)). + // 2) Lookup the organization document + Lookup( + mservice.Organizations, + repository.Field("organizationRef"), + repository.IDField(), + orgField, + ). + Unwind(repository.Ref(orgField)). + // 3) Lookup the account document + Lookup( + mservice.Accounts, + repository.Field("inviterRef"), + repository.IDField(), + accField, + ). + Unwind(repository.Ref(accField)). + /* 4) do we already have an account whose login == invitation.description ? */ + Lookup( + mservice.Accounts, + descEmailField, // local field (invitation.description.email) + repository.Field("login"), // foreign field (account.login) + regField, // array: 0-length or ≥1 + ). + // 5) Projection + Project( + repository.SimpleAlias( + empField.Dot("description"), + repository.Ref(accField), + ), + repository.SimpleAlias( + empField.Dot("avatarUrl"), + repository.Ref(accField.Dot("avatarUrl")), + ), + repository.SimpleAlias( + orgField.Dot("description"), + repository.Ref(orgField), + ), + repository.SimpleAlias( + orgField.Dot("logoUrl"), + repository.Ref(orgField.Dot("logoUrl")), + ), + repository.SimpleAlias( + roleField, + repository.Ref(roleField), + ), + repository.SimpleAlias( + repository.Field("invitation"), // ← left-hand side + repository.Ref(repository.Field("description")), // ← right-hand side (“$description”) + ), + repository.SimpleAlias( + repository.Field("storable"), // ← left-hand side + repository.RootRef(), // ← right-hand side (“$description”) + ), + repository.ProjectionExpr( + repository.Field("registrationRequired"), + repository.Eq( + repository.Size(repository.Value(repository.Ref(regField).Build())), + repository.Literal(0), + ), + ), + ) + + var res model.PublicInvitation + haveResult := false + decoder := func(cur *mongo.Cursor) error { + if haveResult { + // should never get here + db.DBImp.Logger.Warn("Unexpected extra invitation", mzap.ObjRef("invitation_ref", invitationRef)) + return merrors.Internal("Unexpected extra invitation found by reference") + } + if e := cur.Decode(&res); e != nil { + db.DBImp.Logger.Warn("Failed to decode entity", zap.Error(e), zap.Any("data", cur.Current.String())) + return e + } + haveResult = true + return nil + } + if err := db.DBImp.Repository.Aggregate(ctx, pipeline, decoder); err != nil { + db.DBImp.Logger.Warn("Failed to execute aggregation pipeline", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) + return nil, err + } + if !haveResult { + db.DBImp.Logger.Warn("No results fetched", mzap.ObjRef("invitation_ref", invitationRef)) + return nil, merrors.NoData(fmt.Sprintf("Invitation %s not found", invitationRef.Hex())) + } + return &res, nil +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/indexes.go b/backend/pkg/db/internal/mongo/invitationdb/indexes.go new file mode 100644 index 0000000..df40161 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/indexes.go @@ -0,0 +1,30 @@ +package invitationdb + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +const uniquePendingInvitationEmailIndexName = "unique_pending_invitation_email_per_org" + +func pendingInvitationEmailIndexDefinition() *ri.Definition { + statusValues := model.PendingInvitationStatusValues() + statuses := make([]any, 0, len(statusValues)) + for _, status := range statusValues { + statuses = append(statuses, status) + } + + return &ri.Definition{ + Keys: []ri.Key{ + {Field: repository.OrgField().Build(), Sort: ri.Asc}, + {Field: "description.email", Sort: ri.Asc}, + }, + Unique: true, + Name: uniquePendingInvitationEmailIndexName, + PartialFilter: repository.Query(). + In(repository.Field("status"), statuses...). + Comparison(repository.Field("description.email"), builder.Type, "string"), + } +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/list.go b/backend/pkg/db/internal/mongo/invitationdb/list.go new file mode 100644 index 0000000..8b5cffe --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/list.go @@ -0,0 +1,28 @@ +package invitationdb + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mauth "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db/auth" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *InvitationDB) List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) { + res, err := mauth.GetProtectedObjects[model.Invitation]( + ctx, + db.DBImp.Logger, + accountRef, organizationRef, model.ActionRead, + repository.OrgFilter(organizationRef), + cursor, + db.Enforcer, + db.DBImp.Repository, + ) + if errors.Is(err, merrors.ErrNoData) { + return []model.Invitation{}, nil + } + return res, err +} diff --git a/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go b/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go new file mode 100644 index 0000000..62e5b28 --- /dev/null +++ b/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go @@ -0,0 +1,26 @@ +package invitationdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (db *InvitationDB) updateStatus(ctx context.Context, invitationRef bson.ObjectID, newStatus model.InvitationStatus) error { + // db.DBImp.Up + var inv model.Invitation + if err := db.DBImp.FindOne(ctx, repository.IDFilter(invitationRef), &inv); err != nil { + db.DBImp.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) + return err + } + inv.Status = newStatus + if err := db.DBImp.Update(ctx, &inv); err != nil { + db.DBImp.Logger.Warn("Failed to update invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) + return err + } + return nil +} diff --git a/backend/pkg/db/internal/mongo/mongo.go b/backend/pkg/db/internal/mongo/mongo.go new file mode 100644 index 0000000..45915bc --- /dev/null +++ b/backend/pkg/db/internal/mongo/mongo.go @@ -0,0 +1,49 @@ +package mongo + +import ( + "net" + "strings" + + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func buildOptions(s *DBSettings) *options.ClientOptions { + opts := options.Client() + + if s.URI != "" { + return opts.ApplyURI(s.URI) + } + + hosts := make([]string, 0, len(s.Hosts)+1) + for _, h := range s.Hosts { + if trimmed := strings.TrimSpace(h); trimmed != "" { + hosts = append(hosts, trimmed) + } + } + + if len(hosts) == 0 && s.Host != "" { + host := s.Host + if _, _, err := net.SplitHostPort(host); err != nil && s.Port != "" { + host = net.JoinHostPort(host, s.Port) + } + hosts = append(hosts, host) + } + + if len(hosts) > 0 { + opts.SetHosts(hosts) + } + + if s.ReplicaSet != "" { + opts.SetReplicaSet(s.ReplicaSet) + } + + cred := options.Credential{ + AuthMechanism: s.AuthMechanism, + AuthSource: s.AuthSource, + Username: s.User, + Password: s.Password, + } + opts.SetAuth(cred) + + return opts +} diff --git a/backend/pkg/db/internal/mongo/policiesdb/all.go b/backend/pkg/db/internal/mongo/policiesdb/all.go new file mode 100644 index 0000000..c471c39 --- /dev/null +++ b/backend/pkg/db/internal/mongo/policiesdb/all.go @@ -0,0 +1,20 @@ +package policiesdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *PoliciesDB) All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) { + // all documents + filter := repository.Query().Or( + repository.Filter(storable.OrganizationRefField, nil), + repository.OrgFilter(organizationRef), + ) + return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) +} diff --git a/backend/pkg/db/internal/mongo/policiesdb/builtin.go b/backend/pkg/db/internal/mongo/policiesdb/builtin.go new file mode 100644 index 0000000..1c03a5d --- /dev/null +++ b/backend/pkg/db/internal/mongo/policiesdb/builtin.go @@ -0,0 +1,13 @@ +package policiesdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +func (db *PoliciesDB) GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error { + return db.FindOne(ctx, repository.Filter("resourceTypes", resourceType), policy) +} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db.go b/backend/pkg/db/internal/mongo/policiesdb/db.go new file mode 100644 index 0000000..484676f --- /dev/null +++ b/backend/pkg/db/internal/mongo/policiesdb/db.go @@ -0,0 +1,21 @@ +package policiesdb + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type PoliciesDB struct { + template.DBImp[*model.PolicyDescription] +} + +func Create(logger mlogger.Logger, db *mongo.Database) (*PoliciesDB, error) { + p := &PoliciesDB{ + DBImp: *template.Create[*model.PolicyDescription](logger, mservice.Policies, db), + } + + return p, nil +} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db_test.go b/backend/pkg/db/internal/mongo/policiesdb/db_test.go new file mode 100644 index 0000000..6c4baf8 --- /dev/null +++ b/backend/pkg/db/internal/mongo/policiesdb/db_test.go @@ -0,0 +1,355 @@ +//go:build integration +// +build integration + +package policiesdb_test + +import ( + "context" + "errors" + "testing" + "time" + + // Your internal packages + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + + // Model package (contains PolicyDescription + Describable) + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + // Testcontainers + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.uber.org/zap" +) + +// Helper to terminate container +func terminate(t *testing.T, ctx context.Context, container *mongodb.MongoDBContainer) { + err := container.Terminate(ctx) + require.NoError(t, err, "failed to terminate MongoDB container") +} + +// Helper to disconnect client +func disconnect(t *testing.T, ctx context.Context, client *mongo.Client) { + err := client.Disconnect(context.Background()) + require.NoError(t, err, "failed to disconnect from MongoDB") +} + +// Helper to drop the Policies collection +func cleanupCollection(t *testing.T, ctx context.Context, db *mongo.Database) { + // The actual collection name is typically the value returned by + // (&model.PolicyDescription{}).Collection(), or something similar. + // Make sure it matches what your code uses (often "policies" or "policyDescription"). + err := db.Collection((&model.PolicyDescription{}).Collection()).Drop(ctx) + require.NoError(t, err, "failed to drop collection between sub-tests") +} + +func TestPoliciesDB(t *testing.T) { + // Create context with reasonable timeout + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Start MongoDB test container + mongoC, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(t, ctx, mongoC) + + // Get connection URI + mongoURI, err := mongoC.ConnectionString(ctx) + require.NoError(t, err, "failed to get connection string") + + // Connect client + clientOpts := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(ctx, clientOpts) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(t, ctx, client) + + // Create test DB + db := client.Database("testdb") + + // Use a no-op logger (or real logger if you prefer) + logger := zap.NewNop() + + // Create an instance of PoliciesDB + pdb, err := policiesdb.Create(logger, db) + require.NoError(t, err, "unexpected error creating PoliciesDB") + + // --------------------------------------------------------- + // Each sub-test below starts by dropping the collection. + // --------------------------------------------------------- + + t.Run("CreateAndGet", func(t *testing.T) { + cleanupCollection(t, ctx, db) // ensure no leftover data + + desc := "Test policy description" + policy := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "TestPolicy", + Description: &desc, + }, + } + require.NoError(t, pdb.Create(ctx, policy)) + + result := &model.PolicyDescription{} + err := pdb.Get(ctx, policy.ID, result) + require.NoError(t, err) + + assert.Equal(t, policy.ID, result.ID) + assert.Equal(t, "TestPolicy", result.Name) + assert.NotNil(t, result.Description) + assert.Equal(t, "Test policy description", *result.Description) + }) + + t.Run("Get_NotFound", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + // Attempt to get a non-existent ID + nonExistentID := bson.NewObjectID() + result := &model.PolicyDescription{} + err := pdb.Get(ctx, nonExistentID, result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Update", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + originalDesc := "Original description" + policy := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "OriginalName", + Description: &originalDesc, + }, + } + require.NoError(t, pdb.Create(ctx, policy)) + + newDesc := "Updated description" + policy.Name = "UpdatedName" + policy.Description = &newDesc + + err := pdb.Update(ctx, policy) + require.NoError(t, err) + + updated := &model.PolicyDescription{} + err = pdb.Get(ctx, policy.ID, updated) + require.NoError(t, err) + + assert.Equal(t, "UpdatedName", updated.Name) + assert.NotNil(t, updated.Description) + assert.Equal(t, "Updated description", *updated.Description) + }) + + t.Run("Delete", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + desc := "To be deleted" + policy := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "WillDelete", + Description: &desc, + }, + } + require.NoError(t, pdb.Create(ctx, policy)) + + err := pdb.Delete(ctx, policy.ID) + require.NoError(t, err) + + deleted := &model.PolicyDescription{} + err = pdb.Get(ctx, policy.ID, deleted) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("DeleteMany", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + desc1 := "Will be deleted 1" + desc2 := "Will be deleted 2" + pol1 := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "BatchDelete1", + Description: &desc1, + }, + } + pol2 := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "BatchDelete2", + Description: &desc2, + }, + } + require.NoError(t, pdb.Create(ctx, pol1)) + require.NoError(t, pdb.Create(ctx, pol2)) + + q := repository.Query().RegEx(repository.Field("description"), "^Will be deleted", "") + err := pdb.DeleteMany(ctx, q) + require.NoError(t, err) + + res1 := &model.PolicyDescription{} + err1 := pdb.Get(ctx, pol1.ID, res1) + assert.Error(t, err1) + assert.True(t, errors.Is(err1, merrors.ErrNoData)) + + res2 := &model.PolicyDescription{} + err2 := pdb.Get(ctx, pol2.ID, res2) + assert.Error(t, err2) + assert.True(t, errors.Is(err2, merrors.ErrNoData)) + }) + + t.Run("FindOne", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + desc := "Unique find test" + policy := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "FindOneTest", + Description: &desc, + }, + } + require.NoError(t, pdb.Create(ctx, policy)) + + // Match by name == "FindOneTest" + q := repository.Query().Comparison(repository.Field("name"), builder.Eq, "FindOneTest") + + found := &model.PolicyDescription{} + err := pdb.FindOne(ctx, q, found) + require.NoError(t, err) + + assert.Equal(t, policy.ID, found.ID) + assert.Equal(t, "FindOneTest", found.Name) + assert.NotNil(t, found.Description) + assert.Equal(t, "Unique find test", *found.Description) + }) + + t.Run("All", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + // Insert some policies (orgA, orgB, nil org) + orgA := bson.NewObjectID() + orgB := bson.NewObjectID() + + descA := "Org A policy" + policyA := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyA", + Description: &descA, + }, + OrganizationRef: &orgA, // belongs to orgA + } + descB := "Org B policy" + policyB := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyB", + Description: &descB, + }, + OrganizationRef: &orgB, // belongs to orgB + } + descNil := "No org policy" + policyNil := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyNil", + Description: &descNil, + }, + // nil => built-in + } + require.NoError(t, pdb.Create(ctx, policyA)) + require.NoError(t, pdb.Create(ctx, policyB)) + require.NoError(t, pdb.Create(ctx, policyNil)) + + // Suppose the requirement is: "All" returns + // - policies for the requested org + // - plus built-in (nil) ones + resultsA, err := pdb.All(ctx, orgA) + require.NoError(t, err) + require.Len(t, resultsA, 2) // orgA + built-in + + var idsA []bson.ObjectID + for _, r := range resultsA { + idsA = append(idsA, r.ID) + } + assert.Contains(t, idsA, policyA.ID) + assert.Contains(t, idsA, policyNil.ID) + assert.NotContains(t, idsA, policyB.ID) + + resultsB, err := pdb.All(ctx, orgB) + require.NoError(t, err) + require.Len(t, resultsB, 2) // orgB + built-in + + var idsB []bson.ObjectID + for _, r := range resultsB { + idsB = append(idsB, r.ID) + } + assert.Contains(t, idsB, policyB.ID) + assert.Contains(t, idsB, policyNil.ID) + assert.NotContains(t, idsB, policyA.ID) + }) + + t.Run("Policies", func(t *testing.T) { + cleanupCollection(t, ctx, db) + + desc1 := "PolicyOne" + pol1 := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyOne", + Description: &desc1, + }, + } + desc2 := "PolicyTwo" + pol2 := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyTwo", + Description: &desc2, + }, + } + desc3 := "PolicyThree" + pol3 := &model.PolicyDescription{ + Describable: model.Describable{ + Name: "PolicyThree", + Description: &desc3, + }, + } + require.NoError(t, pdb.Create(ctx, pol1)) + require.NoError(t, pdb.Create(ctx, pol2)) + require.NoError(t, pdb.Create(ctx, pol3)) + + // 1) Request pol1, pol2 + results12, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol2.ID}) + require.NoError(t, err) + require.Len(t, results12, 2) + // IDs might be out of order, so we do a set-like check + var set12 []bson.ObjectID + for _, r := range results12 { + set12 = append(set12, r.ID) + } + assert.Contains(t, set12, pol1.ID) + assert.Contains(t, set12, pol2.ID) + + // 2) Request pol1, pol3, plus a random ID + fakeID := bson.NewObjectID() + results13Fake, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol3.ID, fakeID}) + require.NoError(t, err) + require.Len(t, results13Fake, 2) // pol1 + pol3 only + var set13Fake []bson.ObjectID + for _, r := range results13Fake { + set13Fake = append(set13Fake, r.ID) + } + assert.Contains(t, set13Fake, pol1.ID) + assert.Contains(t, set13Fake, pol3.ID) + + // 3) Request with empty slice => expect no results + resultsEmpty, err := pdb.Policies(ctx, []bson.ObjectID{}) + require.NoError(t, err) + assert.Len(t, resultsEmpty, 0) + }) +} diff --git a/backend/pkg/db/internal/mongo/policiesdb/policies.go b/backend/pkg/db/internal/mongo/policiesdb/policies.go new file mode 100644 index 0000000..abc5893 --- /dev/null +++ b/backend/pkg/db/internal/mongo/policiesdb/policies.go @@ -0,0 +1,18 @@ +package policiesdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *PoliciesDB) Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) { + if len(refs) == 0 { + return []model.PolicyDescription{}, nil + } + filter := repository.Query().In(repository.IDField(), refs) + return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go new file mode 100644 index 0000000..c59e89c --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go @@ -0,0 +1,90 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type literalAccumulatorImp struct { + op builder.MongoOperation + value any +} + +func (a *literalAccumulatorImp) Build() bson.D { + return bson.D{{Key: string(a.op), Value: a.value}} +} + +func NewAccumulator(op builder.MongoOperation, value any) builder.Accumulator { + return &literalAccumulatorImp{op: op, value: value} +} + +func AddToSet(value builder.Expression) builder.Expression { + return newUnaryExpression(builder.AddToSet, value) +} + +func Size(value builder.Expression) builder.Expression { + return newUnaryExpression(builder.Size, value) +} + +func Ne(left, right builder.Expression) builder.Expression { + return newBinaryExpression(builder.Ne, left, right) +} + +func Sum(value any) builder.Accumulator { + return NewAccumulator(builder.Sum, value) +} + +func Avg(value any) builder.Accumulator { + return NewAccumulator(builder.Avg, value) +} + +func Min(value any) builder.Accumulator { + return NewAccumulator(builder.Min, value) +} + +func Max(value any) builder.Accumulator { + return NewAccumulator(builder.Max, value) +} + +func Eq(left, right builder.Expression) builder.Expression { + return newBinaryExpression(builder.Eq, left, right) +} + +func Gt(left, right builder.Expression) builder.Expression { + return newBinaryExpression(builder.Gt, left, right) +} + +func Add(left, right builder.Accumulator) builder.Accumulator { + return newBinaryAccumulator(builder.Add, left, right) +} + +func Subtract(left, right builder.Accumulator) builder.Accumulator { + return newBinaryAccumulator(builder.Subtract, left, right) +} + +func Multiply(left, right builder.Accumulator) builder.Accumulator { + return newBinaryAccumulator(builder.Multiply, left, right) +} + +func Divide(left, right builder.Accumulator) builder.Accumulator { + return newBinaryAccumulator(builder.Divide, left, right) +} + +type binaryAccumulator struct { + op builder.MongoOperation + left builder.Accumulator + right builder.Accumulator +} + +func newBinaryAccumulator(op builder.MongoOperation, left, right builder.Accumulator) builder.Accumulator { + return &binaryAccumulator{ + op: op, + left: left, + right: right, + } +} + +func (b *binaryAccumulator) Build() bson.D { + args := []any{b.left.Build(), b.right.Build()} + return bson.D{{Key: string(b.op), Value: args}} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go new file mode 100644 index 0000000..bf9c508 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go @@ -0,0 +1,87 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type aliasImp struct { + lhs builder.Field + rhs any +} + +func (a *aliasImp) Field() builder.Field { + return a.lhs +} + +func (a *aliasImp) Build() bson.D { + return bson.D{{Key: a.lhs.Build(), Value: a.rhs}} +} + +// 1. Null alias (_id: null) +func NewNullAlias(lhs builder.Field) builder.Alias { + return &aliasImp{lhs: lhs, rhs: nil} +} + +func NewAlias(lhs builder.Field, rhs any) builder.Alias { + return &aliasImp{lhs: lhs, rhs: rhs} +} + +// 2. Simple alias (_id: "$taskRef") +func NewSimpleAlias(lhs, rhs builder.Field) builder.Alias { + return &aliasImp{lhs: lhs, rhs: rhs.Build()} +} + +// 3. Complex alias (_id: { aliasName: "$originalField", ... }) +type ComplexAlias struct { + lhs builder.Field + rhs []builder.Alias // Correcting handling of slice of aliases +} + +func (a *ComplexAlias) Field() builder.Field { + return a.lhs +} + +func (a *ComplexAlias) Build() bson.D { + fieldMap := bson.M{} + + for _, alias := range a.rhs { + aliasDoc := alias.Build() + for _, field := range aliasDoc { + fieldMap[field.Key] = field.Value + } + } + + return bson.D{{Key: a.lhs.Build(), Value: fieldMap}} +} + +func NewComplexAlias(lhs builder.Field, rhs []builder.Alias) builder.Alias { + return &ComplexAlias{lhs: lhs, rhs: rhs} +} + +type aliasesImp struct { + aliases []builder.Alias +} + +func (a *aliasesImp) Field() builder.Field { + if len(a.aliases) > 0 { + return a.aliases[0].Field() + } + return NewFieldImp("") +} + +func (a *aliasesImp) Build() bson.D { + results := make([]bson.D, 0) + for _, alias := range a.aliases { + results = append(results, alias.Build()) + } + aliases := bson.D{} + for _, r := range results { + aliases = append(aliases, r...) + } + return aliases +} + +func NewAliases(aliases ...builder.Alias) builder.Alias { + return &aliasesImp{aliases: aliases} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go new file mode 100644 index 0000000..5c0a4ad --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go @@ -0,0 +1,27 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type arrayImp struct { + elements []builder.Expression +} + +// Build renders the literal array: +// +// [ , , … ] +func (b *arrayImp) Build() bson.A { + arr := make(bson.A, len(b.elements)) + for i, expr := range b.elements { + // each expr.Build() returns the raw value or sub‐expression + arr[i] = expr.Build() + } + return arr +} + +// NewArray constructs a new array expression from the given sub‐expressions. +func NewArray(exprs ...builder.Expression) *arrayImp { + return &arrayImp{elements: exprs} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go new file mode 100644 index 0000000..719ac97 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go @@ -0,0 +1,108 @@ +package builderimp + +import ( + "reflect" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type literalExpression struct { + value any +} + +func NewLiteralExpression(value any) builder.Expression { + return &literalExpression{value: value} +} + +func (e *literalExpression) Build() any { + return bson.D{{Key: string(builder.Literal), Value: e.value}} +} + +type variadicExpression struct { + op builder.MongoOperation + parts []builder.Expression +} + +func (e *variadicExpression) Build() any { + args := make([]any, 0, len(e.parts)) + for _, p := range e.parts { + args = append(args, p.Build()) + } + return bson.D{{Key: string(e.op), Value: args}} +} + +func newVariadicExpression(op builder.MongoOperation, exprs ...builder.Expression) builder.Expression { + return &variadicExpression{ + op: op, + parts: exprs, + } +} + +func newBinaryExpression(op builder.MongoOperation, left, right builder.Expression) builder.Expression { + return &variadicExpression{ + op: op, + parts: []builder.Expression{left, right}, + } +} + +type unaryExpression struct { + op builder.MongoOperation + rhs builder.Expression +} + +func (e *unaryExpression) Build() any { + return bson.D{{Key: string(e.op), Value: e.rhs.Build()}} +} + +func newUnaryExpression(op builder.MongoOperation, right builder.Expression) builder.Expression { + return &unaryExpression{ + op: op, + rhs: right, + } +} + +type matchExpression struct { + op builder.MongoOperation + rhs builder.Expression +} + +func (e *matchExpression) Build() any { + return bson.E{Key: string(e.op), Value: e.rhs.Build()} +} + +func newMatchExpression(op builder.MongoOperation, right builder.Expression) builder.Expression { + return &matchExpression{ + op: op, + rhs: right, + } +} + +func InRef(value builder.Field) builder.Expression { + return newMatchExpression(builder.In, NewValue(NewRefFieldImp(value).Build())) +} + +type inImpl struct { + values []any +} + +func (e *inImpl) Build() any { + return bson.D{{Key: string(builder.In), Value: e.values}} +} + +func In(values ...any) builder.Expression { + var flattenedValues []any + + for _, v := range values { + switch reflect.TypeOf(v).Kind() { + case reflect.Slice: + slice := reflect.ValueOf(v) + for i := range slice.Len() { + flattenedValues = append(flattenedValues, slice.Index(i).Interface()) + } + default: + flattenedValues = append(flattenedValues, v) + } + } + return &inImpl{values: flattenedValues} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go new file mode 100644 index 0000000..e53a22c --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go @@ -0,0 +1,71 @@ +package builderimp + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" +) + +type FieldImp struct { + fields []string +} + +func (b *FieldImp) Dot(field string) builder.Field { + newFields := make([]string, len(b.fields), len(b.fields)+1) + copy(newFields, b.fields) + newFields = append(newFields, field) + return &FieldImp{fields: newFields} +} + +func (b *FieldImp) CopyWith(field string) builder.Field { + copiedFields := make([]string, 0, len(b.fields)+1) + copiedFields = append(copiedFields, b.fields...) + copiedFields = append(copiedFields, field) + return &FieldImp{ + fields: copiedFields, + } +} + +func (b *FieldImp) Build() string { + return strings.Join(b.fields, ".") +} + +func NewFieldImp(baseName string) builder.Field { + return &FieldImp{ + fields: []string{baseName}, + } +} + +type RefField struct { + imp builder.Field +} + +func (b *RefField) Build() string { + return "$" + b.imp.Build() +} + +func (b *RefField) CopyWith(field string) builder.Field { + return &RefField{ + imp: b.imp.CopyWith(field), + } +} + +func (b *RefField) Dot(field string) builder.Field { + return &RefField{ + imp: b.imp.Dot(field), + } +} + +func NewRefFieldImp(field builder.Field) builder.Field { + return &RefField{ + imp: field, + } +} + +func NewRootRef() builder.Field { + return NewFieldImp("$$ROOT") +} + +func NewRemoveRef() builder.Field { + return NewFieldImp("$$REMOVE") +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go new file mode 100644 index 0000000..18ed6e0 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go @@ -0,0 +1,137 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type condImp struct { + condition builder.Expression + ifTrue any + ifFalse any +} + +func (c *condImp) Build() any { + return bson.D{ + {Key: string(builder.Cond), Value: bson.D{ + {Key: "if", Value: c.condition.Build()}, + {Key: "then", Value: c.ifTrue}, + {Key: "else", Value: c.ifFalse}, + }}, + } +} + +func NewCond(condition builder.Expression, ifTrue, ifFalse any) builder.Expression { + return &condImp{ + condition: condition, + ifTrue: ifTrue, + ifFalse: ifFalse, + } +} + +// setUnionImp implements builder.Expression but takes only builder.Array inputs. +type setUnionImp struct { + inputs []builder.Expression +} + +// Build renders the $setUnion stage: +// +// { $setUnion: [ , , … ] } +func (s *setUnionImp) Build() any { + arr := make(bson.A, len(s.inputs)) + for i, arrayExpr := range s.inputs { + arr[i] = arrayExpr.Build() + } + return bson.D{ + {Key: string(builder.SetUnion), Value: arr}, + } +} + +// NewSetUnion constructs a new $setUnion expression from the given Arrays. +func NewSetUnion(arrays ...builder.Expression) builder.Expression { + return &setUnionImp{inputs: arrays} +} + +type assignmentImp struct { + field builder.Field + expression builder.Expression +} + +func (a *assignmentImp) Build() bson.D { + // Assign it to the given field name + return bson.D{ + {Key: a.field.Build(), Value: a.expression.Build()}, + } +} + +// NewAssignment creates a projection assignment of the form: +// +// : +func NewAssignment(field builder.Field, expression builder.Expression) builder.Projection { + return &assignmentImp{ + field: field, + expression: expression, + } +} + +type computeImp struct { + field builder.Field + expression builder.Expression +} + +func (a *computeImp) Build() any { + return bson.D{ + {Key: a.field.Build(), Value: a.expression.Build()}, + } +} + +func NewCompute(field builder.Field, expression builder.Expression) builder.Expression { + return &computeImp{ + field: field, + expression: expression, + } +} + +func NewIfNull(expression, replacement builder.Expression) builder.Expression { + return newBinaryExpression(builder.IfNull, expression, replacement) +} + +func NewPush(expression builder.Expression) builder.Expression { + return newUnaryExpression(builder.Push, expression) +} + +func NewAnd(exprs ...builder.Expression) builder.Expression { + return newVariadicExpression(builder.And, exprs...) +} + +func NewOr(exprs ...builder.Expression) builder.Expression { + return newVariadicExpression(builder.Or, exprs...) +} + +func NewEach(exprs ...builder.Expression) builder.Expression { + return newVariadicExpression(builder.Each, exprs...) +} + +func NewLt(left, right builder.Expression) builder.Expression { + return newBinaryExpression(builder.Lt, left, right) +} + +func NewNot(expression builder.Expression) builder.Expression { + return newUnaryExpression(builder.Not, expression) +} + +func NewSum(expression builder.Expression) builder.Expression { + return newUnaryExpression(builder.Sum, expression) +} + +func NewMin(expression builder.Expression) builder.Expression { + return newUnaryExpression(builder.Min, expression) +} + +func First(expr builder.Expression) builder.Expression { + return newUnaryExpression(builder.First, expr) +} + +func NewType(expr builder.Expression) builder.Expression { + return newUnaryExpression(builder.Type, expr) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go new file mode 100644 index 0000000..4ee062b --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go @@ -0,0 +1,35 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type groupAccumulatorImp struct { + field builder.Field + acc builder.Accumulator +} + +// NewGroupAccumulator creates a new GroupAccumulator for the given field using the specified operator and value. +func NewGroupAccumulator(field builder.Field, acc builder.Accumulator) builder.GroupAccumulator { + return &groupAccumulatorImp{ + field: field, + acc: acc, + } +} + +func (g *groupAccumulatorImp) Field() builder.Field { + return g.field +} + +func (g *groupAccumulatorImp) Accumulator() builder.Accumulator { + return g.acc +} + +// Build returns a bson.E element for this group accumulator. +func (g *groupAccumulatorImp) Build() bson.D { + return bson.D{{ + Key: g.field.Build(), + Value: g.acc.Build(), + }} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go new file mode 100644 index 0000000..9311116 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go @@ -0,0 +1,104 @@ +package builderimp + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type patchBuilder struct { + updates bson.D +} + +func set(field builder.Field, value any) bson.E { + return bson.E{Key: string(builder.Set), Value: bson.D{{Key: field.Build(), Value: value}}} +} + +func (u *patchBuilder) Set(field builder.Field, value any) builder.Patch { + u.updates = append(u.updates, set(field, value)) + return u +} + +func (u *patchBuilder) Inc(field builder.Field, value any) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.Inc), Value: bson.D{{Key: field.Build(), Value: value}}}) + return u +} + +func (u *patchBuilder) Unset(field builder.Field) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.Unset), Value: bson.D{{Key: field.Build(), Value: ""}}}) + return u +} + +func (u *patchBuilder) Rename(field builder.Field, newName string) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.Rename), Value: bson.D{{Key: field.Build(), Value: newName}}}) + return u +} + +func (u *patchBuilder) Push(field builder.Field, value any) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.Push), Value: bson.D{{Key: field.Build(), Value: value}}}) + return u +} + +func (u *patchBuilder) Pull(field builder.Field, value any) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.Pull), Value: bson.D{{Key: field.Build(), Value: value}}}) + return u +} + +func (u *patchBuilder) AddToSet(field builder.Field, value any) builder.Patch { + u.updates = append(u.updates, bson.E{Key: string(builder.AddToSet), Value: bson.D{{Key: field.Build(), Value: value}}}) + return u +} + +func (u *patchBuilder) Build() bson.D { + updates := append(bson.D{}, u.updates...) + updates = append(updates, set(NewFieldImp(storable.UpdatedAtField), time.Now())) + return mergePatchOperations(updates) +} + +func NewPatchImp() builder.Patch { + return &patchBuilder{updates: bson.D{}} +} + +func mergePatchOperations(updates bson.D) bson.D { + operators := make([]string, 0, len(updates)) + merged := make(map[string]bson.D, len(updates)) + for _, update := range updates { + fields, ok := update.Value.(bson.D) + if !ok { + operators = appendMissingOperator(operators, update.Key) + merged[update.Key] = append(merged[update.Key], bson.E{Key: update.Key, Value: update.Value}) + continue + } + operators = appendMissingOperator(operators, update.Key) + for _, field := range fields { + merged[update.Key] = setPatchField(merged[update.Key], field) + } + } + + out := make(bson.D, 0, len(operators)) + for _, operator := range operators { + out = append(out, bson.E{Key: operator, Value: merged[operator]}) + } + return out +} + +func appendMissingOperator(operators []string, operator string) []string { + for _, existing := range operators { + if existing == operator { + return operators + } + } + return append(operators, operator) +} + +func setPatchField(fields bson.D, field bson.E) bson.D { + for i := range fields { + if fields[i].Key == field.Key { + fields[i] = field + return fields + } + } + return append(fields, field) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go new file mode 100644 index 0000000..191badf --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go @@ -0,0 +1,98 @@ +package builderimp + +import ( + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestPatchBuilderBuildMergesSetOperations(t *testing.T) { + patch := NewPatchImp(). + Set(NewFieldImp("name"), "alice"). + Inc(NewFieldImp("version"), 1). + Set(NewFieldImp("status"), "active") + + built := patch.Build() + + if got := countPatchOperator(built, string(builder.Set)); got != 1 { + t.Fatalf("$set operator count = %d, want 1: %#v", got, built) + } + if got := countPatchOperator(built, string(builder.Inc)); got != 1 { + t.Fatalf("$inc operator count = %d, want 1: %#v", got, built) + } + setFields := patchOperatorFields(t, built, string(builder.Set)) + for _, key := range []string{"name", "status", storable.UpdatedAtField} { + if countPatchField(setFields, key) != 1 { + t.Fatalf("$set field %s must be present once: %#v", key, setFields) + } + } +} + +func TestPatchBuilderBuildOwnsUpdatedAt(t *testing.T) { + manualUpdatedAt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + patch := NewPatchImp(). + Set(NewFieldImp(storable.UpdatedAtField), manualUpdatedAt). + Set(NewFieldImp("status"), "active") + + built := patch.Build() + setFields := patchOperatorFields(t, built, string(builder.Set)) + + if got := countPatchField(setFields, storable.UpdatedAtField); got != 1 { + t.Fatalf("updatedAt field count = %d, want 1: %#v", got, setFields) + } + value, ok := patchFieldValue(setFields, storable.UpdatedAtField).(time.Time) + if !ok { + t.Fatalf("updatedAt value type = %T, want time.Time", patchFieldValue(setFields, storable.UpdatedAtField)) + } + if value.Equal(manualUpdatedAt) { + t.Fatalf("updatedAt must be repository-owned, got manual value %s", value.Format(time.RFC3339Nano)) + } +} + +func patchOperatorFields(t *testing.T, built bson.D, operator string) bson.D { + t.Helper() + for _, entry := range built { + if entry.Key != operator { + continue + } + fields, ok := entry.Value.(bson.D) + if !ok { + t.Fatalf("%s value type = %T, want bson.D", operator, entry.Value) + } + return fields + } + t.Fatalf("%s operator missing: %#v", operator, built) + return nil +} + +func countPatchOperator(built bson.D, operator string) int { + count := 0 + for _, entry := range built { + if entry.Key == operator { + count++ + } + } + return count +} + +func countPatchField(fields bson.D, key string) int { + count := 0 + for _, field := range fields { + if field.Key == key { + count++ + } + } + return count +} + +func patchFieldValue(fields bson.D, key string) any { + for _, field := range fields { + if field.Key == key { + return field.Value + } + } + return nil +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go new file mode 100644 index 0000000..245821f --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go @@ -0,0 +1,133 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type unwindOpts = builder.UnwindOpts + +// UnwindOption is the same type defined in the builder package. +type UnwindOption = builder.UnwindOption + +// NewUnwindOpts applies all UnwindOption's to a fresh unwindOpts. +func NewUnwindOpts(opts ...UnwindOption) *unwindOpts { + cfg := &unwindOpts{} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +type PipelineImp struct { + pipeline mongo.Pipeline +} + +func (b *PipelineImp) Match(filter builder.Query) builder.Pipeline { + b.pipeline = append(b.pipeline, filter.BuildPipeline()) + return b +} + +func (b *PipelineImp) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline { + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: from}, + {Key: string(builder.MKLocalField), Value: localField.Build()}, + {Key: string(builder.MKForeignField), Value: foreignField.Build()}, + {Key: string(builder.MKAs), Value: as.Build()}, + }}}) + return b +} + +func (b *PipelineImp) LookupWithPipeline( + from mservice.Type, + nested builder.Pipeline, + as builder.Field, + let *map[string]builder.Field, +) builder.Pipeline { + lookupStage := bson.D{ + {Key: string(builder.MKFrom), Value: from}, + {Key: string(builder.MKPipeline), Value: nested.Build()}, + {Key: string(builder.MKAs), Value: as.Build()}, + } + + // only add "let" if provided and not empty + if let != nil && len(*let) > 0 { + letDoc := bson.D{} + for varName, fld := range *let { + letDoc = append(letDoc, bson.E{Key: varName, Value: fld.Build()}) + } + lookupStage = append(lookupStage, bson.E{Key: string(builder.MKLet), Value: letDoc}) + } + + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: lookupStage}}) + return b +} + +func (b *PipelineImp) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline { + cfg := NewUnwindOpts(opts...) + + var stageValue interface{} + // if no options, shorthand + if !cfg.PreserveNullAndEmptyArrays && cfg.IncludeArrayIndex == "" { + stageValue = path.Build() + } else { + d := bson.D{{Key: string(builder.MKPath), Value: path.Build()}} + if cfg.PreserveNullAndEmptyArrays { + d = append(d, bson.E{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true}) + } + if cfg.IncludeArrayIndex != "" { + d = append(d, bson.E{Key: string(builder.MKIncludeArrayIndex), Value: cfg.IncludeArrayIndex}) + } + stageValue = d + } + + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Unwind), Value: stageValue}}) + return b +} + +func (b *PipelineImp) Count(field builder.Field) builder.Pipeline { + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Count), Value: field.Build()}}) + return b +} + +func (b *PipelineImp) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline { + groupDoc := groupBy.Build() + for _, acc := range accumulators { + groupDoc = append(groupDoc, acc.Build()...) + } + + b.pipeline = append(b.pipeline, bson.D{ + {Key: string(builder.Group), Value: groupDoc}, + }) + return b +} + +func (b *PipelineImp) Project(projections ...builder.Projection) builder.Pipeline { + projDoc := bson.D{} + for _, pr := range projections { + projDoc = append(projDoc, pr.Build()...) + } + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Project), Value: projDoc}}) + return b +} + +func (b *PipelineImp) ReplaceRoot(newRoot builder.Expression) builder.Pipeline { + b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{ + {Key: string(builder.MKNewRoot), Value: newRoot.Build()}, + }}}) + return b +} + +func (b *PipelineImp) Build() mongo.Pipeline { + return b.pipeline +} + +func NewPipelineImp(stages ...bson.D) builder.Pipeline { + pipeline := make(mongo.Pipeline, len(stages)) + copy(pipeline, stages) + return &PipelineImp{ + pipeline: pipeline, + } +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go new file mode 100644 index 0000000..7592b59 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go @@ -0,0 +1,576 @@ +package builderimp + +import ( + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestNewPipelineImp(t *testing.T) { + pipeline := NewPipelineImp() + + assert.NotNil(t, pipeline) + assert.IsType(t, &PipelineImp{}, pipeline) + + // Build should return empty pipeline initially + built := pipeline.Build() + assert.NotNil(t, built) + assert.Len(t, built, 0) +} + +func TestNewPipelineImpSeedsComposableStages(t *testing.T) { + sortStage := bson.D{{Key: "$sort", Value: bson.D{{Key: "createdAt", Value: -1}}}} + pipeline := NewPipelineImp(sortStage) + + result := pipeline.Count(&MockField{build: "total"}) + + assert.Same(t, pipeline, result) + assert.Equal(t, mongo.Pipeline{ + sortStage, + bson.D{{Key: "$count", Value: "total"}}, + }, pipeline.Build()) +} + +func TestPipelineImp_Match(t *testing.T) { + pipeline := NewPipelineImp() + mockQuery := &MockQuery{ + buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}}, + } + + result := pipeline.Match(mockQuery) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}}, built[0]) +} + +func TestPipelineImp_Lookup(t *testing.T) { + pipeline := NewPipelineImp() + mockLocalField := &MockField{build: "localField"} + mockForeignField := &MockField{build: "foreignField"} + mockAsField := &MockField{build: "asField"} + + result := pipeline.Lookup(mservice.Site, mockLocalField, mockForeignField, mockAsField) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: mservice.Site}, + {Key: string(builder.MKLocalField), Value: "localField"}, + {Key: string(builder.MKForeignField), Value: "foreignField"}, + {Key: string(builder.MKAs), Value: "asField"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_LookupWithPipeline_WithoutLet(t *testing.T) { + pipeline := NewPipelineImp() + mockNestedPipeline := &MockPipeline{ + build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}}, + } + mockAsField := &MockField{build: "asField"} + + result := pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, nil) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: mservice.Site}, + {Key: string(builder.MKPipeline), Value: mockNestedPipeline.build}, + {Key: string(builder.MKAs), Value: "asField"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_LookupWithPipeline_WithLet(t *testing.T) { + pipeline := NewPipelineImp() + mockNestedPipeline := &MockPipeline{ + build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}}, + } + mockAsField := &MockField{build: "asField"} + mockLetField := &MockField{build: "$_id"} + + letVars := map[string]builder.Field{ + "projRef": mockLetField, + } + + result := pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, &letVars) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: mservice.Site}, + {Key: string(builder.MKPipeline), Value: mockNestedPipeline.build}, + {Key: string(builder.MKAs), Value: "asField"}, + {Key: string(builder.MKLet), Value: bson.D{{Key: "projRef", Value: "$_id"}}}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_LookupWithPipeline_WithEmptyLet(t *testing.T) { + pipeline := NewPipelineImp() + mockNestedPipeline := &MockPipeline{ + build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}}, + } + mockAsField := &MockField{build: "asField"} + + emptyLetVars := map[string]builder.Field{} + + pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, &emptyLetVars) + + built := pipeline.Build() + assert.Len(t, built, 1) + + // Should not include let field when empty + expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: mservice.Site}, + {Key: string(builder.MKPipeline), Value: mockNestedPipeline.build}, + {Key: string(builder.MKAs), Value: "asField"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Unwind_Simple(t *testing.T) { + pipeline := NewPipelineImp() + mockField := &MockField{build: "$array"} + + result := pipeline.Unwind(mockField) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Unwind), Value: "$array"}} + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Unwind_WithPreserveNullAndEmptyArrays(t *testing.T) { + pipeline := NewPipelineImp() + mockField := &MockField{build: "$array"} + + // Mock the UnwindOption function + preserveOpt := func(opts *builder.UnwindOpts) { + opts.PreserveNullAndEmptyArrays = true + } + + pipeline.Unwind(mockField, preserveOpt) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{ + {Key: string(builder.MKPath), Value: "$array"}, + {Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Unwind_WithIncludeArrayIndex(t *testing.T) { + pipeline := NewPipelineImp() + mockField := &MockField{build: "$array"} + + // Mock the UnwindOption function + indexOpt := func(opts *builder.UnwindOpts) { + opts.IncludeArrayIndex = "arrayIndex" + } + + pipeline.Unwind(mockField, indexOpt) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{ + {Key: string(builder.MKPath), Value: "$array"}, + {Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Unwind_WithBothOptions(t *testing.T) { + pipeline := NewPipelineImp() + mockField := &MockField{build: "$array"} + + // Mock the UnwindOption functions + preserveOpt := func(opts *builder.UnwindOpts) { + opts.PreserveNullAndEmptyArrays = true + } + indexOpt := func(opts *builder.UnwindOpts) { + opts.IncludeArrayIndex = "arrayIndex" + } + + pipeline.Unwind(mockField, preserveOpt, indexOpt) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{ + {Key: string(builder.MKPath), Value: "$array"}, + {Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true}, + {Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Count(t *testing.T) { + pipeline := NewPipelineImp() + mockField := &MockField{build: "totalCount"} + + result := pipeline.Count(mockField) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Count), Value: "totalCount"}} + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Group(t *testing.T) { + pipeline := NewPipelineImp() + mockAlias := &MockAlias{ + build: bson.D{{Key: "_id", Value: "$field"}}, + field: &MockField{build: "_id"}, + } + mockAccumulator := &MockGroupAccumulator{ + build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}, + } + + result := pipeline.Group(mockAlias, mockAccumulator) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Group), Value: bson.D{ + {Key: "_id", Value: "$field"}, + {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Group_MultipleAccumulators(t *testing.T) { + pipeline := NewPipelineImp() + mockAlias := &MockAlias{ + build: bson.D{{Key: "_id", Value: "$field"}}, + field: &MockField{build: "_id"}, + } + mockAccumulator1 := &MockGroupAccumulator{ + build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}, + } + mockAccumulator2 := &MockGroupAccumulator{ + build: bson.D{{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}}}, + } + + pipeline.Group(mockAlias, mockAccumulator1, mockAccumulator2) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Group), Value: bson.D{ + {Key: "_id", Value: "$field"}, + {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Project(t *testing.T) { + pipeline := NewPipelineImp() + mockProjection := &MockProjection{ + build: bson.D{{Key: "field1", Value: 1}}, + } + + result := pipeline.Project(mockProjection) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Project), Value: bson.D{ + {Key: "field1", Value: 1}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_Project_MultipleProjections(t *testing.T) { + pipeline := NewPipelineImp() + mockProjection1 := &MockProjection{ + build: bson.D{{Key: "field1", Value: 1}}, + } + mockProjection2 := &MockProjection{ + build: bson.D{{Key: "field2", Value: 0}}, + } + + pipeline.Project(mockProjection1, mockProjection2) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.Project), Value: bson.D{ + {Key: "field1", Value: 1}, + {Key: "field2", Value: 0}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_ChainedOperations(t *testing.T) { + pipeline := NewPipelineImp() + + // Create mocks + mockQuery := &MockQuery{ + buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}}, + } + mockLocalField := &MockField{build: "userId"} + mockForeignField := &MockField{build: "_id"} + mockAsField := &MockField{build: "user"} + mockUnwindField := &MockField{build: "$user"} + mockProjection := &MockProjection{ + build: bson.D{{Key: "name", Value: "$user.name"}}, + } + + // Chain operations + result := pipeline. + Match(mockQuery). + Lookup(mservice.Accounts, mockLocalField, mockForeignField, mockAsField). + Unwind(mockUnwindField). + Project(mockProjection) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 4) + + // Verify each stage + assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}}, built[0]) + + expectedLookup := bson.D{{Key: string(builder.Lookup), Value: bson.D{ + {Key: string(builder.MKFrom), Value: mservice.Accounts}, + {Key: string(builder.MKLocalField), Value: "userId"}, + {Key: string(builder.MKForeignField), Value: "_id"}, + {Key: string(builder.MKAs), Value: "user"}, + }}} + assert.Equal(t, expectedLookup, built[1]) + + assert.Equal(t, bson.D{{Key: string(builder.Unwind), Value: "$user"}}, built[2]) + + expectedProject := bson.D{{Key: string(builder.Project), Value: bson.D{ + {Key: "name", Value: "$user.name"}, + }}} + assert.Equal(t, expectedProject, built[3]) +} + +func TestNewUnwindOpts(t *testing.T) { + t.Run("NoOptions", func(t *testing.T) { + opts := NewUnwindOpts() + + assert.NotNil(t, opts) + assert.False(t, opts.PreserveNullAndEmptyArrays) + assert.Empty(t, opts.IncludeArrayIndex) + }) + + t.Run("WithPreserveOption", func(t *testing.T) { + preserveOpt := func(opts *builder.UnwindOpts) { + opts.PreserveNullAndEmptyArrays = true + } + + opts := NewUnwindOpts(preserveOpt) + + assert.True(t, opts.PreserveNullAndEmptyArrays) + assert.Empty(t, opts.IncludeArrayIndex) + }) + + t.Run("WithIndexOption", func(t *testing.T) { + indexOpt := func(opts *builder.UnwindOpts) { + opts.IncludeArrayIndex = "index" + } + + opts := NewUnwindOpts(indexOpt) + + assert.False(t, opts.PreserveNullAndEmptyArrays) + assert.Equal(t, "index", opts.IncludeArrayIndex) + }) + + t.Run("WithBothOptions", func(t *testing.T) { + preserveOpt := func(opts *builder.UnwindOpts) { + opts.PreserveNullAndEmptyArrays = true + } + indexOpt := func(opts *builder.UnwindOpts) { + opts.IncludeArrayIndex = "index" + } + + opts := NewUnwindOpts(preserveOpt, indexOpt) + + assert.True(t, opts.PreserveNullAndEmptyArrays) + assert.Equal(t, "index", opts.IncludeArrayIndex) + }) +} + +// Mock implementations for testing + +type MockQuery struct { + buildPipeline bson.D +} + +func (m *MockQuery) And(filters ...builder.Query) builder.Query { return m } +func (m *MockQuery) Or(filters ...builder.Query) builder.Query { return m } +func (m *MockQuery) Filter(field builder.Field, value any) builder.Query { return m } +func (m *MockQuery) Expression(value builder.Expression) builder.Query { return m } +func (m *MockQuery) Comparison(field builder.Field, operator builder.MongoOperation, value any) builder.Query { + return m +} +func (m *MockQuery) RegEx(field builder.Field, pattern, options string) builder.Query { return m } +func (m *MockQuery) In(field builder.Field, values ...any) builder.Query { return m } +func (m *MockQuery) NotIn(field builder.Field, values ...any) builder.Query { return m } +func (m *MockQuery) Sort(field builder.Field, ascending bool) builder.Query { return m } +func (m *MockQuery) Limit(limit *int64) builder.Query { return m } +func (m *MockQuery) Offset(offset *int64) builder.Query { return m } +func (m *MockQuery) Archived(isArchived *bool) builder.Query { return m } +func (m *MockQuery) BuildPipeline() bson.D { return m.buildPipeline } +func (m *MockQuery) BuildQuery() bson.D { return bson.D{} } +func (m *MockQuery) BuildOptions() *options.FindOptionsBuilder { return &options.FindOptionsBuilder{} } + +type MockField struct { + build string +} + +func (m *MockField) Dot(field string) builder.Field { return &MockField{build: m.build + "." + field} } +func (m *MockField) CopyWith(field string) builder.Field { return &MockField{build: field} } +func (m *MockField) Build() string { return m.build } + +type MockPipeline struct { + build mongo.Pipeline +} + +func (m *MockPipeline) Match(filter builder.Query) builder.Pipeline { return m } +func (m *MockPipeline) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline { + return m +} +func (m *MockPipeline) LookupWithPipeline(from mservice.Type, pipeline builder.Pipeline, as builder.Field, let *map[string]builder.Field) builder.Pipeline { + return m +} +func (m *MockPipeline) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline { return m } +func (m *MockPipeline) Count(field builder.Field) builder.Pipeline { return m } +func (m *MockPipeline) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline { + return m +} +func (m *MockPipeline) Project(projections ...builder.Projection) builder.Pipeline { return m } +func (m *MockPipeline) ReplaceRoot(newRoot builder.Expression) builder.Pipeline { return m } +func (m *MockPipeline) Build() mongo.Pipeline { return m.build } + +type MockAlias struct { + build bson.D + field builder.Field +} + +func (m *MockAlias) Field() builder.Field { return m.field } +func (m *MockAlias) Build() bson.D { return m.build } + +type MockGroupAccumulator struct { + build bson.D +} + +func (m *MockGroupAccumulator) Build() bson.D { return m.build } + +type MockProjection struct { + build bson.D +} + +func (m *MockProjection) Build() bson.D { return m.build } + +func TestPipelineImp_ReplaceRoot(t *testing.T) { + pipeline := NewPipelineImp() + mockExpr := &MockExpression{build: "$newRoot"} + + result := pipeline.ReplaceRoot(mockExpr) + + // Should return self for chaining + assert.Same(t, pipeline, result) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{ + {Key: string(builder.MKNewRoot), Value: "$newRoot"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_ReplaceRoot_WithNestedField(t *testing.T) { + pipeline := NewPipelineImp() + mockExpr := &MockExpression{build: "$document.data"} + + pipeline.ReplaceRoot(mockExpr) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{ + {Key: string(builder.MKNewRoot), Value: "$document.data"}, + }}} + + assert.Equal(t, expected, built[0]) +} + +func TestPipelineImp_ReplaceRoot_WithExpression(t *testing.T) { + pipeline := NewPipelineImp() + // Mock a complex expression like { $mergeObjects: [...] } + mockExpr := &MockExpression{build: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}} + + pipeline.ReplaceRoot(mockExpr) + + built := pipeline.Build() + assert.Len(t, built, 1) + + expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{ + {Key: string(builder.MKNewRoot), Value: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}}, + }}} + + assert.Equal(t, expected, built[0]) +} + +type MockExpression struct { + build any +} + +func (m *MockExpression) Build() any { return m.build } diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go new file mode 100644 index 0000000..f982533 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go @@ -0,0 +1,97 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// projectionExprImp is a concrete implementation of builder.Projection +// that projects a field using a custom expression. +type projectionExprImp struct { + expr builder.Expression // The expression for this projection. + field builder.Field // The field name for the projected field. +} + +// Field returns the field being projected. +func (p *projectionExprImp) Field() builder.Field { + return p.field +} + +// Expression returns the expression for the projection. +func (p *projectionExprImp) Expression() builder.Expression { + return p.expr +} + +// Build returns the built expression. If no expression is provided, returns 1. +func (p *projectionExprImp) Build() bson.D { + if p.expr == nil { + return bson.D{{Key: p.field.Build(), Value: 1}} + } + return bson.D{{Key: p.field.Build(), Value: p.expr.Build()}} +} + +// NewProjectionExpr creates a new Projection for a given field and expression. +func NewProjectionExpr(field builder.Field, expr builder.Expression) builder.Projection { + return &projectionExprImp{field: field, expr: expr} +} + +// aliasProjectionImp is a concrete implementation of builder.Projection +// that projects an alias (renaming a field or expression). +type aliasProjectionImp struct { + alias builder.Alias // The alias for this projection. +} + +// Field returns the field being projected (via the alias). +func (p *aliasProjectionImp) Field() builder.Field { + return p.alias.Field() +} + +// Expression returns no additional expression for an alias projection. +func (p *aliasProjectionImp) Expression() builder.Expression { + return nil +} + +// Build returns the built alias expression. +func (p *aliasProjectionImp) Build() bson.D { + return p.alias.Build() +} + +// NewAliasProjection creates a new Projection that renames or wraps an existing field or expression. +func NewAliasProjection(alias builder.Alias) builder.Projection { + return &aliasProjectionImp{alias: alias} +} + +// sinkProjectionImp is a simple include/exclude projection (0 or 1). +type sinkProjectionImp struct { + field builder.Field // The field name for the projected field. + val int // 1 to include, 0 to exclude. +} + +// Expression returns no expression for a sink projection. +func (p *sinkProjectionImp) Expression() builder.Expression { + return nil +} + +// Build returns the include/exclude projection. +func (p *sinkProjectionImp) Build() bson.D { + return bson.D{{Key: p.field.Build(), Value: p.val}} +} + +// NewSinkProjection creates a new Projection that includes (true) or excludes (false) a field. +func NewSinkProjection(field builder.Field, include bool) builder.Projection { + val := 0 + if include { + val = 1 + } + return &sinkProjectionImp{field: field, val: val} +} + +// IncludeField returns a projection including the given field. +func IncludeField(field builder.Field) builder.Projection { + return NewSinkProjection(field, true) +} + +// ExcludeField returns a projection excluding the given field. +func ExcludeField(field builder.Field) builder.Projection { + return NewSinkProjection(field, false) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go new file mode 100644 index 0000000..fd2ed01 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go @@ -0,0 +1,162 @@ +package builderimp + +import ( + "reflect" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type QueryImp struct { + filter bson.D + sort bson.D + limit *int64 + offset *int64 +} + +func (b *QueryImp) Filter(field builder.Field, value any) builder.Query { + b.filter = append(b.filter, bson.E{Key: field.Build(), Value: value}) + return b +} + +func (b *QueryImp) And(filters ...builder.Query) builder.Query { + andFilters := bson.A{} + for _, f := range filters { + andFilters = append(andFilters, f.BuildQuery()) + } + b.filter = append(b.filter, bson.E{Key: string(builder.And), Value: andFilters}) + return b +} + +func (b *QueryImp) Or(filters ...builder.Query) builder.Query { + orFilters := bson.A{} + for _, f := range filters { + orFilters = append(orFilters, f.BuildQuery()) + } + b.filter = append(b.filter, bson.E{Key: string(builder.Or), Value: orFilters}) + return b +} + +func (b *QueryImp) Comparison(field builder.Field, operator builder.MongoOperation, value any) builder.Query { + b.filter = append(b.filter, bson.E{Key: field.Build(), Value: bson.M{string(operator): value}}) + return b +} + +func (b *QueryImp) Expression(value builder.Expression) builder.Query { + b.filter = append(b.filter, bson.E{Key: string(builder.Expr), Value: value.Build()}) + return b +} + +func (b *QueryImp) RegEx(field builder.Field, pattern, options string) builder.Query { + b.filter = append(b.filter, bson.E{Key: field.Build(), Value: bson.Regex{Pattern: pattern, Options: options}}) + return b +} + +func (b *QueryImp) opIn(field builder.Field, op builder.MongoOperation, values ...any) builder.Query { + // Mongo requires $in and $nin operands to be arrays. Keep an empty input as + // a non-nil slice so the BSON encoder emits [] instead of null. + flattenedValues := make([]any, 0, len(values)) + + for _, v := range values { + valueType := reflect.TypeOf(v) + if valueType == nil { + flattenedValues = append(flattenedValues, nil) + continue + } + switch valueType.Kind() { + case reflect.Slice: + slice := reflect.ValueOf(v) + for i := range slice.Len() { + flattenedValues = append(flattenedValues, slice.Index(i).Interface()) + } + default: + flattenedValues = append(flattenedValues, v) + } + } + + b.filter = append(b.filter, bson.E{Key: field.Build(), Value: bson.M{string(op): flattenedValues}}) + return b +} + +func (b *QueryImp) NotIn(field builder.Field, values ...any) builder.Query { + return b.opIn(field, builder.NotIn, values...) +} + +func (b *QueryImp) In(field builder.Field, values ...any) builder.Query { + return b.opIn(field, builder.In, values...) +} + +func (b *QueryImp) Archived(isArchived *bool) builder.Query { + if isArchived == nil { + return b + } + return b.And(NewQueryImp().Filter(NewFieldImp(storable.IsArchivedField), *isArchived)) +} + +func (b *QueryImp) Sort(field builder.Field, ascending bool) builder.Query { + order := 1 + if !ascending { + order = -1 + } + b.sort = append(b.sort, bson.E{Key: field.Build(), Value: order}) + return b +} + +func (b *QueryImp) BuildPipeline() bson.D { + query := bson.D{} + + if len(b.filter) > 0 { + query = append(query, bson.E{Key: string(builder.Match), Value: b.filter}) + } + + if len(b.sort) > 0 { + query = append(query, bson.E{Key: string(builder.Sort), Value: b.sort}) + } + + if b.limit != nil { + query = append(query, bson.E{Key: string(builder.Limit), Value: *b.limit}) + } + + if b.offset != nil { + query = append(query, bson.E{Key: string(builder.Skip), Value: *b.offset}) + } + + return query +} + +func (b *QueryImp) BuildQuery() bson.D { + return b.filter +} + +func (b *QueryImp) Limit(limit *int64) builder.Query { + b.limit = limit + return b +} + +func (b *QueryImp) Offset(offset *int64) builder.Query { + b.offset = offset + return b +} + +func (b *QueryImp) BuildOptions() *options.FindOptionsBuilder { + opts := options.Find() + if b.limit != nil { + opts.SetLimit(*b.limit) + } + if b.offset != nil { + opts.SetSkip(*b.offset) + } + if len(b.sort) > 0 { + opts.SetSort(b.sort) + } + return opts +} + +func NewQueryImp() builder.Query { + return &QueryImp{ + filter: bson.D{}, + sort: bson.D{}, + } +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go new file mode 100644 index 0000000..934fffb --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go @@ -0,0 +1,31 @@ +package builderimp + +import ( + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestEmptySetComparisonsRemainBSONArrays(t *testing.T) { + field := NewFieldImp("organizationRef") + + require.Equal(t, + bson.D{{Key: "organizationRef", Value: bson.M{string(builder.NotIn): []any{}}}}, + NewQueryImp().NotIn(field).BuildQuery(), + ) + require.Equal(t, + bson.D{{Key: "organizationRef", Value: bson.M{string(builder.In): []any{}}}}, + NewQueryImp().In(field).BuildQuery(), + ) +} + +func TestSetComparisonsAcceptNilElements(t *testing.T) { + field := NewFieldImp("organizationRef") + + require.Equal(t, + bson.D{{Key: "organizationRef", Value: bson.M{string(builder.NotIn): []any{"", nil}}}}, + NewQueryImp().NotIn(field, "", nil).BuildQuery(), + ) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go new file mode 100644 index 0000000..26cf000 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go @@ -0,0 +1,17 @@ +package builderimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" +) + +type valueImp struct { + value any +} + +func (v *valueImp) Build() any { + return v.value +} + +func NewValue(value any) builder.Value { + return &valueImp{value: value} +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index.go b/backend/pkg/db/internal/mongo/repositoryimp/index.go new file mode 100644 index 0000000..5d71b17 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/index.go @@ -0,0 +1,143 @@ +package repositoryimp + +import ( + "context" + "fmt" + + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func (r *MongoRepository) CreateIndex(def *ri.Definition) error { + if r.collection == nil { + return merrors.NoData("data collection is not set") + } + if len(def.Keys) == 0 { + return merrors.InvalidArgument("Index definition has no keys", "index.keys") + } + + // ----- build BSON keys -------------------------------------------------- + keys := bson.D{} + for _, k := range def.Keys { + var value any + switch { + case k.Type != "": + value = k.Type // text, 2dsphere, … + case k.Sort == ri.Desc: + value = int8(-1) + default: + value = int8(1) // default to Asc + } + keys = append(keys, bson.E{Key: k.Field, Value: value}) + } + + opts := options.Index(). + SetUnique(def.Unique) + if def.TTL != nil { + opts.SetExpireAfterSeconds(*def.TTL) + } + if def.Name != "" { + opts.SetName(def.Name) + } + if def.PartialFilter != nil { + opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery()) + } + if def.Sparse { + opts.SetSparse(def.Sparse) + } + + _, err := r.collection.Indexes().CreateOne( + context.Background(), + mongo.IndexModel{Keys: keys, Options: opts}, + ) + return err +} + +func (r *MongoRepository) DropIndex(ctx context.Context, name string) error { + if r.collection == nil { + return merrors.NoData("data collection is not set") + } + if name == "" { + return merrors.InvalidArgument("index name is required", "index.name") + } + return r.collection.Indexes().DropOne(ctx, name) +} + +func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) { + if r.collection == nil { + return nil, merrors.NoData("data collection is not set") + } + cursor, err := r.collection.Indexes().List(ctx) + if err != nil { + return nil, err + } + defer func() { _ = cursor.Close(ctx) }() + + defs := make([]*ri.Definition, 0) + for cursor.Next(ctx) { + var doc mongoIndexDocument + if err := cursor.Decode(&doc); err != nil { + return nil, err + } + defs = append(defs, indexDefinitionFromDocument(doc)) + } + if err := cursor.Err(); err != nil { + return nil, err + } + return defs, nil +} + +type mongoIndexDocument struct { + Name string `bson:"name"` + Key bson.D `bson:"key"` + Unique bool `bson:"unique"` + Sparse bool `bson:"sparse"` + ExpireAfterSeconds *int32 `bson:"expireAfterSeconds,omitempty"` +} + +func indexDefinitionFromDocument(doc mongoIndexDocument) *ri.Definition { + keys := make([]ri.Key, 0, len(doc.Key)) + for _, key := range doc.Key { + keys = append(keys, indexKeyFromBSON(key)) + } + return &ri.Definition{ + Keys: keys, + Unique: doc.Unique, + Sparse: doc.Sparse, + TTL: doc.ExpireAfterSeconds, + Name: doc.Name, + } +} + +func indexKeyFromBSON(key bson.E) ri.Key { + out := ri.Key{Field: key.Key} + switch typed := key.Value.(type) { + case string: + out.Type = ri.IndexType(typed) + case int: + out.Sort = indexSort(typed) + case int8: + out.Sort = indexSort(typed) + case int16: + out.Sort = indexSort(typed) + case int32: + out.Sort = indexSort(typed) + case int64: + out.Sort = indexSort(typed) + case float64: + out.Sort = indexSort(typed) + default: + out.Type = ri.IndexType(fmt.Sprint(typed)) + } + return out +} + +func indexSort[T ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float64](value T) ri.Sort { + if value < 0 { + return ri.Desc + } + return ri.Asc +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go b/backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go new file mode 100644 index 0000000..2aa6189 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go @@ -0,0 +1,95 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestCreateIndex_WithPartialFilter(t *testing.T) { + startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer startCancel() + + mongoContainer, err := mongodb.Run(startCtx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)), + ) + require.NoError(t, err) + + mongoURI, err := mongoContainer.ConnectionString(startCtx) + require.NoError(t, err) + + client, err := mongo.Connect(options.Client().ApplyURI(mongoURI)) + require.NoError(t, err) + defer client.Disconnect(context.Background()) + + database := client.Database("test_partial_index_" + t.Name()) + defer database.Drop(context.Background()) + + repo := repository.CreateMongoRepository(database, "partial_index_items") + + def := &ri.Definition{ + Keys: []ri.Key{ + {Field: "field", Sort: ri.Asc}, + }, + Unique: true, + Name: "partial_unique_field_true", + PartialFilter: repository.Filter("flag", true), + } + + require.NoError(t, repo.CreateIndex(def)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + defs, err := repo.ListIndexDefinitions(ctx) + require.NoError(t, err) + listed := false + for _, listedDef := range defs { + if listedDef.Name == def.Name { + listed = true + assert.True(t, listedDef.Unique) + assert.Equal(t, []ri.Key{{Field: "field", Sort: ri.Asc}}, listedDef.Keys) + } + } + assert.True(t, listed, "created index was not returned by ListIndexDefinitions") + + cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx) + require.NoError(t, err) + defer cursor.Close(ctx) + + found := false + for cursor.Next(ctx) { + var idx bson.M + require.NoError(t, cursor.Decode(&idx)) + if idx["name"] == def.Name { + found = true + assert.Equal(t, true, idx["unique"]) + assert.Equal(t, bson.M{"field": int32(1)}, idx["key"]) + partial, ok := idx["partialFilterExpression"].(bson.M) + require.True(t, ok) + assert.Equal(t, bson.M{"flag": true}, partial) + } + } + assert.True(t, found, "partial unique index was not created") + + termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer termCancel() + _ = mongoContainer.Terminate(termCtx) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index_test.go b/backend/pkg/db/internal/mongo/repositoryimp/index_test.go new file mode 100644 index 0000000..94f27d5 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/index_test.go @@ -0,0 +1,52 @@ +package repositoryimp + +import ( + "testing" + + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestIndexDefinitionFromDocumentMapsIndexMetadata(t *testing.T) { + ttl := int32(3600) + def := indexDefinitionFromDocument(mongoIndexDocument{ + Name: "account_ref_unique", + Key: bson.D{{Key: "accountRef", Value: int32(1)}, {Key: "version", Value: int64(-1)}}, + Unique: true, + Sparse: true, + ExpireAfterSeconds: &ttl, + }) + + if def.Name != "account_ref_unique" || !def.Unique || !def.Sparse { + t.Fatalf("unexpected definition metadata: %#v", def) + } + if def.TTL == nil || *def.TTL != ttl { + t.Fatalf("TTL = %v, want %d", def.TTL, ttl) + } + wantKeys := []ri.Key{ + {Field: "accountRef", Sort: ri.Asc}, + {Field: "version", Sort: ri.Desc}, + } + if len(def.Keys) != len(wantKeys) { + t.Fatalf("keys = %#v, want %#v", def.Keys, wantKeys) + } + for i := range wantKeys { + if def.Keys[i] != wantKeys[i] { + t.Fatalf("keys[%d] = %#v, want %#v", i, def.Keys[i], wantKeys[i]) + } + } +} + +func TestIndexDefinitionFromDocumentMapsTypedIndex(t *testing.T) { + def := indexDefinitionFromDocument(mongoIndexDocument{ + Name: "text_index", + Key: bson.D{{Key: "description", Value: "text"}}, + }) + + if len(def.Keys) != 1 { + t.Fatalf("keys = %#v", def.Keys) + } + if def.Keys[0] != (ri.Key{Field: "description", Type: ri.IndexTypeText}) { + t.Fatalf("key = %#v", def.Keys[0]) + } +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository.go b/backend/pkg/db/internal/mongo/repositoryimp/repository.go new file mode 100644 index 0000000..2c6ac60 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository.go @@ -0,0 +1,305 @@ +package repositoryimp + +import ( + "context" + "errors" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type MongoRepository struct { + collectionName string + collection *mongo.Collection +} + +func idFilter(id bson.ObjectID) bson.D { + return bson.D{ + {Key: storable.IDField, Value: id}, + } +} + +func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository { + return &MongoRepository{ + collectionName: collection, + collection: db.Collection(collection), + } +} + +func (r *MongoRepository) Collection() string { + return r.collectionName +} + +func prepareForWrite(obj storable.Storable) bson.ObjectID { + id := obj.GetID() + if id == nil || id.IsZero() { + obj.SetID(bson.NewObjectID()) + id = obj.GetID() + } + obj.Update() + return *id +} + +func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error { + prepareForWrite(obj) + _, err := r.collection.InsertOne(ctx, obj) + if mongo.IsDuplicateKeyError(err) { + if getFilter != nil { + if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil { + return err + } + } + return merrors.DataConflict("duplicate_key") + } + return err +} + +func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error { + if len(objects) == 0 { + return nil + } + + docs := make([]interface{}, len(objects)) + for i, obj := range objects { + prepareForWrite(obj) + docs[i] = obj + } + + _, err := r.collection.InsertMany(ctx, docs) + return err +} + +func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error { + err := r.collection.FindOne(ctx, filter).Decode(result) + if errors.Is(err, mongo.ErrNoDocuments) { + return merrors.NoData(errMessage) + } + return err +} + +func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result storable.Storable) error { + if id.IsZero() { + return merrors.InvalidArgument("zero id provided while fetching "+result.Collection(), "id") + } + return r.findOneByFilterImp(ctx, idFilter(id), fmt.Sprintf("%s with ID = %s not found", result.Collection(), id.Hex()), result) +} + +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 { + cursor, err := queryFunc(ctx, r.collection) + if errors.Is(err, mongo.ErrNoDocuments) { + return merrors.NoData("no_items_in_array") + } + if err != nil { + return err + } + defer func() { + _ = cursor.Close(ctx) + }() + + for cursor.Next(ctx) { + if err = decoder(cursor); err != nil { + return err + } + } + + return nil +} + +func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error { + queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) { + return collection.Aggregate(ctx, pipeline.Build()) + } + return r.executeQuery(ctx, queryFunc, decoder) +} + +func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error { + queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) { + return collection.Find(ctx, query.BuildQuery(), query.BuildOptions()) + } + return r.executeQuery(ctx, queryFunc, decoder) +} + +func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error { + if query == nil { + cursor, err := r.collection.Find(ctx, bson.D{}) + if err != nil { + return err + } + defer func() { + _ = cursor.Close(ctx) + }() + return cursor.All(ctx, result) + } + cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions()) + if err != nil { + return err + } + defer func() { + _ = cursor.Close(ctx) + }() + return cursor.All(ctx, result) +} + +func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { + return r.findOneByFilterImp(ctx, query.BuildQuery(), result.Collection()+" not found by filter", result) +} + +func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error { + obj.Update() + return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err() +} + +func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) { + if query == nil { + return false, merrors.InvalidArgument("filter is required", "filter") + } + if obj == nil { + return false, merrors.InvalidArgument("object is required", "obj") + } + result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj) + if mongo.IsDuplicateKeyError(err) { + return false, merrors.DataConflict("duplicate_key") + } + if err != nil { + return false, err + } + return result.MatchedCount > 0, nil +} + +func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error { + id := prepareForWrite(obj) + _, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true)) + return err +} + +func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error { + if id.IsZero() { + return merrors.InvalidArgument("zero id provided while patching", "id") + } + _, err := r.collection.UpdateByID(ctx, id, patch.Build()) + return err +} + +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()) + if err != nil { + return 0, err + } + return int(result.ModifiedCount), nil +} + +func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) { + filter := query.BuildQuery() + findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1}) + + cursor, err := r.collection.Find(ctx, filter, findOptions) + if err != nil { + return nil, err + } + defer func() { + _ = cursor.Close(ctx) + }() + + var ids []bson.ObjectID + for cursor.Next(ctx) { + var doc struct { + ID bson.ObjectID `bson:"_id"` + } + if err := cursor.Decode(&doc); err != nil { + return nil, err + } + ids = append(ids, doc.ID) + } + if err := cursor.Err(); err != nil { + return nil, err + } + + return ids, nil +} + +func (r *MongoRepository) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { + return listProjectedStorables(ctx, r.collection, query, bson.M{ + storable.IDField: 1, + storable.PermissionRefField: 1, + storable.OrganizationRefField: 1, + }, func(doc *model.PermissionBound) model.PermissionBoundStorable { + return doc + }) +} + +func (r *MongoRepository) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) { + return listProjectedStorables(ctx, r.collection, query, bson.M{ + storable.IDField: 1, + model.AccountRefField: 1, + model.OrganizationRefField: 1, + }, func(doc *model.AccountBoundBase) model.AccountBoundStorable { + return doc + }) +} + +func listProjectedStorables[T any, R any]( + ctx context.Context, + collection *mongo.Collection, + query builder.Query, + projection bson.M, + wrap func(*T) R, +) ([]R, error) { + filter := query.BuildQuery() + cursor, err := collection.Find(ctx, filter, options.Find().SetProjection(projection)) + if err != nil { + return nil, err + } + defer func() { + _ = cursor.Close(ctx) + }() + + result := make([]R, 0) + for cursor.Next(ctx) { + doc := new(T) + if err := cursor.Decode(doc); err != nil { + return nil, err + } + result = append(result, wrap(doc)) + } + if err := cursor.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, idFilter(id)) + return err +} + +func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error { + _, err := r.collection.DeleteMany(ctx, query.BuildQuery()) + return err +} + +func (r *MongoRepository) Name() string { + return r.collection.Name() +} + +func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + if cb == nil { + return nil, merrors.InvalidArgument("transaction callback is required", "callback") + } + session, err := r.collection.Database().Client().StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + return session.WithTransaction(ctx, cb) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go new file mode 100644 index 0000000..e90e6fb --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go @@ -0,0 +1,576 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestMongoRepository_Insert(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Insert_WithoutID", func(t *testing.T) { + testObj := &TestObject{Name: "testInsert"} + // ID should be nil/zero initially + assert.True(t, testObj.GetID().IsZero()) + + err := repository.Insert(ctx, testObj, nil) + require.NoError(t, err) + + // ID should be assigned after insert + assert.False(t, testObj.GetID().IsZero()) + assert.NotEmpty(t, testObj.CreatedAt) + assert.NotEmpty(t, testObj.UpdatedAt) + }) + + t.Run("Insert_WithExistingID", func(t *testing.T) { + existingID := bson.NewObjectID() + testObj := &TestObject{Name: "testInsertWithID"} + testObj.SetID(existingID) + + err := repository.Insert(ctx, testObj, nil) + require.NoError(t, err) + + // ID should remain the same + assert.Equal(t, existingID, *testObj.GetID()) + }) + + t.Run("Insert_DuplicateKey", func(t *testing.T) { + // Insert first object + testObj1 := &TestObject{Name: "duplicate"} + err := repository.Insert(ctx, testObj1, nil) + require.NoError(t, err) + + // Try to insert object with same ID + testObj2 := &TestObject{Name: "duplicate2"} + testObj2.SetID(*testObj1.GetID()) + + err = repository.Insert(ctx, testObj2, nil) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrDataConflict)) + }) + + t.Run("Insert_DuplicateKeyWithGetFilter", func(t *testing.T) { + // Insert first object + testObj1 := &TestObject{Name: "duplicateWithFilter"} + err := repository.Insert(ctx, testObj1, nil) + require.NoError(t, err) + + // Try to insert object with same ID, but with getFilter + testObj2 := &TestObject{Name: "duplicateWithFilter2"} + testObj2.SetID(*testObj1.GetID()) + + getFilter := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("_id"), builder.Eq, *testObj1.GetID()) + + err = repository.Insert(ctx, testObj2, getFilter) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrDataConflict)) + + // But testObj2 should be populated with the existing object data + assert.Equal(t, testObj1.Name, testObj2.Name) + }) +} + +func TestMongoRepository_Update(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Update_ExistingObject", func(t *testing.T) { + // Insert object first + testObj := &TestObject{Name: "originalName"} + err := repository.Insert(ctx, testObj, nil) + require.NoError(t, err) + + originalUpdatedAt := testObj.UpdatedAt + + // Update the object + testObj.Name = "updatedName" + time.Sleep(10 * time.Millisecond) // Ensure time difference + + err = repository.Update(ctx, testObj) + require.NoError(t, err) + + // Verify the object was updated + result := &TestObject{} + err = repository.Get(ctx, *testObj.GetID(), result) + require.NoError(t, err) + assert.Equal(t, "updatedName", result.Name) + assert.True(t, result.UpdatedAt.After(originalUpdatedAt)) + }) + + t.Run("Update_NonExistentObject", func(t *testing.T) { + nonExistentID := bson.NewObjectID() + testObj := &TestObject{Name: "nonExistent"} + testObj.SetID(nonExistentID) + + err := repository.Update(ctx, testObj) + assert.Error(t, err) + assert.True(t, errors.Is(err, mongo.ErrNoDocuments)) + }) +} + +func TestMongoRepository_Delete(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Delete_ExistingObject", func(t *testing.T) { + // Insert object first + testObj := &TestObject{Name: "toDelete"} + err := repository.Insert(ctx, testObj, nil) + require.NoError(t, err) + + // Delete the object + err = repository.Delete(ctx, *testObj.GetID()) + require.NoError(t, err) + + // Verify the object was deleted + result := &TestObject{} + err = repository.Get(ctx, *testObj.GetID(), result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Delete_NonExistentObject", func(t *testing.T) { + nonExistentID := bson.NewObjectID() + + err := repository.Delete(ctx, nonExistentID) + // Delete should not return error even if object doesn't exist + assert.NoError(t, err) + }) +} + +func TestMongoRepository_FindOneByFilter(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("FindOneByFilter_MatchingFilter", func(t *testing.T) { + // Insert test objects + testObjs := []*TestObject{ + {Name: "findMe"}, + {Name: "dontFindMe"}, + {Name: "findMeToo"}, + } + + for _, obj := range testObjs { + err := repository.Insert(ctx, obj, nil) + require.NoError(t, err) + } + + // Find by filter + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "findMe") + result := &TestObject{} + + err := repository.FindOneByFilter(ctx, query, result) + require.NoError(t, err) + assert.Equal(t, "findMe", result.Name) + }) + + t.Run("FindOneByFilter_NoMatch", func(t *testing.T) { + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName") + result := &TestObject{} + + err := repository.FindOneByFilter(ctx, query, result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) +} + +func TestMongoRepository_FindManyByFilter(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("FindManyByFilter_MultipleResults", func(t *testing.T) { + // Insert test objects + testObjs := []*TestObject{ + {Name: "findMany1"}, + {Name: "findMany2"}, + {Name: "dontFind"}, + } + + for _, obj := range testObjs { + err := repository.Insert(ctx, obj, nil) + require.NoError(t, err) + } + + // Find objects with names starting with "findMany" + query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^findMany", "") + + var results []*TestObject + decoder := func(cursor *mongo.Cursor) error { + var obj TestObject + if err := cursor.Decode(&obj); err != nil { + return err + } + results = append(results, &obj) + return nil + } + + err := repository.FindManyByFilter(ctx, query, decoder) + require.NoError(t, err) + assert.Len(t, results, 2) + + names := make([]string, len(results)) + for i, obj := range results { + names[i] = obj.Name + } + assert.Contains(t, names, "findMany1") + assert.Contains(t, names, "findMany2") + }) + + t.Run("FindManyByFilter_NoResults", func(t *testing.T) { + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentPattern") + + var results []*TestObject + decoder := func(cursor *mongo.Cursor) error { + var obj TestObject + if err := cursor.Decode(&obj); err != nil { + return err + } + results = append(results, &obj) + return nil + } + + err := repository.FindManyByFilter(ctx, query, decoder) + require.NoError(t, err) + assert.Empty(t, results) + }) +} + +func TestMongoRepository_DeleteMany(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("DeleteMany_MultipleDocuments", func(t *testing.T) { + // Insert test objects + testObjs := []*TestObject{ + {Name: "deleteMany1"}, + {Name: "deleteMany2"}, + {Name: "keepMe"}, + } + + for _, obj := range testObjs { + err := repository.Insert(ctx, obj, nil) + require.NoError(t, err) + } + + // Delete objects with names starting with "deleteMany" + query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^deleteMany", "") + + err := repository.DeleteMany(ctx, query) + require.NoError(t, err) + + // Verify deletions + queryAll := builderimp.NewQueryImp() + var results []*TestObject + decoder := func(cursor *mongo.Cursor) error { + var obj TestObject + if err := cursor.Decode(&obj); err != nil { + return err + } + results = append(results, &obj) + return nil + } + + err = repository.FindManyByFilter(ctx, queryAll, decoder) + require.NoError(t, err) + assert.Len(t, results, 1) + assert.Equal(t, "keepMe", results[0].Name) + }) +} + +func TestMongoRepository_Name(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "mycollection") + + t.Run("Name_ReturnsCollectionName", func(t *testing.T) { + name := repository.Name() + assert.Equal(t, "mycollection", name) + }) +} + +func TestMongoRepository_ListPermissionBound(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("ListPermissionBound_WithData", func(t *testing.T) { + // Insert test objects with permission bound data + orgID := bson.NewObjectID() + + // Insert documents directly with permission bound fields + _, err := db.Collection("testcollection").InsertMany(ctx, []interface{}{ + bson.M{ + "_id": bson.NewObjectID(), + "organizationRef": orgID, + "permissionRef": bson.NewObjectID(), + }, + bson.M{ + "_id": bson.NewObjectID(), + "organizationRef": orgID, + "permissionRef": bson.NewObjectID(), + }, + }) + require.NoError(t, err) + + // Query for permission bound objects + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, orgID) + + results, err := repository.ListPermissionBound(ctx, query) + require.NoError(t, err) + assert.Len(t, results, 2) + + for _, result := range results { + assert.Equal(t, orgID, result.GetOrganizationRef()) + assert.NotNil(t, result.GetPermissionRef()) + } + }) + + t.Run("ListPermissionBound_EmptyResult", func(t *testing.T) { + nonExistentOrgID := bson.NewObjectID() + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, nonExistentOrgID) + + results, err := repository.ListPermissionBound(ctx, query) + require.NoError(t, err) + assert.Empty(t, results) + }) +} + +func TestMongoRepository_UpdateTimestamp(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Update_Should_Update_Timestamp", func(t *testing.T) { + // Create test object + obj := &TestObject{ + Name: "Test Object", + } + + // Set ID and initial timestamps + obj.SetID(bson.NewObjectID()) + originalCreatedAt := obj.CreatedAt + originalUpdatedAt := obj.UpdatedAt + + // Insert the object + err := repository.Insert(ctx, obj, nil) + require.NoError(t, err) + + // Wait a moment to ensure timestamp difference + time.Sleep(10 * time.Millisecond) + + // Update the object + obj.Name = "Updated Object" + err = repository.Update(ctx, obj) + require.NoError(t, err) + + // Verify timestamps + assert.Equal(t, originalCreatedAt, obj.CreatedAt, "CreatedAt should not change") + assert.True(t, obj.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated") + + // Verify the object was actually updated in the database + var retrieved TestObject + err = repository.Get(ctx, *obj.GetID(), &retrieved) + require.NoError(t, err) + + assert.Equal(t, "Updated Object", retrieved.Name, "Name should be updated") + assert.WithinDuration(t, originalCreatedAt, retrieved.CreatedAt, time.Second, "CreatedAt should not change in DB") + assert.True(t, retrieved.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated in DB") + assert.WithinDuration(t, obj.UpdatedAt, retrieved.UpdatedAt, time.Second, "UpdatedAt should match between object and DB") + }) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go new file mode 100644 index 0000000..186ee24 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go @@ -0,0 +1,59 @@ +package repositoryimp + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { + opts := query.BuildOptions().SetLimit(1) + cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts) + if err != nil { + return err + } + defer func() { + _ = cursor.Close(ctx) + }() + if !cursor.Next(ctx) { + if err := cursor.Err(); err != nil { + return err + } + return merrors.NoData(result.Collection() + " not found by filter") + } + return cursor.Decode(result) +} + +func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) { + return r.collection.CountDocuments(ctx, query.BuildQuery()) +} + +func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) { + if query == nil { + return false, merrors.InvalidArgument("filter is required", "filter") + } + if patch == nil { + return false, merrors.InvalidArgument("patch is required", "patch") + } + if result == nil { + return false, merrors.InvalidArgument("result is required", "result") + } + err := r.collection.FindOneAndUpdate( + ctx, + query.BuildQuery(), + patch.Build(), + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(result) + if errors.Is(err, mongo.ErrNoDocuments) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go new file mode 100644 index 0000000..68f5286 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go @@ -0,0 +1,153 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestMongoRepository_InsertMany(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("InsertMany_Success", func(t *testing.T) { + objects := []storable.Storable{ + &TestObject{Name: "test1"}, + &TestObject{Name: "test2"}, + &TestObject{Name: "test3"}, + } + + err := repository.InsertMany(ctx, objects) + require.NoError(t, err) + + // Verify all objects were inserted and have IDs + for _, obj := range objects { + assert.NotNil(t, obj.GetID()) + assert.False(t, obj.GetID().IsZero()) + + // Verify we can retrieve each object + result := &TestObject{} + err := repository.Get(ctx, *obj.GetID(), result) + require.NoError(t, err) + assert.Equal(t, obj.(*TestObject).Name, result.Name) + } + }) + + t.Run("InsertMany_EmptySlice", func(t *testing.T) { + objects := []storable.Storable{} + + err := repository.InsertMany(ctx, objects) + require.NoError(t, err) + }) + + t.Run("InsertMany_WithExistingIDs", func(t *testing.T) { + id1 := bson.NewObjectID() + id2 := bson.NewObjectID() + + objects := []storable.Storable{ + &TestObject{Base: storable.Base{ID: id1}, Name: "preassigned1"}, + &TestObject{Base: storable.Base{ID: id2}, Name: "preassigned2"}, + } + + err := repository.InsertMany(ctx, objects) + require.NoError(t, err) + + // Verify objects were inserted with pre-assigned IDs + result1 := &TestObject{} + err = repository.Get(ctx, id1, result1) + require.NoError(t, err) + assert.Equal(t, "preassigned1", result1.Name) + + result2 := &TestObject{} + err = repository.Get(ctx, id2, result2) + require.NoError(t, err) + assert.Equal(t, "preassigned2", result2.Name) + }) + + t.Run("InsertMany_MixedTypes", func(t *testing.T) { + objects := []storable.Storable{ + &TestObject{Name: "test1"}, + &AnotherObject{Description: "desc1"}, + &TestObject{Name: "test2"}, + } + + err := repository.InsertMany(ctx, objects) + require.NoError(t, err) + + // Verify all objects were inserted + for _, obj := range objects { + assert.NotNil(t, obj.GetID()) + assert.False(t, obj.GetID().IsZero()) + } + }) + + t.Run("InsertMany_DuplicateKey", func(t *testing.T) { + id := bson.NewObjectID() + + // Insert first object + obj1 := &TestObject{Base: storable.Base{ID: id}, Name: "original"} + err := repository.Insert(ctx, obj1, nil) + require.NoError(t, err) + + // Try to insert multiple objects including one with duplicate ID + objects := []storable.Storable{ + &TestObject{Name: "test1"}, + &TestObject{Base: storable.Base{ID: id}, Name: "duplicate"}, + } + + err = repository.InsertMany(ctx, objects) + assert.Error(t, err) + assert.True(t, mongo.IsDuplicateKeyError(err)) + }) + + t.Run("InsertMany_UpdateTimestamps", func(t *testing.T) { + objects := []storable.Storable{ + &TestObject{Name: "test1"}, + &TestObject{Name: "test2"}, + } + + err := repository.InsertMany(ctx, objects) + require.NoError(t, err) + + // Verify timestamps were set + for _, obj := range objects { + testObj := obj.(*TestObject) + assert.NotZero(t, testObj.CreatedAt) + assert.NotZero(t, testObj.UpdatedAt) + } + }) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go new file mode 100644 index 0000000..1b0e501 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go @@ -0,0 +1,288 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestMongoRepository_PatchOperations(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repo := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Patch_SingleDocument", func(t *testing.T) { + obj := &TestObject{Name: "old"} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + original := obj.UpdatedAt + + patch := repository.Patch().Set(repository.Field("name"), "new") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, "new", result.Name) + assert.True(t, result.UpdatedAt.After(original)) + }) + + t.Run("PatchMany_MultipleDocuments", func(t *testing.T) { + objs := []*TestObject{{Name: "match"}, {Name: "match"}, {Name: "other"}} + for _, o := range objs { + err := repo.Insert(ctx, o, nil) + require.NoError(t, err) + } + + query := repository.Query().Comparison(repository.Field("name"), builder.Eq, "match") + patch := repository.Patch().Set(repository.Field("name"), "patched") + modified, err := repo.PatchMany(ctx, query, patch) + require.NoError(t, err) + assert.Equal(t, 2, modified) + + verify := repository.Query().Comparison(repository.Field("name"), builder.Eq, "patched") + var results []TestObject + decoder := func(cursor *mongo.Cursor) error { + var obj TestObject + if err := cursor.Decode(&obj); err != nil { + return err + } + results = append(results, obj) + return nil + } + err = repo.FindManyByFilter(ctx, verify, decoder) + require.NoError(t, err) + assert.Len(t, results, 2) + }) + + t.Run("PatchOneByFilter_MatchesAndReturnsUpdatedDocument", func(t *testing.T) { + obj := &TestObject{Name: "patch-one-match", Tags: []string{"before"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + originalUpdatedAt := obj.UpdatedAt + + query := repository.Query().Filter(repository.Field("name"), "patch-one-match") + patch := repository.Patch(). + Set(repository.Field("name"), "patch-one-updated"). + Push(repository.Field("tags"), "after") + var result TestObject + matched, err := repo.PatchOneByFilter(ctx, query, patch, &result) + + require.NoError(t, err) + require.True(t, matched) + require.Equal(t, *obj.GetID(), *result.GetID()) + require.Equal(t, "patch-one-updated", result.Name) + require.Equal(t, []string{"before", "after"}, result.Tags) + require.True(t, result.UpdatedAt.After(originalUpdatedAt)) + + var persisted TestObject + err = repo.Get(ctx, *obj.GetID(), &persisted) + require.NoError(t, err) + require.Equal(t, result.Name, persisted.Name) + require.Equal(t, result.Tags, persisted.Tags) + }) + + t.Run("PatchOneByFilter_NoMatch", func(t *testing.T) { + patch := repository.Patch().Set(repository.Field("name"), "not-applied") + var result TestObject + + matched, err := repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "missing-patch-one"), patch, &result) + + require.NoError(t, err) + require.False(t, matched) + require.Empty(t, result.Name) + }) + + t.Run("PatchOneByFilter_InvalidArguments", func(t *testing.T) { + var result TestObject + _, err := repo.PatchOneByFilter(ctx, nil, repository.Patch().Set(repository.Field("name"), "x"), &result) + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrInvalidArg)) + + _, err = repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "x"), nil, &result) + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrInvalidArg)) + + _, err = repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "x"), repository.Patch().Set(repository.Field("name"), "y"), nil) + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrInvalidArg)) + }) + + t.Run("Patch_PushArray", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{"tag1"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + patch := repository.Patch().Push(repository.Field("tags"), "tag2") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1", "tag2"}, result.Tags) + }) + + t.Run("Patch_PullArray", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2", "tag3"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + patch := repository.Patch().Pull(repository.Field("tags"), "tag2") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1", "tag3"}, result.Tags) + }) + + t.Run("Patch_AddToSetArray", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{"tag1"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + // Add new tag + patch := repository.Patch().AddToSet(repository.Field("tags"), "tag2") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1", "tag2"}, result.Tags) + + // Try to add duplicate tag - should not add + patch = repository.Patch().AddToSet(repository.Field("tags"), "tag1") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1", "tag2"}, result.Tags) + }) + + t.Run("Patch_PushToEmptyArray", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + patch := repository.Patch().Push(repository.Field("tags"), "tag1") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1"}, result.Tags) + }) + + t.Run("Patch_PullFromEmptyArray", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{}, result.Tags) + }) + + t.Run("Patch_PullNonExistentElement", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.NoError(t, err) + + var result TestObject + err = repo.Get(ctx, *obj.GetID(), &result) + require.NoError(t, err) + assert.Equal(t, []string{"tag1", "tag2"}, result.Tags) + }) + + t.Run("Patch_ChainedArrayOperations", func(t *testing.T) { + obj := &TestObject{Name: "test", Tags: []string{"tag1"}} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + // Note: MongoDB doesn't allow multiple operations on the same array field in a single update + // This test demonstrates that chained array operations on the same field will fail + patch := repository.Patch(). + Push(repository.Field("tags"), "tag2"). + AddToSet(repository.Field("tags"), "tag3"). + Pull(repository.Field("tags"), "tag1") + err = repo.Patch(ctx, *obj.GetID(), patch) + require.Error(t, err) // This should fail due to MongoDB's limitation + assert.Contains(t, err.Error(), "conflict") + }) + + t.Run("PatchMany_ArrayOperations", func(t *testing.T) { + objs := []*TestObject{ + {Name: "obj1", Tags: []string{"tag1"}}, + {Name: "obj2", Tags: []string{"tag2"}}, + {Name: "obj3", Tags: []string{"tag3"}}, + } + for _, o := range objs { + err := repo.Insert(ctx, o, nil) + require.NoError(t, err) + } + + query := repository.Query().Comparison(repository.Field("name"), builder.In, []string{"obj1", "obj2"}) + patch := repository.Patch().Push(repository.Field("tags"), "common") + modified, err := repo.PatchMany(ctx, query, patch) + require.NoError(t, err) + assert.Equal(t, 2, modified) + + // Verify the changes + for _, name := range []string{"obj1", "obj2"} { + var result TestObject + err = repo.FindOneByFilter(ctx, repository.Query().Comparison(repository.Field("name"), builder.Eq, name), &result) + require.NoError(t, err) + assert.Contains(t, result.Tags, "common") + } + }) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_test.go new file mode 100644 index 0000000..ad1b2c6 --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_test.go @@ -0,0 +1,188 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type TestObject struct { + storable.Base `bson:",inline" json:",inline"` + Name string `bson:"name"` + Tags []string `bson:"tags"` +} + +func (t *TestObject) Collection() string { + return "testObject" +} + +type AnotherObject struct { + storable.Base `bson:",inline" json:",inline"` + Description string `bson:"description"` +} + +func (a *AnotherObject) Collection() string { + return "anotherObject" +} + +func terminate(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) { + err := container.Terminate(ctx) + require.NoError(t, err, "failed to terminate MongoDB container") +} + +func disconnect(ctx context.Context, t *testing.T, client *mongo.Client) { + err := client.Disconnect(ctx) + require.NoError(t, err, "failed to disconnect from MongoDB") +} + +func TestMongoRepository_Get(t *testing.T) { + // Use a context with timeout, so if container spinning or DB ops hang, + // the test won't run indefinitely. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Get_Success", func(t *testing.T) { + testObj := &TestObject{Name: "testName"} + err := repository.Insert(ctx, testObj, nil) + require.NoError(t, err) + + result := &TestObject{} + err = repository.Get(ctx, testObj.ID, result) + require.NoError(t, err) + assert.Equal(t, testObj.Name, result.Name) + assert.Equal(t, testObj.ID, result.ID) + }) + + t.Run("Get_NotFound", func(t *testing.T) { + nonExistentID := bson.NewObjectID() + result := &TestObject{} + + err := repository.Get(ctx, nonExistentID, result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrNoData)) + }) + + t.Run("Get_InvalidID", func(t *testing.T) { + invalidID := bson.ObjectID{} // zero value + result := &TestObject{} + + err := repository.Get(ctx, invalidID, result) + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrInvalidArg)) + }) + + t.Run("Get_DifferentTypes", func(t *testing.T) { + anotherObj := &AnotherObject{Description: "testDescription"} + err := repository.Insert(ctx, anotherObj, nil) + require.NoError(t, err) + + result := &AnotherObject{} + err = repository.Get(ctx, anotherObj.ID, result) + require.NoError(t, err) + assert.Equal(t, anotherObj.Description, result.Description) + assert.Equal(t, anotherObj.ID, result.ID) + }) +} + +func TestMongoRepository_ListIDs(t *testing.T) { + // Use a context with timeout, so if container spinning or DB ops hang, + // the test won't run indefinitely. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repository := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("ListIDs_Success", func(t *testing.T) { + // Insert test data + testObjs := []*TestObject{ + {Name: "testName1"}, + {Name: "testName2"}, + {Name: "testName3"}, + } + for _, obj := range testObjs { + err := repository.Insert(ctx, obj, nil) + require.NoError(t, err) + } + + // Define a query to match all objects + query := builderimp.NewQueryImp() + + // Call ListIDs + ids, err := repository.ListIDs(ctx, query) + require.NoError(t, err) + + // Assert the IDs are correct + require.Len(t, ids, len(testObjs)) + for _, obj := range testObjs { + assert.Contains(t, ids, obj.ID) + } + }) + + t.Run("ListIDs_EmptyResult", func(t *testing.T) { + // Define a query that matches no objects + query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName") + + // Call ListIDs + ids, err := repository.ListIDs(ctx, query) + require.NoError(t, err) + + // Assert no IDs are returned + assert.Empty(t, ids) + }) +} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go new file mode 100644 index 0000000..bf8136d --- /dev/null +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go @@ -0,0 +1,99 @@ +//go:build integration +// +build integration + +package repositoryimp_test + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func TestMongoRepository_Upsert(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mongoContainer, err := mongodb.Run(ctx, + "mongo:latest", + mongodb.WithUsername("root"), + mongodb.WithPassword("password"), + testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), + ) + require.NoError(t, err, "failed to start MongoDB container") + defer terminate(ctx, t, mongoContainer) + + mongoURI, err := mongoContainer.ConnectionString(ctx) + require.NoError(t, err, "failed to get MongoDB connection string") + + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(clientOptions) + require.NoError(t, err, "failed to connect to MongoDB") + defer disconnect(ctx, t, client) + + db := client.Database("testdb") + repo := repositoryimp.NewMongoRepository(db, "testcollection") + + t.Run("Upsert_InsertsWhenIDIsZero", func(t *testing.T) { + obj := &TestObject{Name: "inserted-via-upsert"} + require.True(t, obj.GetID().IsZero()) + + err := repo.Upsert(ctx, obj) + require.NoError(t, err) + assert.False(t, obj.GetID().IsZero()) + assert.NotZero(t, obj.CreatedAt) + assert.NotZero(t, obj.UpdatedAt) + + var stored TestObject + err = repo.Get(ctx, *obj.GetID(), &stored) + require.NoError(t, err) + assert.Equal(t, "inserted-via-upsert", stored.Name) + }) + + t.Run("Upsert_UpdatesExistingDocument", func(t *testing.T) { + obj := &TestObject{Name: "before-upsert"} + err := repo.Insert(ctx, obj, nil) + require.NoError(t, err) + + createdAt := obj.CreatedAt + updatedAt := obj.UpdatedAt + time.Sleep(10 * time.Millisecond) + + obj.Name = "after-upsert" + err = repo.Upsert(ctx, obj) + require.NoError(t, err) + + assert.Equal(t, createdAt, obj.CreatedAt) + assert.True(t, obj.UpdatedAt.After(updatedAt)) + + var stored TestObject + err = repo.Get(ctx, *obj.GetID(), &stored) + require.NoError(t, err) + assert.Equal(t, "after-upsert", stored.Name) + assert.WithinDuration(t, createdAt, stored.CreatedAt, time.Second) + assert.True(t, stored.UpdatedAt.After(updatedAt)) + }) + + t.Run("Upsert_InsertsWhenIDDoesNotExist", func(t *testing.T) { + obj := &TestObject{Name: "upsert-with-preassigned-id"} + obj.SetID(bson.NewObjectID()) + + err := repo.Upsert(ctx, obj) + require.NoError(t, err) + + var stored TestObject + err = repo.Get(ctx, *obj.GetID(), &stored) + require.NoError(t, err) + assert.Equal(t, "upsert-with-preassigned-id", stored.Name) + assert.Equal(t, *obj.GetID(), stored.ID) + }) +} diff --git a/backend/pkg/db/internal/mongo/rolesdb/db.go b/backend/pkg/db/internal/mongo/rolesdb/db.go new file mode 100644 index 0000000..4ef1f4b --- /dev/null +++ b/backend/pkg/db/internal/mongo/rolesdb/db.go @@ -0,0 +1,21 @@ +package rolesdb + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type RolesDB struct { + template.DBImp[*model.RoleDescription] +} + +func Create(logger mlogger.Logger, db *mongo.Database) (*RolesDB, error) { + p := &RolesDB{ + DBImp: *template.Create[*model.RoleDescription](logger, mservice.Roles, db), + } + + return p, nil +} diff --git a/backend/pkg/db/internal/mongo/rolesdb/list.go b/backend/pkg/db/internal/mongo/rolesdb/list.go new file mode 100644 index 0000000..9f56adb --- /dev/null +++ b/backend/pkg/db/internal/mongo/rolesdb/list.go @@ -0,0 +1,15 @@ +package rolesdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *RolesDB) List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) { + filter := repository.OrgFilter(organizationRef) + return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, cursor, db.Repository) +} diff --git a/backend/pkg/db/internal/mongo/rolesdb/roles.go b/backend/pkg/db/internal/mongo/rolesdb/roles.go new file mode 100644 index 0000000..89a289f --- /dev/null +++ b/backend/pkg/db/internal/mongo/rolesdb/roles.go @@ -0,0 +1,15 @@ +package rolesdb + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (db *RolesDB) Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) { + filter := repository.Query().In(repository.IDField(), refs) + return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, nil, db.Repository) +} diff --git a/backend/pkg/db/internal/mongo/transactionimp/factory.go b/backend/pkg/db/internal/mongo/transactionimp/factory.go new file mode 100644 index 0000000..8106414 --- /dev/null +++ b/backend/pkg/db/internal/mongo/transactionimp/factory.go @@ -0,0 +1,18 @@ +package transactionimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type MongoTransactionFactory struct { + client *mongo.Client +} + +func (mtf *MongoTransactionFactory) CreateTransaction() transaction.Transaction { + return Create(mtf.client) +} + +func CreateFactory(client *mongo.Client) transaction.Factory { + return &MongoTransactionFactory{client: client} +} diff --git a/backend/pkg/db/internal/mongo/transactionimp/transaction.go b/backend/pkg/db/internal/mongo/transactionimp/transaction.go new file mode 100644 index 0000000..4996ee5 --- /dev/null +++ b/backend/pkg/db/internal/mongo/transactionimp/transaction.go @@ -0,0 +1,30 @@ +package transactionimp + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type MongoTransaction struct { + client *mongo.Client +} + +func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := mt.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + callback := func(c context.Context) (any, error) { + return cb(c) + } + + return session.WithTransaction(ctx, callback) +} + +func Create(client *mongo.Client) *MongoTransaction { + return &MongoTransaction{client: client} +} diff --git a/backend/pkg/db/invitation/invitation.go b/backend/pkg/db/invitation/invitation.go new file mode 100644 index 0000000..1fbc43b --- /dev/null +++ b/backend/pkg/db/invitation/invitation.go @@ -0,0 +1,19 @@ +package invitation + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + auth.ProtectedDB[*model.Invitation] + GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) + Accept(ctx context.Context, invitationRef bson.ObjectID) error + Decline(ctx context.Context, invitationRef bson.ObjectID) error + List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) + DeleteCascade(ctx context.Context, accountRef, statusRef bson.ObjectID) error + SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error +} diff --git a/backend/pkg/db/permissions_config_test.go b/backend/pkg/db/permissions_config_test.go new file mode 100644 index 0000000..95140dc --- /dev/null +++ b/backend/pkg/db/permissions_config_test.go @@ -0,0 +1,129 @@ +package db + +import ( + "errors" + "strings" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.uber.org/zap" +) + +func TestPermissionsDatabaseConfigAsDatabaseConfig(t *testing.T) { + cfg := &PermissionsDatabaseConfig{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "PERMISSIONS_MONGO_DATABASE", + }, + } + + converted := cfg.AsDatabaseConfig() + if converted == nil { + t.Fatal("expected converted config") + } + if converted.Driver != Mongo { + t.Fatalf("expected driver %q, got %q", Mongo, converted.Driver) + } + if got := converted.Settings["database_env"]; got != "PERMISSIONS_MONGO_DATABASE" { + t.Fatalf("expected database_env to be preserved, got %v", got) + } +} + +func TestNewCentralPermissionsConnectionRequiresConfig(t *testing.T) { + _, err := NewCentralPermissionsConnection(zap.NewNop(), nil) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} + +func TestNewConnectionRequiresConfig(t *testing.T) { + _, err := NewConnection(zap.NewNop(), nil) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} + +func TestNewCentralPermissionsConnectionDelegatesToDBConnection(t *testing.T) { + _, err := NewCentralPermissionsConnection(zap.NewNop(), &PermissionsDatabaseConfig{ + Driver: DBDriver("bogus"), + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "permissions_database") { + t.Fatalf("expected permissions_database context, got %v", err) + } + if !strings.Contains(err.Error(), "unknown database driver") { + t.Fatalf("expected delegated database driver error, got %v", err) + } +} + +func TestValidateSplitDomainPermissionsDatabase(t *testing.T) { + t.Setenv("DOMAIN_DB", "domain") + t.Setenv("PERMISSIONS_DB", "sendico") + + domain, permissions, err := ValidateSplitDomainPermissionsDatabase( + "test service", + &Config{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "DOMAIN_DB", + }, + }, + &PermissionsDatabaseConfig{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "PERMISSIONS_DB", + }, + }, + ) + if err != nil { + t.Fatalf("expected valid split-domain config, got %v", err) + } + if domain.Name != "domain" || permissions.Name != "sendico" { + t.Fatalf("unexpected resolved names: domain=%+v permissions=%+v", domain, permissions) + } +} + +func TestValidateSplitDomainPermissionsDatabaseRejectsMissingPermissionsConfig(t *testing.T) { + t.Setenv("DOMAIN_DB", "domain") + + _, _, err := ValidateSplitDomainPermissionsDatabase( + "test service", + &Config{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "DOMAIN_DB", + }, + }, + nil, + ) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} + +func TestValidateSplitDomainPermissionsDatabaseRejectsSameResolvedDatabase(t *testing.T) { + t.Setenv("DOMAIN_DB", "domain") + t.Setenv("PERMISSIONS_DB", "domain") + + _, _, err := ValidateSplitDomainPermissionsDatabase( + "test service", + &Config{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "DOMAIN_DB", + }, + }, + &PermissionsDatabaseConfig{ + Driver: Mongo, + Settings: model.SettingsT{ + "database_env": "PERMISSIONS_DB", + }, + }, + ) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} diff --git a/backend/pkg/db/policy/policy.go b/backend/pkg/db/policy/policy.go new file mode 100644 index 0000000..095a4a6 --- /dev/null +++ b/backend/pkg/db/policy/policy.go @@ -0,0 +1,17 @@ +package policy + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + template.DB[*model.PolicyDescription] + All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) + Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) + GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error +} diff --git a/backend/pkg/db/repository/abfilter.go b/backend/pkg/db/repository/abfilter.go new file mode 100644 index 0000000..3501db5 --- /dev/null +++ b/backend/pkg/db/repository/abfilter.go @@ -0,0 +1,78 @@ +package repository + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AccountBoundFilter provides factory methods for creating account-bound filters +type AccountBoundFilter struct{} + +// NewAccountBoundFilter creates a new AccountBoundFilter instance +func NewAccountBoundFilter() *AccountBoundFilter { + return &AccountBoundFilter{} +} + +// WithoutOrg creates a filter for account-bound objects without organization filter +// This filter finds objects where: +// - accountRef matches the provided accountRef, OR +// - accountRef is nil/null, OR +// - accountRef field doesn't exist +func (f *AccountBoundFilter) WithoutOrg(accountRef bson.ObjectID) builder.Query { + return Query().Or( + AccountFilter(accountRef), + Filter(model.AccountRefField, nil), + Exists(AccountField(), false), + ) +} + +// WithOrg creates a filter for account-bound objects with organization filter +// This filter finds objects where: +// - accountRef matches the provided accountRef, OR +// - accountRef is nil/null, OR +// - accountRef field doesn't exist +// AND combines with organization filter +func (f *AccountBoundFilter) WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { + return Query().And( + OrgFilter(organizationRef), + f.WithoutOrg(accountRef), + ) +} + +// WithQuery creates a filter for account-bound objects with additional query and organization filter +func (f *AccountBoundFilter) WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { + accountQuery := f.WithOrg(accountRef, organizationRef) + return additionalQuery.And(accountQuery) +} + +// WithQueryNoOrg creates a filter for account-bound objects with additional query but no org filter +func (f *AccountBoundFilter) WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { + accountQuery := f.WithoutOrg(accountRef) + return additionalQuery.And(accountQuery) +} + +// Global instance for convenience +var DefaultAccountBoundFilter = NewAccountBoundFilter() + +// Convenience functions that use the global factory instance + +// WithOrg is a convenience function that uses the default factory +func WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { + return DefaultAccountBoundFilter.WithOrg(accountRef, organizationRef) +} + +// WithoutOrg is a convenience function that uses the default factory +func WithoutOrg(accountRef bson.ObjectID) builder.Query { + return DefaultAccountBoundFilter.WithoutOrg(accountRef) +} + +// WithQuery is a convenience function that uses the default factory +func WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { + return DefaultAccountBoundFilter.WithQuery(accountRef, organizationRef, additionalQuery) +} + +// WithQueryNoOrg is a convenience function that uses the default factory +func WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { + return DefaultAccountBoundFilter.WithQueryNoOrg(accountRef, additionalQuery) +} diff --git a/backend/pkg/db/repository/builder/accumulator.go b/backend/pkg/db/repository/builder/accumulator.go new file mode 100644 index 0000000..7d2729a --- /dev/null +++ b/backend/pkg/db/repository/builder/accumulator.go @@ -0,0 +1,11 @@ +package builder + +import "go.mongodb.org/mongo-driver/v2/bson" + +type Accumulator interface { + Build() bson.D +} + +type GroupAccumulator interface { + Build() bson.D +} diff --git a/backend/pkg/db/repository/builder/alias.go b/backend/pkg/db/repository/builder/alias.go new file mode 100644 index 0000000..ba587bc --- /dev/null +++ b/backend/pkg/db/repository/builder/alias.go @@ -0,0 +1,8 @@ +package builder + +import "go.mongodb.org/mongo-driver/v2/bson" + +type Alias interface { + Field() Field + Build() bson.D +} diff --git a/backend/pkg/db/repository/builder/array.go b/backend/pkg/db/repository/builder/array.go new file mode 100644 index 0000000..59e78a3 --- /dev/null +++ b/backend/pkg/db/repository/builder/array.go @@ -0,0 +1,7 @@ +package builder + +import "go.mongodb.org/mongo-driver/v2/bson" + +type Array interface { + Build() bson.A +} diff --git a/backend/pkg/db/repository/builder/expression.go b/backend/pkg/db/repository/builder/expression.go new file mode 100644 index 0000000..0c1d284 --- /dev/null +++ b/backend/pkg/db/repository/builder/expression.go @@ -0,0 +1,5 @@ +package builder + +type Expression interface { + Build() any +} diff --git a/backend/pkg/db/repository/builder/field.go b/backend/pkg/db/repository/builder/field.go new file mode 100644 index 0000000..4d2caaa --- /dev/null +++ b/backend/pkg/db/repository/builder/field.go @@ -0,0 +1,7 @@ +package builder + +type Field interface { + Dot(field string) Field + CopyWith(field string) Field + Build() string +} diff --git a/backend/pkg/db/repository/builder/keyword.go b/backend/pkg/db/repository/builder/keyword.go new file mode 100644 index 0000000..64d67b0 --- /dev/null +++ b/backend/pkg/db/repository/builder/keyword.go @@ -0,0 +1,16 @@ +package builder + +type MongoKeyword string + +const ( + MKAs MongoKeyword = "as" + MKForeignField MongoKeyword = "foreignField" + MKFrom MongoKeyword = "from" + MKIncludeArrayIndex MongoKeyword = "includeArrayIndex" + MKLet MongoKeyword = "let" + MKLocalField MongoKeyword = "localField" + MKPath MongoKeyword = "path" + MKPipeline MongoKeyword = "pipeline" + MKPreserveNullAndEmptyArrays MongoKeyword = "preserveNullAndEmptyArrays" + MKNewRoot MongoKeyword = "newRoot" +) diff --git a/backend/pkg/db/repository/builder/operators.go b/backend/pkg/db/repository/builder/operators.go new file mode 100644 index 0000000..c54bbd1 --- /dev/null +++ b/backend/pkg/db/repository/builder/operators.go @@ -0,0 +1,57 @@ +package builder + +type MongoOperation string + +const ( + // Comparison operators + Gt MongoOperation = "$gt" + Lt MongoOperation = "$lt" + Gte MongoOperation = "$gte" + Lte MongoOperation = "$lte" + Eq MongoOperation = "$eq" + Ne MongoOperation = "$ne" + In MongoOperation = "$in" + NotIn MongoOperation = "$nin" + Exists MongoOperation = "$exists" + + // Logical operators + And MongoOperation = "$and" + Or MongoOperation = "$or" + Not MongoOperation = "$not" + + AddToSet MongoOperation = "$addToSet" + Avg MongoOperation = "$avg" + Pull MongoOperation = "$pull" + Count MongoOperation = "$count" + Cond MongoOperation = "$cond" + Each MongoOperation = "$each" + Expr MongoOperation = "$expr" + First MongoOperation = "$first" + Group MongoOperation = "$group" + IfNull MongoOperation = "$ifNull" + Limit MongoOperation = "$limit" + Literal MongoOperation = "$literal" + Lookup MongoOperation = "$lookup" + Match MongoOperation = "$match" + Max MongoOperation = "$max" + Min MongoOperation = "$min" + Push MongoOperation = "$push" + Project MongoOperation = "$project" + Set MongoOperation = "$set" + Inc MongoOperation = "$inc" + Unset MongoOperation = "$unset" + Rename MongoOperation = "$rename" + ReplaceRoot MongoOperation = "$replaceRoot" + SetUnion MongoOperation = "$setUnion" + Size MongoOperation = "$size" + Sort MongoOperation = "$sort" + Skip MongoOperation = "$skip" + Sum MongoOperation = "$sum" + Type MongoOperation = "$type" + Unwind MongoOperation = "$unwind" + + Add MongoOperation = "$add" + Subtract MongoOperation = "$subtract" + Multiply MongoOperation = "$multiply" + Divide MongoOperation = "$divide" +) diff --git a/backend/pkg/db/repository/builder/patch.go b/backend/pkg/db/repository/builder/patch.go new file mode 100644 index 0000000..5da3eee --- /dev/null +++ b/backend/pkg/db/repository/builder/patch.go @@ -0,0 +1,16 @@ +package builder + +import "go.mongodb.org/mongo-driver/v2/bson" + +// Patch defines operations for constructing partial update documents. +// Each builder method returns the same Patch instance to allow chaining. +type Patch interface { + Set(field Field, value any) Patch + Inc(field Field, value any) Patch + Unset(field Field) Patch + Rename(field Field, newName string) Patch + Push(field Field, value any) Patch + Pull(field Field, value any) Patch + AddToSet(field Field, value any) Patch + Build() bson.D +} diff --git a/backend/pkg/db/repository/builder/pipeline.go b/backend/pkg/db/repository/builder/pipeline.go new file mode 100644 index 0000000..957b863 --- /dev/null +++ b/backend/pkg/db/repository/builder/pipeline.go @@ -0,0 +1,24 @@ +package builder + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type Pipeline interface { + Match(filter Query) Pipeline + Lookup(from mservice.Type, localField, foreignField, as Field) Pipeline + LookupWithPipeline( + from mservice.Type, + pipeline Pipeline, // your nested pipeline + as Field, + let *map[string]Field, // optional e.g. {"projRef": Field("$_id")} + ) Pipeline + // unwind with functional options + Unwind(path Field, opts ...UnwindOption) Pipeline + Count(field Field) Pipeline + Group(groupBy Alias, accumulators ...GroupAccumulator) Pipeline + Project(projections ...Projection) Pipeline + ReplaceRoot(newRoot Expression) Pipeline + Build() mongo.Pipeline +} diff --git a/backend/pkg/db/repository/builder/projection.go b/backend/pkg/db/repository/builder/projection.go new file mode 100644 index 0000000..466f07a --- /dev/null +++ b/backend/pkg/db/repository/builder/projection.go @@ -0,0 +1,7 @@ +package builder + +import "go.mongodb.org/mongo-driver/v2/bson" + +type Projection interface { + Build() bson.D +} diff --git a/backend/pkg/db/repository/builder/query.go b/backend/pkg/db/repository/builder/query.go new file mode 100644 index 0000000..1fc397a --- /dev/null +++ b/backend/pkg/db/repository/builder/query.go @@ -0,0 +1,24 @@ +package builder + +import ( + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type Query interface { + Filter(field Field, value any) Query + And(filters ...Query) Query + Or(filters ...Query) Query + Expression(value Expression) Query + Comparison(field Field, operator MongoOperation, value any) Query + RegEx(field Field, pattern, options string) Query + In(field Field, values ...any) Query + NotIn(field Field, values ...any) Query + Sort(field Field, ascending bool) Query + Limit(limit *int64) Query + Offset(offset *int64) Query + Archived(isArchived *bool) Query + BuildPipeline() bson.D + BuildQuery() bson.D + BuildOptions() *options.FindOptionsBuilder +} diff --git a/backend/pkg/db/repository/builder/unwind.go b/backend/pkg/db/repository/builder/unwind.go new file mode 100644 index 0000000..9ed2c68 --- /dev/null +++ b/backend/pkg/db/repository/builder/unwind.go @@ -0,0 +1,23 @@ +package builder + +// UnwindOption is a functional option for configuring the $unwind stage. +type UnwindOption func(*UnwindOpts) + +type UnwindOpts struct { + PreserveNullAndEmptyArrays bool + IncludeArrayIndex string +} + +// WithPreserveNullAndEmptyArrays tells $unwind to keep docs where the array is null/empty. +func WithPreserveNullAndEmptyArrays() UnwindOption { + return func(o *UnwindOpts) { + o.PreserveNullAndEmptyArrays = true + } +} + +// WithIncludeArrayIndex adds an array‐index field named idxField to each unwound doc. +func WithIncludeArrayIndex(idxField string) UnwindOption { + return func(o *UnwindOpts) { + o.IncludeArrayIndex = idxField + } +} diff --git a/backend/pkg/db/repository/builder/value.go b/backend/pkg/db/repository/builder/value.go new file mode 100644 index 0000000..45b011c --- /dev/null +++ b/backend/pkg/db/repository/builder/value.go @@ -0,0 +1,5 @@ +package builder + +type Value interface { + Build() any +} diff --git a/backend/pkg/db/repository/builders.go b/backend/pkg/db/repository/builders.go new file mode 100644 index 0000000..54543cf --- /dev/null +++ b/backend/pkg/db/repository/builders.go @@ -0,0 +1,269 @@ +package repository + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func Query() builder.Query { + return builderimp.NewQueryImp() +} + +func Filter(field string, value any) builder.Query { + return Query().Filter(Field(field), value) +} + +func Field(baseName string) builder.Field { + return builderimp.NewFieldImp(baseName) +} + +func Ref(field builder.Field) builder.Field { + return builderimp.NewRefFieldImp(field) +} + +func RootRef() builder.Field { + return builderimp.NewRootRef() +} + +func RemoveRef() builder.Field { + return builderimp.NewRemoveRef() +} + +func Pipeline(stages ...bson.D) builder.Pipeline { + return builderimp.NewPipelineImp(stages...) +} + +func IDField() builder.Field { + return Field(storable.IDField) +} + +func NameField() builder.Field { + return Field(model.NameField) +} + +func DescrtiptionField() builder.Field { + return Field(model.DescriptionField) +} + +func IsArchivedField() builder.Field { + return Field(storable.IsArchivedField) +} + +func IDFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(IDField(), ref) +} + +func ArchivedFilter() builder.Query { + return IsArchivedFilter(true) +} + +func NotArchivedFilter() builder.Query { + return IsArchivedFilter(false) +} + +func IsArchivedFilter(isArchived bool) builder.Query { + return Query().Filter(IsArchivedField(), isArchived) +} + +func OrgField() builder.Field { + return Field(storable.OrganizationRefField) +} + +func OrgFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(OrgField(), ref) +} + +func ProjectField() builder.Field { + return Field("projectRef") +} + +func ProjectFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(ProjectField(), ref) +} + +func AccountField() builder.Field { + return Field(model.AccountRefField) +} + +func AccountFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(AccountField(), ref) +} + +func StatusRefField() builder.Field { + return Field("statusRef") +} + +func StatusRefFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(StatusRefField(), ref) +} + +func PriorityRefField() builder.Field { + return Field("priorityRef") +} + +func PriorityRefFilter(ref bson.ObjectID) builder.Query { + return Query().Filter(PriorityRefField(), ref) +} + +func IndexField() builder.Field { + return Field("index") +} + +func IndexFilter(index int) builder.Query { + return Query().Filter(IndexField(), index) +} + +func IndexOpFilter(index int, operation builder.MongoOperation) builder.Query { + return Query().Comparison(IndexField(), operation, index) +} + +func Patch() builder.Patch { + return builderimp.NewPatchImp() +} + +func Accumulator(operator builder.MongoOperation, value any) builder.Accumulator { + return builderimp.NewAccumulator(operator, value) +} + +func GroupAccumulator(field builder.Field, acc builder.Accumulator) builder.GroupAccumulator { + return builderimp.NewGroupAccumulator(field, acc) +} + +func Literal(value any) builder.Expression { + return builderimp.NewLiteralExpression(value) +} + +func Projection(alias builder.Alias) builder.Projection { + return builderimp.NewAliasProjection(alias) +} + +func IncludeField(field builder.Field) builder.Projection { + return builderimp.IncludeField(field) +} + +func ExcludeField(field builder.Field) builder.Projection { + return builderimp.ExcludeField(field) +} + +func ProjectionExpr(field builder.Field, expr builder.Expression) builder.Projection { + return builderimp.NewProjectionExpr(field, expr) +} + +func NullAlias(lhs builder.Field) builder.Alias { + return builderimp.NewNullAlias(lhs) +} + +func SimpleAlias(lhs, rhs builder.Field) builder.Alias { + return builderimp.NewSimpleAlias(lhs, rhs) +} + +func ComplexAlias(lhs builder.Field, rhs []builder.Alias) builder.Alias { + return builderimp.NewComplexAlias(lhs, rhs) +} + +func Aliases(aliases ...builder.Alias) builder.Alias { + return builderimp.NewAliases(aliases...) +} + +func AddToSet(value builder.Expression) builder.Expression { + return builderimp.AddToSet(value) +} + +func Size(value builder.Expression) builder.Expression { + return builderimp.Size(value) +} + +func InRef(value builder.Field) builder.Expression { + return builderimp.InRef(value) +} + +func In(values ...any) builder.Expression { + return builderimp.In(values) +} + +func Cond(condition builder.Expression, ifTrue, ifFalse any) builder.Expression { + return builderimp.NewCond(condition, ifTrue, ifFalse) +} + +func And(exprs ...builder.Expression) builder.Expression { + return builderimp.NewAnd(exprs...) +} + +func Or(exprs ...builder.Expression) builder.Expression { + return builderimp.NewOr(exprs...) +} + +func Type(expr builder.Expression) builder.Expression { + return builderimp.NewType(expr) +} + +func Not(expression builder.Expression) builder.Expression { + return builderimp.NewNot(expression) +} + +func Sum(expression builder.Expression) builder.Expression { + return builderimp.NewSum(expression) +} + +func Assign(field builder.Field, expression builder.Expression) builder.Projection { + return builderimp.NewAssignment(field, expression) +} + +func SetUnion(exprs ...builder.Expression) builder.Expression { + return builderimp.NewSetUnion(exprs...) +} + +func Eq(left, right builder.Expression) builder.Expression { + return builderimp.Eq(left, right) +} + +func Gt(left, right builder.Expression) builder.Expression { + return builderimp.Gt(left, right) +} + +func Lt(left, right builder.Expression) builder.Expression { + return builderimp.NewLt(left, right) +} + +func Array(expressions ...builder.Expression) builder.Array { + return builderimp.NewArray(expressions...) +} + +func IfNull(cond, replacement builder.Expression) builder.Expression { + return builderimp.NewIfNull(cond, replacement) +} + +func Each(exprs ...builder.Expression) builder.Expression { + return builderimp.NewEach(exprs...) +} + +func Push(expression builder.Expression) builder.Expression { + return builderimp.NewPush(expression) +} + +func Min(expression builder.Expression) builder.Expression { + return builderimp.NewMin(expression) +} + +func Ne(left, right builder.Expression) builder.Expression { + return builderimp.Ne(left, right) +} + +func Compute(field builder.Field, expression builder.Expression) builder.Expression { + return builderimp.NewCompute(field, expression) +} + +func First(expr builder.Expression) builder.Expression { + return builderimp.First(expr) +} + +func Value(value any) builder.Value { + return builderimp.NewValue(value) +} + +func Exists(field builder.Field, exists bool) builder.Query { + return Query().Comparison(field, builder.Exists, exists) +} diff --git a/backend/pkg/db/repository/cursor.go b/backend/pkg/db/repository/cursor.go new file mode 100644 index 0000000..8a66b50 --- /dev/null +++ b/backend/pkg/db/repository/cursor.go @@ -0,0 +1,19 @@ +package repository + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/model" +) + +// ApplyCursor adds pagination and archival filters to the provided query. +func ApplyCursor(query builder.Query, cursor *model.ViewCursor) builder.Query { + if cursor == nil { + return query + } + + query = query.Limit(cursor.Limit) + query = query.Offset(cursor.Offset) + query = query.Archived(cursor.IsArchived) + + return query +} diff --git a/backend/pkg/db/repository/decoder/decoder.go b/backend/pkg/db/repository/decoder/decoder.go new file mode 100644 index 0000000..3a5fb78 --- /dev/null +++ b/backend/pkg/db/repository/decoder/decoder.go @@ -0,0 +1,5 @@ +package repository + +import "go.mongodb.org/mongo-driver/v2/mongo" + +type DecodingFunc = func(r *mongo.Cursor) error diff --git a/backend/pkg/db/repository/filter_factory_test.go b/backend/pkg/db/repository/filter_factory_test.go new file mode 100644 index 0000000..9ba32e8 --- /dev/null +++ b/backend/pkg/db/repository/filter_factory_test.go @@ -0,0 +1,93 @@ +package repository + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestAccountBoundFilter_WithOrg(t *testing.T) { + factory := NewAccountBoundFilter() + accountRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + + query := factory.WithOrg(accountRef, orgRef) + + // Test that the query is not nil + assert.NotNil(t, query) +} + +func TestAccountBoundFilter_WithoutOrg(t *testing.T) { + factory := NewAccountBoundFilter() + accountRef := bson.NewObjectID() + + query := factory.WithoutOrg(accountRef) + + // Test that the query is not nil + assert.NotNil(t, query) +} + +func TestAccountBoundFilter_WithQuery(t *testing.T) { + factory := NewAccountBoundFilter() + accountRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + additionalQuery := Query().Filter(Field("status"), "active") + + query := factory.WithQuery(accountRef, orgRef, additionalQuery) + + // Test that the query is not nil + assert.NotNil(t, query) +} + +func TestAccountBoundFilter_WithQueryNoOrg(t *testing.T) { + factory := NewAccountBoundFilter() + accountRef := bson.NewObjectID() + additionalQuery := Query().Filter(Field("status"), "active") + + query := factory.WithQueryNoOrg(accountRef, additionalQuery) + + // Test that the query is not nil + assert.NotNil(t, query) +} + +func TestDefaultAccountBoundFilter(t *testing.T) { + // Test that the default factory is not nil + assert.NotNil(t, DefaultAccountBoundFilter) + + // Test that it's the correct type + assert.IsType(t, &AccountBoundFilter{}, DefaultAccountBoundFilter) +} + +func TestConvenienceFunctions(t *testing.T) { + accountRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + additionalQuery := Query().Filter(Field("status"), "active") + + // Test convenience functions + query1 := WithOrg(accountRef, orgRef) + assert.NotNil(t, query1) + + query2 := WithoutOrg(accountRef) + assert.NotNil(t, query2) + + query3 := WithQuery(accountRef, orgRef, additionalQuery) + assert.NotNil(t, query3) + + query4 := WithQueryNoOrg(accountRef, additionalQuery) + assert.NotNil(t, query4) +} + +func TestFilterFactoryConsistency(t *testing.T) { + factory := NewAccountBoundFilter() + accountRef := bson.NewObjectID() + orgRef := bson.NewObjectID() + + // Test that factory methods and convenience functions produce the same result + query1 := factory.WithOrg(accountRef, orgRef) + query2 := WithOrg(accountRef, orgRef) + + // Both should be valid queries + assert.NotNil(t, query1) + assert.NotNil(t, query2) +} diff --git a/backend/pkg/db/repository/index/index.go b/backend/pkg/db/repository/index/index.go new file mode 100644 index 0000000..0606bea --- /dev/null +++ b/backend/pkg/db/repository/index/index.go @@ -0,0 +1,25 @@ +package repository + +import "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + +type Sort int8 + +const ( + Asc Sort = 1 + Desc Sort = -1 +) + +type Key struct { + Field string + Sort Sort // 1 or -1. 0 means “use Type”. + Type IndexType // optional: "text", "2dsphere", ... +} + +type Definition struct { + Keys []Key // mandatory, at least one element + Unique bool // unique constraint? + Sparse bool // sparse? + TTL *int32 // seconds; nil means “no TTL” + Name string // optional explicit name + PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes +} diff --git a/backend/pkg/db/repository/index/types.go b/backend/pkg/db/repository/index/types.go new file mode 100644 index 0000000..281ec1d --- /dev/null +++ b/backend/pkg/db/repository/index/types.go @@ -0,0 +1,36 @@ +package repository + +// IndexType represents a supported MongoDB index type. +type IndexType string + +const ( + // IndexTypeNotSet is a default index type + IndexTypeNotSet IndexType = "" + + // IndexTypeSingleField is a single-field index. + IndexTypeSingleField IndexType = "single" + + // IndexTypeCompound is a compound index on multiple fields. + IndexTypeCompound IndexType = "compound" + + // IndexTypeMultikey is an index on array fields (created automatically when needed). + IndexTypeMultikey IndexType = "multikey" + + // IndexTypeText is a text index for full-text search. + IndexTypeText IndexType = "text" + + // IndexTypeGeo2D is a legacy 2D geospatial index for planar geometry. + IndexTypeGeo2D IndexType = "2d" + + // IndexTypeGeo2DSphere is a 2dsphere geospatial index for GeoJSON data. + IndexTypeGeo2DSphere IndexType = "2dsphere" + + // IndexTypeHashed is a hashed index for sharding and efficient equality queries. + IndexTypeHashed IndexType = "hashed" + + // IndexTypeWildcard is a wildcard index to index all fields or subpaths. + IndexTypeWildcard IndexType = "wildcard" + + // IndexTypeClustered is a clustered index that orders the collection on the index key. + IndexTypeClustered IndexType = "clustered" +) diff --git a/backend/pkg/db/repository/repository.go b/backend/pkg/db/repository/repository.go new file mode 100644 index 0000000..e2719b3 --- /dev/null +++ b/backend/pkg/db/repository/repository.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" + ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type ( + // FilterQuery selects documents to operate on. + FilterQuery = builder.Query + // PatchDoc defines field/value modifications for partial updates. + PatchDoc = builder.Patch +) + +type Repository interface { + Aggregate(ctx context.Context, builder builder.Pipeline, decoder rd.DecodingFunc) error + Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error + InsertMany(ctx context.Context, objects []storable.Storable) error + Get(ctx context.Context, id bson.ObjectID, result storable.Storable) error + FindOneByFilter(ctx context.Context, builder builder.Query, result storable.Storable) error + FindFirstByFilter(ctx context.Context, builder builder.Query, result storable.Storable) error + FindManyByFilter(ctx context.Context, builder builder.Query, decoder rd.DecodingFunc) error + // FindAllByFilter decodes all matching documents into result, which must be a pointer to a slice. + FindAllByFilter(ctx context.Context, builder builder.Query, result any) error + CountByFilter(ctx context.Context, builder builder.Query) (int64, error) + Update(ctx context.Context, obj storable.Storable) error + // ReplaceOneByFilter replaces one matching document with obj and reports whether a document matched the filter. + // The repository must persist obj as provided and must not mutate its timestamps or identity before replacement. + ReplaceOneByFilter(ctx context.Context, filter builder.Query, obj storable.Storable) (bool, error) + // Upsert replaces the document identified by obj ID, inserting it when no document exists. + Upsert(ctx context.Context, obj storable.Storable) error + // Patch applies partial updates defined by patch to the document identified by id. + Patch(ctx context.Context, id bson.ObjectID, patch PatchDoc) error + // PatchOneByFilter atomically patches one document matching filter and decodes the updated document into result. + PatchOneByFilter(ctx context.Context, filter FilterQuery, patch PatchDoc, result storable.Storable) (bool, error) + // PatchMany applies partial updates defined by patch to all documents matching filter and returns the number of updated documents. + PatchMany(ctx context.Context, filter FilterQuery, patch PatchDoc) (int, error) + Delete(ctx context.Context, id bson.ObjectID) error + DeleteMany(ctx context.Context, query builder.Query) error + CreateIndex(def *ri.Definition) error + DropIndex(ctx context.Context, name string) error + ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) + ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) + ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) + ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) + WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) + Collection() string +} + +func CreateMongoRepository(db *mongo.Database, collection string) Repository { + return repositoryimp.NewMongoRepository(db, collection) +} diff --git a/backend/pkg/db/role/role.go b/backend/pkg/db/role/role.go new file mode 100644 index 0000000..2d5009d --- /dev/null +++ b/backend/pkg/db/role/role.go @@ -0,0 +1,15 @@ +package role + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/template" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + template.DB[*model.RoleDescription] + Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) + List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) +} diff --git a/backend/pkg/db/storable/id.go b/backend/pkg/db/storable/id.go new file mode 100644 index 0000000..58d0637 --- /dev/null +++ b/backend/pkg/db/storable/id.go @@ -0,0 +1,39 @@ +package storable + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + IDField = "_id" + PermissionRefField = "permissionRef" + OrganizationRefField = "organizationRef" + IsArchivedField = "isArchived" + UpdatedAtField = "updatedAt" +) + +type Base struct { + ID bson.ObjectID `bson:"_id" json:"id"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // Timestamp for when the comment was created + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` // Timestamp for when the comment was last updated (optional) +} + +func (b *Base) GetID() *bson.ObjectID { + return &b.ID +} + +func (b *Base) SetID(objID bson.ObjectID) { + b.ID = objID + b.CreatedAt = time.Now() + b.UpdatedAt = time.Now() +} + +func (b *Base) Update() { + b.UpdatedAt = time.Now() +} + +func (b *Base) Collection() string { + return "base" +} diff --git a/backend/pkg/db/storable/ref.go b/backend/pkg/db/storable/ref.go new file mode 100644 index 0000000..58e3468 --- /dev/null +++ b/backend/pkg/db/storable/ref.go @@ -0,0 +1,11 @@ +package storable + +import "go.mongodb.org/mongo-driver/v2/bson" + +const ( + RefField = "ref" +) + +type Ref struct { + Ref bson.ObjectID `bson:"ref" json:"ref"` +} diff --git a/backend/pkg/db/storable/storable.go b/backend/pkg/db/storable/storable.go new file mode 100644 index 0000000..e048582 --- /dev/null +++ b/backend/pkg/db/storable/storable.go @@ -0,0 +1,10 @@ +package storable + +import "go.mongodb.org/mongo-driver/v2/bson" + +type Storable interface { + GetID() *bson.ObjectID + SetID(objID bson.ObjectID) + Update() + Collection() string +} diff --git a/backend/pkg/db/template/interface.go b/backend/pkg/db/template/interface.go new file mode 100644 index 0000000..61b2556 --- /dev/null +++ b/backend/pkg/db/template/interface.go @@ -0,0 +1,21 @@ +package template + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB[T storable.Storable] interface { + Create(ctx context.Context, object T) error + InsertMany(ctx context.Context, objects []T) error + Get(ctx context.Context, objectRef bson.ObjectID, result T) error + Update(ctx context.Context, object T) error + Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error + Delete(ctx context.Context, objectRef bson.ObjectID) error + DeleteMany(ctx context.Context, query builder.Query) error + DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error + FindOne(ctx context.Context, query builder.Query, result T) error +} diff --git a/backend/pkg/db/template/template.go b/backend/pkg/db/template/template.go new file mode 100644 index 0000000..5aff65f --- /dev/null +++ b/backend/pkg/db/template/template.go @@ -0,0 +1,104 @@ +package template + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type CascadeDeleterT = func(ctx context.Context, objectRef bson.ObjectID) error + +type DBImp[T storable.Storable] struct { + Logger mlogger.Logger + Repository repository.Repository + cdeleter CascadeDeleterT +} + +func (db *DBImp[T]) Create(ctx context.Context, object T) error { + return db.Repository.Insert(ctx, object, nil) +} + +func (db *DBImp[T]) InsertMany(ctx context.Context, objects []T) error { + if len(objects) == 0 { + return nil + } + + storables := make([]storable.Storable, len(objects)) + for i, obj := range objects { + storables[i] = obj + } + + return db.Repository.InsertMany(ctx, storables) +} + +func (db *DBImp[T]) Get(ctx context.Context, objectRef bson.ObjectID, result T) error { + return db.Repository.Get(ctx, objectRef, result) +} + +func (db *DBImp[T]) Update(ctx context.Context, object T) error { + return db.Repository.Update(ctx, object) +} + +func (db *DBImp[T]) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { + return db.Repository.Patch(ctx, objectRef, patch) +} + +func (db *DBImp[T]) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) { + return db.Repository.PatchMany(ctx, query, patch) +} + +func (db *DBImp[T]) Delete(ctx context.Context, objectRef bson.ObjectID) error { + return db.Repository.Delete(ctx, objectRef) +} + +func (db *DBImp[T]) DeleteMany(ctx context.Context, query builder.Query) error { + return db.Repository.DeleteMany(ctx, query) +} + +func (db *DBImp[T]) FindOne(ctx context.Context, query builder.Query, result T) error { + return db.Repository.FindOneByFilter(ctx, query, result) +} + +func (db *DBImp[T]) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) { + return db.Repository.ListIDs(ctx, query) +} + +func (db *DBImp[T]) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { + return db.Repository.ListPermissionBound(ctx, query) +} + +func (db *DBImp[T]) SetDeleter(deleter CascadeDeleterT) { + db.Logger.Debug("Custom cascade deletion method installed") + db.cdeleter = deleter +} + +func (db *DBImp[T]) deleteCascadeStub(_ context.Context, objectRef bson.ObjectID) error { + db.Logger.Warn("Unimplemented cascade delete call", mzap.ObjRef("object_ref", objectRef)) + return merrors.NotImplemented("custom implementation required") +} + +func (db *DBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { + return db.cdeleter(ctx, objectRef) +} + +func Create[T storable.Storable]( + logger mlogger.Logger, + collection mservice.Type, + db *mongo.Database, +) *DBImp[T] { + res := &DBImp[T]{ + Logger: logger.Named(collection), + Repository: repository.CreateMongoRepository(db, collection), + } + res.cdeleter = res.deleteCascadeStub + return res +} diff --git a/backend/pkg/db/template/tseries.go b/backend/pkg/db/template/tseries.go new file mode 100644 index 0000000..304be17 --- /dev/null +++ b/backend/pkg/db/template/tseries.go @@ -0,0 +1,14 @@ +package template + +import ( + "context" + + tspoint "git.sendico.io/tech/sxchnge/bff/pkg/db/tseries/point" +) + +type TimeSeries[T tspoint.TimePoint] interface { + // Insert adds a single point into the series. + Insert(ctx context.Context, point T) error + // InsertMany adds multiple points in one bulk operation. + InsertMany(ctx context.Context, points []T) error +} diff --git a/backend/pkg/db/transaction/context.go b/backend/pkg/db/transaction/context.go new file mode 100644 index 0000000..0b89e5f --- /dev/null +++ b/backend/pkg/db/transaction/context.go @@ -0,0 +1,12 @@ +package transaction + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func ActiveMongoTransaction(ctx context.Context) bool { + session := mongo.SessionFromContext(ctx) + return session != nil && session.TransactionRunning() +} diff --git a/backend/pkg/db/transaction/context_test.go b/backend/pkg/db/transaction/context_test.go new file mode 100644 index 0000000..5f0c7ed --- /dev/null +++ b/backend/pkg/db/transaction/context_test.go @@ -0,0 +1,21 @@ +package transaction + +import ( + "context" + "testing" + + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func TestActiveMongoTransactionRejectsContextWithoutSession(t *testing.T) { + if ActiveMongoTransaction(context.Background()) { + t.Fatal("context without mongo session must not be active") + } +} + +func TestActiveMongoTransactionRejectsSessionWithoutTransaction(t *testing.T) { + ctx := mongo.NewSessionContext(context.Background(), &mongo.Session{}) + if ActiveMongoTransaction(ctx) { + t.Fatal("session without transaction must not be active") + } +} diff --git a/backend/pkg/db/transaction/factory.go b/backend/pkg/db/transaction/factory.go new file mode 100644 index 0000000..d26e313 --- /dev/null +++ b/backend/pkg/db/transaction/factory.go @@ -0,0 +1,5 @@ +package transaction + +type Factory interface { + CreateTransaction() Transaction +} diff --git a/backend/pkg/db/transaction/transaction.go b/backend/pkg/db/transaction/transaction.go new file mode 100644 index 0000000..859977f --- /dev/null +++ b/backend/pkg/db/transaction/transaction.go @@ -0,0 +1,11 @@ +package transaction + +import ( + "context" +) + +type Callback = func(ctx context.Context) (any, error) + +type Transaction interface { + Execute(ctx context.Context, cb Callback) (any, error) +} diff --git a/backend/pkg/db/tseries/point/interface.go b/backend/pkg/db/tseries/point/interface.go new file mode 100644 index 0000000..efb9483 --- /dev/null +++ b/backend/pkg/db/tseries/point/interface.go @@ -0,0 +1,14 @@ +package tspoint + +import ( + "time" +) + +type TimePoint interface { + GetTimeStamp() time.Time +} + +type TimePointWithMeta[T any] interface { + GetTimeStamp() time.Time + GetMeta() T +} diff --git a/backend/pkg/decimal/money.go b/backend/pkg/decimal/money.go new file mode 100644 index 0000000..ee24707 --- /dev/null +++ b/backend/pkg/decimal/money.go @@ -0,0 +1,129 @@ +package decimal + +import ( + "math/big" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +// Money represents a monetary amount with currency and precision handling +type Money struct { + Amount *big.Rat + Currency string + Scale uint32 // decimal places for this currency +} + +// NewMoney creates a new Money value from a decimal string +func NewMoney(amount, currency string, scale uint32) (*Money, error) { + rat, err := RatFromString(amount) + if err != nil { + return nil, err + } + return &Money{ + Amount: rat, + Currency: currency, + Scale: scale, + }, nil +} + +// Add adds two money values (must have same currency) +func (m *Money) Add(other *Money) (*Money, error) { + if m.Currency != other.Currency { + return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) + } + return &Money{ + Amount: AddRat(m.Amount, other.Amount), + Currency: m.Currency, + Scale: m.Scale, + }, nil +} + +// Sub subtracts two money values (must have same currency) +func (m *Money) Sub(other *Money) (*Money, error) { + if m.Currency != other.Currency { + return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) + } + return &Money{ + Amount: SubRat(m.Amount, other.Amount), + Currency: m.Currency, + Scale: m.Scale, + }, nil +} + +// Mul multiplies money by a rational number (for fees, percentages, etc.) +func (m *Money) Mul(factor *big.Rat) *Money { + return &Money{ + Amount: MulRat(m.Amount, factor), + Currency: m.Currency, + Scale: m.Scale, + } +} + +// Div divides money by a rational number +func (m *Money) Div(divisor *big.Rat) (*Money, error) { + result, err := DivRat(m.Amount, divisor) + if err != nil { + return nil, err + } + return &Money{ + Amount: result, + Currency: m.Currency, + Scale: m.Scale, + }, nil +} + +// Negate returns the negative of this money value +func (m *Money) Negate() *Money { + return &Money{ + Amount: NegRat(m.Amount), + Currency: m.Currency, + Scale: m.Scale, + } +} + +// Round rounds the money to its scale using the specified rounding mode +func (m *Money) Round(mode RoundingMode) (*Money, error) { + rounded, err := RoundRatToScale(m.Amount, m.Scale, mode) + if err != nil { + return nil, err + } + return &Money{ + Amount: rounded, + Currency: m.Currency, + Scale: m.Scale, + }, nil +} + +// String returns the formatted money value +func (m *Money) String() string { + return FormatRat(m.Amount, m.Scale) + " " + m.Currency +} + +// StringAmount returns just the formatted amount +func (m *Money) StringAmount() string { + return FormatRat(m.Amount, m.Scale) +} + +// IsZero checks if the money amount is zero +func (m *Money) IsZero() bool { + return IsZero(m.Amount) +} + +// IsPositive checks if the money amount is positive +func (m *Money) IsPositive() bool { + return IsPositive(m.Amount) +} + +// IsNegative checks if the money amount is negative +func (m *Money) IsNegative() bool { + return IsNegative(m.Amount) +} + +// Cmp compares two money values (must have same currency) +// Returns -1 if m < other, 0 if m == other, 1 if m > other +func (m *Money) Cmp(other *Money) (int, error) { + if m.Currency != other.Currency { + return 0, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) + } + return CmpRat(m.Amount, other.Amount), nil +} diff --git a/backend/pkg/decimal/rational.go b/backend/pkg/decimal/rational.go new file mode 100644 index 0000000..81a44f8 --- /dev/null +++ b/backend/pkg/decimal/rational.go @@ -0,0 +1,161 @@ +package decimal + +import ( + "math/big" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +// RatFromString parses a decimal string into a big.Rat +// Supports standard decimal notation like "123.45", "-0.001", etc. +func RatFromString(value string) (*big.Rat, error) { + if strings.TrimSpace(value) == "" { + return nil, merrors.InvalidArgument("decimal: empty value") + } + r := new(big.Rat) + if _, ok := r.SetString(value); !ok { + return nil, merrors.InvalidArgument("decimal: invalid decimal value: " + value) + } + return r, nil +} + +// MulRat multiplies two rational numbers +func MulRat(a, b *big.Rat) *big.Rat { + return new(big.Rat).Mul(a, b) +} + +// DivRat divides two rational numbers +func DivRat(a, b *big.Rat) (*big.Rat, error) { + if b.Sign() == 0 { + return nil, merrors.InvalidArgument("decimal: division by zero") + } + return new(big.Rat).Quo(a, b), nil +} + +// AddRat adds two rational numbers +func AddRat(a, b *big.Rat) *big.Rat { + return new(big.Rat).Add(a, b) +} + +// SubRat subtracts two rational numbers (a - b) +func SubRat(a, b *big.Rat) *big.Rat { + return new(big.Rat).Sub(a, b) +} + +// NegRat negates a rational number +func NegRat(a *big.Rat) *big.Rat { + return new(big.Rat).Neg(a) +} + +// RoundRatToScale rounds a rational number to a specific number of decimal places +// using the specified rounding mode +func RoundRatToScale(value *big.Rat, scale uint32, mode RoundingMode) (*big.Rat, error) { + scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) + numerator := new(big.Int).Mul(new(big.Int).Set(value.Num()), scaleFactor) + denominator := value.Denom() + + quotient := new(big.Int) + remainder := new(big.Int) + quotient.QuoRem(numerator, denominator, remainder) + + // No remainder, already exact + if remainder.Sign() == 0 { + return new(big.Rat).SetFrac(quotient, scaleFactor), nil + } + + sign := quotient.Sign() + absQuotient := new(big.Int).Abs(new(big.Int).Set(quotient)) + absRemainder := new(big.Int).Abs(remainder) + absDenominator := new(big.Int).Abs(denominator) + + doubledRemainder := new(big.Int).Mul(absRemainder, big.NewInt(2)) + cmp := doubledRemainder.Cmp(absDenominator) + + shouldIncrement := false + + switch mode { + case RoundingModeDown: + shouldIncrement = false + case RoundingModeHalfUp: + if cmp >= 0 { + shouldIncrement = true + } + case RoundingModeHalfEven, RoundingModeUnspecified: + if cmp > 0 { + shouldIncrement = true + } else if cmp == 0 { + // Tie: round to even + if absQuotient.Bit(0) == 1 { + shouldIncrement = true + } + } + default: + // Default to HALF_EVEN + if cmp > 0 { + shouldIncrement = true + } else if cmp == 0 { + if absQuotient.Bit(0) == 1 { + shouldIncrement = true + } + } + } + + if shouldIncrement { + if sign < 0 { + absQuotient.Add(absQuotient, big.NewInt(1)) + quotient = absQuotient.Neg(absQuotient) + } else { + absQuotient.Add(absQuotient, big.NewInt(1)) + quotient = absQuotient + } + } + + return new(big.Rat).SetFrac(quotient, scaleFactor), nil +} + +// FormatRat formats a rational number as a decimal string with the specified scale +func FormatRat(r *big.Rat, scale uint32) string { + sign := "" + if r.Sign() < 0 { + sign = "-" + } + + absRat := new(big.Rat).Abs(r) + scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) + numerator := new(big.Int).Mul(absRat.Num(), scaleFactor) + numerator.Quo(numerator, absRat.Denom()) + + intStr := numerator.String() + if scale == 0 { + return sign + intStr + } + + if len(intStr) <= int(scale) { + intStr = strings.Repeat("0", int(scale)-len(intStr)+1) + intStr + } + + pointPos := len(intStr) - int(scale) + return sign + intStr[:pointPos] + "." + intStr[pointPos:] +} + +// CmpRat compares two rational numbers +// Returns -1 if a < b, 0 if a == b, 1 if a > b +func CmpRat(a, b *big.Rat) int { + return a.Cmp(b) +} + +// IsZero checks if a rational number is zero +func IsZero(r *big.Rat) bool { + return r.Sign() == 0 +} + +// IsPositive checks if a rational number is positive +func IsPositive(r *big.Rat) bool { + return r.Sign() > 0 +} + +// IsNegative checks if a rational number is negative +func IsNegative(r *big.Rat) bool { + return r.Sign() < 0 +} diff --git a/backend/pkg/decimal/rounding.go b/backend/pkg/decimal/rounding.go new file mode 100644 index 0000000..65b345d --- /dev/null +++ b/backend/pkg/decimal/rounding.go @@ -0,0 +1,29 @@ +package decimal + +// RoundingMode specifies how to round decimal values +type RoundingMode int + +const ( + // RoundingModeUnspecified defaults to HALF_EVEN + RoundingModeUnspecified RoundingMode = iota + // RoundingModeHalfEven rounds to nearest, ties to even (banker's rounding) + RoundingModeHalfEven + // RoundingModeHalfUp rounds to nearest, ties away from zero + RoundingModeHalfUp + // RoundingModeDown rounds toward zero (truncate) + RoundingModeDown +) + +// String returns the string representation of the rounding mode +func (r RoundingMode) String() string { + switch r { + case RoundingModeHalfEven: + return "HALF_EVEN" + case RoundingModeHalfUp: + return "HALF_UP" + case RoundingModeDown: + return "DOWN" + default: + return "HALF_EVEN" + } +} diff --git a/backend/pkg/domainprovider/domain_provider.go b/backend/pkg/domainprovider/domain_provider.go new file mode 100644 index 0000000..cd92f13 --- /dev/null +++ b/backend/pkg/domainprovider/domain_provider.go @@ -0,0 +1,15 @@ +package domainprovider + +import ( + dimp "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider/imp" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +) + +type DomainProvider interface { + GetFullLink(linkElem ...string) (string, error) + GetAPILink(linkElem ...string) (string, error) +} + +func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) { + return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint) +} diff --git a/backend/pkg/domainprovider/imp/domain_provider.go b/backend/pkg/domainprovider/imp/domain_provider.go new file mode 100644 index 0000000..312485b --- /dev/null +++ b/backend/pkg/domainprovider/imp/domain_provider.go @@ -0,0 +1,35 @@ +package domainproviderimp + +import ( + "net/url" + "os" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.uber.org/zap" +) + +type DomainProviderImp struct { + domain string + apiEndpoint string +} + +func (dp *DomainProviderImp) GetFullLink(linkElem ...string) (string, error) { + return url.JoinPath(dp.domain, linkElem...) +} + +func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) { + return url.JoinPath(dp.apiEndpoint, linkElem...) +} + +func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) { + p := new(DomainProviderImp) + p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain) + var err error + apiPath := os.Getenv(apiPointEnv) + if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil { + logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath)) + return nil, err + } + logger.Named("domain_provider").Info("Domain name provider is up", zap.String("domain", p.domain)) + return p, nil +} diff --git a/backend/pkg/merrors/accounting.go b/backend/pkg/merrors/accounting.go new file mode 100644 index 0000000..e5f34a8 --- /dev/null +++ b/backend/pkg/merrors/accounting.go @@ -0,0 +1,25 @@ +package merrors + +var ErrAccountingInvalidPosting = Define("accounting.invalid_posting") + +var ErrAccountingUnbalancedPosting = Define("accounting.unbalanced_posting") + +var ErrAccountingAssetMismatch = Define("accounting.asset_mismatch") + +var ErrAccountingAccountNotFound = Define("accounting.account_not_found") + +var ErrAccountingAccountInactive = Define("accounting.account_inactive") + +var ErrAccountingInvalidAmount = Define("accounting.invalid_amount") + +var ErrAccountingInvalidIdempotency = Define("accounting.invalid_idempotency") + +var ErrAccountingIdempotencyConflict = Define("accounting.idempotency_conflict") + +var ErrAccountingStorageFailed = Define("accounting.storage_failed") + +var ErrAccountingDuplicateKey = Define("accounting.duplicate_key") + +var ErrAccountingOptimisticConflict = Define("accounting.optimistic_conflict") + +var ErrAccountingInvalidStorageState = Define("accounting.invalid_storage_state") diff --git a/backend/pkg/merrors/aml.go b/backend/pkg/merrors/aml.go new file mode 100644 index 0000000..c544dfc --- /dev/null +++ b/backend/pkg/merrors/aml.go @@ -0,0 +1,20 @@ +package merrors + +import "strings" + +const ReasonAMLSourceIdempotencyConflict = "AML_SOURCE_IDEMPOTENCY_CONFLICT" + +var ErrAMLSourceIdempotencyConflict = Define("aml.source_idempotency_conflict") + +func AMLSourceIdempotencyConflict(msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" { + msg = ErrAMLSourceIdempotencyConflict.Error() + } + return classifiedError{ + base: ErrDataConflict, + code: ErrAMLSourceIdempotencyConflict, + msg: msg, + reason: ReasonAMLSourceIdempotencyConflict, + } +} diff --git a/backend/pkg/merrors/aml_test.go b/backend/pkg/merrors/aml_test.go new file mode 100644 index 0000000..bf03151 --- /dev/null +++ b/backend/pkg/merrors/aml_test.go @@ -0,0 +1,21 @@ +package merrors + +import ( + "errors" + "testing" +) + +func TestAMLSourceIdempotencyConflictReasonRoundTrip(t *testing.T) { + err := AMLSourceIdempotencyConflict("key is bound to another source mutation") + if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrAMLSourceIdempotencyConflict) { + t.Fatalf("AML source idempotency conflict classification mismatch: %v", err) + } + reason, ok := Reason(err) + if !ok || reason != ReasonAMLSourceIdempotencyConflict { + t.Fatalf("AML source idempotency reason mismatch: reason=%q ok=%t", reason, ok) + } + restored, ok := FromReason(reason, err.Error()) + if !ok || !errors.Is(restored, ErrAMLSourceIdempotencyConflict) { + t.Fatalf("AML source idempotency reason did not round trip: %v", restored) + } +} diff --git a/backend/pkg/merrors/chain_execution.go b/backend/pkg/merrors/chain_execution.go new file mode 100644 index 0000000..6dd96e0 --- /dev/null +++ b/backend/pkg/merrors/chain_execution.go @@ -0,0 +1,21 @@ +package merrors + +import "strings" + +const ( + ReasonChainExecutionSigningUnavailable = "chain_execution.signing_unavailable" + ReasonChainExecutionUnsupportedCapability = "chain_execution.unsupported_capability" + ReasonChainExecutionInsufficientBalance = "chain_execution.insufficient_balance" + ReasonChainExecutionInsufficientNativeResources = "chain_execution.insufficient_native_resources" + ReasonChainExecutionDestinationIneligible = "chain_execution.destination_ineligible" +) + +// ChainExecutionPrecondition creates a typed, reason-bearing permanent +// preparation failure. Callers can classify it with both errors.Is and Reason. +func ChainExecutionPrecondition(reason, message string) error { + return classifiedError{ + base: ErrFailedPrecondition, + msg: strings.TrimSpace(message), + reason: strings.TrimSpace(reason), + } +} diff --git a/backend/pkg/merrors/chain_execution_test.go b/backend/pkg/merrors/chain_execution_test.go new file mode 100644 index 0000000..85831a6 --- /dev/null +++ b/backend/pkg/merrors/chain_execution_test.go @@ -0,0 +1,17 @@ +package merrors + +import ( + "errors" + "testing" +) + +func TestChainExecutionPreconditionCarriesClassAndReason(t *testing.T) { + err := ChainExecutionPrecondition(ReasonChainExecutionInsufficientBalance, "source balance is insufficient") + if !errors.Is(err, ErrFailedPrecondition) { + t.Fatal("expected failed-precondition classification") + } + reason, ok := Reason(err) + if !ok || reason != ReasonChainExecutionInsufficientBalance { + t.Fatalf("unexpected reason: %q", reason) + } +} diff --git a/backend/pkg/merrors/classified.go b/backend/pkg/merrors/classified.go new file mode 100644 index 0000000..5690492 --- /dev/null +++ b/backend/pkg/merrors/classified.go @@ -0,0 +1,48 @@ +package merrors + +import "strings" + +type classifiedError struct { + base error + code error + msg string + reason string + publicMessage bool + argumentNames []string +} + +func (e classifiedError) Error() string { + if e.publicMessage { + msg := strings.TrimSpace(e.msg) + if msg != "" { + return msg + } + if e.code != nil { + return e.code.Error() + } + if e.base != nil { + return e.base.Error() + } + return "" + } + return Wrap(e.base, e.msg).Error() +} + +func (e classifiedError) Unwrap() []error { + causes := make([]error, 0, 2) + if e.base != nil { + causes = append(causes, e.base) + } + if e.code != nil { + causes = append(causes, e.code) + } + return causes +} + +func (e classifiedError) Reason() string { + return strings.TrimSpace(e.reason) +} + +func (e classifiedError) InvalidArgumentFields() []string { + return append([]string(nil), e.argumentNames...) +} diff --git a/backend/pkg/merrors/documents.go b/backend/pkg/merrors/documents.go new file mode 100644 index 0000000..f94d13e --- /dev/null +++ b/backend/pkg/merrors/documents.go @@ -0,0 +1,44 @@ +package merrors + +import "strings" + +const ( + ReasonDocumentsReportOwned = "DOCUMENTS_REPORT_OWNED" + ReasonDocumentsQuotationExpired = "DOCUMENTS_QUOTATION_EXPIRED" +) + +var ErrDocumentsReportOwned = Define("documents.report_owned") + +var ErrDocumentsQuotationExpired = Define("documents.quotation_expired") + +// DocumentsReportOwned reports that an active or settled payout owns a +// report, so callers must not mutate its document lifecycle. +func DocumentsReportOwned(msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" { + msg = "report is owned by a payout" + } + return classifiedError{ + base: ErrDataConflict, + code: ErrDocumentsReportOwned, + msg: msg, + reason: ReasonDocumentsReportOwned, + publicMessage: true, + } +} + +// DocumentsQuotationExpired reports that a payout registry still references +// a quotation which can no longer be executed. A fresh quotation is required. +func DocumentsQuotationExpired(msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" { + msg = "the payout quotation has expired; request a new quotation" + } + return classifiedError{ + base: ErrFailedPrecondition, + code: ErrDocumentsQuotationExpired, + msg: msg, + reason: ReasonDocumentsQuotationExpired, + publicMessage: true, + } +} diff --git a/backend/pkg/merrors/documents_test.go b/backend/pkg/merrors/documents_test.go new file mode 100644 index 0000000..6b048c6 --- /dev/null +++ b/backend/pkg/merrors/documents_test.go @@ -0,0 +1,37 @@ +package merrors + +import ( + "errors" + "testing" +) + +func TestDocumentsReportOwnedReasonRoundTrip(t *testing.T) { + err := DocumentsReportOwned("report has an active payout") + if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrDocumentsReportOwned) { + t.Fatalf("DocumentsReportOwned() classification = %v", err) + } + reason, ok := Reason(err) + if !ok || reason != ReasonDocumentsReportOwned { + t.Fatalf("Reason() = %q, %t", reason, ok) + } + restored, ok := FromReason(reason, "report has an active payout") + if !ok || !errors.Is(restored, ErrDataConflict) || !errors.Is(restored, ErrDocumentsReportOwned) { + t.Fatalf("FromReason() = %v, %t", restored, ok) + } +} + +func TestDocumentsQuotationExpiredReasonRoundTrip(t *testing.T) { + err := DocumentsQuotationExpired("quotation expired") + if !errors.Is(err, ErrFailedPrecondition) || !errors.Is(err, ErrDocumentsQuotationExpired) { + t.Fatalf("DocumentsQuotationExpired() classification = %v", err) + } + reason, ok := Reason(err) + if !ok || reason != ReasonDocumentsQuotationExpired { + t.Fatalf("Reason() = %q, %t", reason, ok) + } + restored, ok := FromReason(reason, "quotation expired") + if !ok || !errors.Is(restored, ErrFailedPrecondition) || + !errors.Is(restored, ErrDocumentsQuotationExpired) { + t.Fatalf("FromReason() = %v, %t", restored, ok) + } +} diff --git a/backend/pkg/merrors/errors.go b/backend/pkg/merrors/errors.go new file mode 100644 index 0000000..dceb396 --- /dev/null +++ b/backend/pkg/merrors/errors.go @@ -0,0 +1,268 @@ +package merrors + +import ( + "errors" + "fmt" + "strings" +) + +type definedError string + +func (e definedError) Error() string { + return string(e) +} + +type wrappedError struct { + base error + msg string +} + +// opaqueWrappedError preserves error classification and causal inspection +// without including the cause text in Error. Use it at boundaries where a +// storage or provider error can contain secrets or sensitive identifiers. +type opaqueWrappedError struct { + base error + cause error + msg string +} + +func (e wrappedError) Error() string { + baseText := "" + if e.base != nil { + baseText = strings.TrimSpace(e.base.Error()) + } + msg := strings.TrimSpace(e.msg) + + switch { + case baseText == "": + return msg + case msg == "": + return baseText + default: + return baseText + ": " + msg + } +} + +func (e wrappedError) Unwrap() error { + return e.base +} + +func (e opaqueWrappedError) Error() string { + return Wrap(e.base, e.msg).Error() +} + +func (e opaqueWrappedError) Unwrap() []error { + causes := make([]error, 0, 2) + if e.base != nil { + causes = append(causes, e.base) + } + if e.cause != nil { + causes = append(causes, e.cause) + } + return causes +} + +func Define(msg string) error { + return definedError(msg) +} + +func Message(msg string) error { + return definedError(msg) +} + +func Messagef(format string, args ...any) error { + return Message(fmt.Sprintf(format, args...)) +} + +func Wrap(err error, msg string) error { + return wrappedError{ + base: err, + msg: msg, + } +} + +func Wrapf(err error, format string, args ...any) error { + return Wrap(err, fmt.Sprintf(format, args...)) +} + +// OpaqueWrap adds safe context while retaining the cause for errors.Is and +// errors.As. Unlike Wrap, it deliberately excludes the cause text from Error. +func OpaqueWrap(base, cause error, msg string) error { + return opaqueWrappedError{base: base, cause: cause, msg: msg} +} + +var ErrNotImplemented = Define("notImplemented") + +func NotImplemented(msg string) error { + return Wrap(ErrNotImplemented, msg) +} + +var ErrNoData = Define("noData") + +func NoData(msg string) error { + return Wrap(ErrNoData, msg) +} + +func NoDataf(format string, args ...any) error { + return NoData(fmt.Sprintf(format, args...)) +} + +var ErrInternal = Define("internal") + +func Internal(msg string) error { + return Wrap(ErrInternal, msg) +} + +func Internalf(format string, args ...any) error { + return Internal(fmt.Sprintf(format, args...)) +} + +var ErrInvalidArg = Define("invalidArgError") + +func InvalidArgument(msg string, argumentNames ...string) error { + return Wrap(ErrInvalidArg, invalidArgumentMessage(msg, argumentNames...)) +} + +func InvalidArgumentf(format string, args ...any) error { + return InvalidArgument(fmt.Sprintf(format, args...)) +} + +func InvalidArgumentWithNamesf(argumentNames []string, format string, args ...any) error { + return InvalidArgument(fmt.Sprintf(format, args...), argumentNames...) +} + +var ErrDataConflict = Define("dataConflict") + +func DataConflict(msg string) error { + return Wrap(ErrDataConflict, msg) +} + +func DataConflictf(format string, args ...any) error { + return DataConflict(fmt.Sprintf(format, args...)) +} + +func DataConflictWrap(err error, msg string) error { + return wrapError(ErrDataConflict, msg, err) +} + +func DataConflictWrapf(err error, format string, args ...any) error { + return DataConflictWrap(err, fmt.Sprintf(format, args...)) +} + +var ErrFailedPrecondition = Define("failedPrecondition") + +func FailedPrecondition(msg string) error { + return Wrap(ErrFailedPrecondition, msg) +} + +func FailedPreconditionf(format string, args ...any) error { + return FailedPrecondition(fmt.Sprintf(format, args...)) +} + +func FailedPreconditionWrap(err error, msg string) error { + return wrapError(ErrFailedPrecondition, msg, err) +} + +func FailedPreconditionWrapf(err error, format string, args ...any) error { + return FailedPreconditionWrap(err, fmt.Sprintf(format, args...)) +} + +var ErrServiceUnavailable = Define("serviceUnavailable") + +func ServiceUnavailable(msg string) error { + return Wrap(ErrServiceUnavailable, msg) +} + +func ServiceUnavailablef(format string, args ...any) error { + return ServiceUnavailable(fmt.Sprintf(format, args...)) +} + +func ServiceUnavailableWrap(err error, msg string) error { + return wrapError(ErrServiceUnavailable, msg, err) +} + +func ServiceUnavailableWrapf(err error, format string, args ...any) error { + return ServiceUnavailableWrap(err, fmt.Sprintf(format, args...)) +} + +type objectRef interface { + IsZero() bool + Hex() string +} + +var ErrAccessDenied = Define("accessDenied") + +func PermissionDenied(msg string) error { + return Wrap(ErrAccessDenied, msg) +} + +func AccessDenied(object, action string, ref objectRef) error { + if ref != nil && !ref.IsZero() { + return Wrap(ErrAccessDenied, fmt.Sprintf("cannot %s object of type '%s' with ID '%s'", action, object, ref.Hex())) + } + return Wrap(ErrAccessDenied, fmt.Sprintf("cannot %s object of type '%s'", action, object)) +} + +var ErrInvalidDataType = Define("invalidDataType") + +func InvalidDataType(msg string) error { + return Wrap(ErrInvalidDataType, msg) +} + +func InvalidDataTypef(format string, args ...any) error { + return InvalidDataType(fmt.Sprintf(format, args...)) +} + +var ErrUnauthorized = Define("unathorized") + +func Unauthorized(msg string) error { + return Wrap(ErrUnauthorized, msg) +} + +func Unauthorizedf(format string, args ...any) error { + return Unauthorized(fmt.Sprintf(format, args...)) +} + +var ErrNoMessagingTopic = Define("messagingTopicError") + +func NoMessagingTopic(topic string) error { + return Wrapf(ErrNoMessagingTopic, "messaging topic '%s' not found", topic) +} + +func InvalidArgumentWrap(err error, msg string, argumentNames ...string) error { + return wrapError(ErrInvalidArg, invalidArgumentMessage(msg, argumentNames...), err) +} + +func InvalidArgumentWrapf(err error, format string, args ...any) error { + return InvalidArgumentWrap(err, fmt.Sprintf(format, args...)) +} + +func InternalWrap(err error, msg string) error { + return wrapError(ErrInternal, msg, err) +} + +func InternalWrapf(err error, format string, args ...any) error { + return InternalWrap(err, fmt.Sprintf(format, args...)) +} + +func InternalWrapAll(msg string, errs ...error) error { + return wrapErrors(ErrInternal, msg, errs...) +} + +func wrapError(base error, msg string, err error) error { + return wrapErrors(base, msg, err) +} + +func wrapErrors(base error, msg string, errs ...error) error { + baseErr := Wrap(base, msg) + causes := make([]error, 0, len(errs)) + for _, err := range errs { + if err != nil { + causes = append(causes, err) + } + } + if len(causes) == 0 { + return baseErr + } + return errors.Join(baseErr, errors.Join(causes...)) +} diff --git a/backend/pkg/merrors/errors_test.go b/backend/pkg/merrors/errors_test.go new file mode 100644 index 0000000..4602c7c --- /dev/null +++ b/backend/pkg/merrors/errors_test.go @@ -0,0 +1,125 @@ +package merrors + +import ( + "errors" + "strings" + "testing" +) + +type testReasonedError struct { + error + reason string +} + +func (e testReasonedError) Reason() string { + return e.reason +} + +func TestReasonUsesReasonedError(t *testing.T) { + err := testReasonedError{ + error: InvalidArgument("value is invalid"), + reason: "amount_below_minimum", + } + reason, ok := Reason(err) + if !ok { + t.Fatalf("expected reason") + } + if reason != "amount_below_minimum" { + t.Fatalf("reason mismatch: got=%q", reason) + } +} + +func TestInvalidArgumentSupportsBrokenArgumentName(t *testing.T) { + t.Run("without argument name keeps old behavior", func(t *testing.T) { + err := InvalidArgument("value is missing") + expected := "invalidArgError: value is missing" + if err.Error() != expected { + t.Fatalf("unexpected error message: %s", err) + } + if !errors.Is(err, ErrInvalidArg) { + t.Fatalf("error should wrap ErrInvalidArg") + } + }) + + t.Run("single argument name", func(t *testing.T) { + err := InvalidArgument("value is missing", "bot_token_env") + expected := `invalidArgError: broken argument "bot_token_env": value is missing` + if err.Error() != expected { + t.Fatalf("unexpected error message: %s", err) + } + }) + + t.Run("multiple argument names", func(t *testing.T) { + err := InvalidArgument("value mismatch", "bot_token_env", "chat_id_env", " ") + expected := `invalidArgError: broken arguments "bot_token_env", "chat_id_env": value mismatch` + if err.Error() != expected { + t.Fatalf("unexpected error message: %s", err) + } + }) +} + +func TestInvalidArgumentWithNamesfSupportsBrokenArgumentName(t *testing.T) { + err := InvalidArgumentWithNamesf([]string{"gateway.supported_assets.currency"}, "asset[%d] %s", 2, "is invalid") + expected := `invalidArgError: broken argument "gateway.supported_assets.currency": asset[2] is invalid` + if err.Error() != expected { + t.Fatalf("unexpected error message: %s", err) + } + if !errors.Is(err, ErrInvalidArg) { + t.Fatalf("error should wrap ErrInvalidArg") + } +} + +func TestInvalidArgumentWrapSupportsBrokenArgumentName(t *testing.T) { + base := Define("root cause") + err := InvalidArgumentWrap(base, "value is missing", "bot_token_env") + if !strings.Contains(err.Error(), `invalidArgError: broken argument "bot_token_env": value is missing`) { + t.Fatalf("wrapped error should include broken argument name: %s", err) + } + if !errors.Is(err, ErrInvalidArg) || !errors.Is(err, base) { + t.Fatalf("wrapped error should preserve all error layers") + } +} + +func TestInternalWrapAllPreservesAllCauses(t *testing.T) { + first := Define("first cause") + second := Define("second cause") + err := InternalWrapAll("combined failure", first, nil, second) + if !strings.Contains(err.Error(), "internal: combined failure") { + t.Fatalf("wrapped error should include context: %s", err) + } + if !errors.Is(err, ErrInternal) || !errors.Is(err, first) || !errors.Is(err, second) { + t.Fatalf("wrapped error should preserve all error layers") + } +} + +func TestOpaqueWrapPreservesCauseWithoutExposingItsText(t *testing.T) { + cause := DataConflict("restricted storage key") + err := OpaqueWrap(ErrInternal, cause, "safe persistence failure") + + if got, want := err.Error(), "internal: safe persistence failure"; got != want { + t.Fatalf("opaque error mismatch: got=%q want=%q", got, want) + } + if !errors.Is(err, ErrInternal) || !errors.Is(err, ErrDataConflict) { + t.Fatalf("opaque error did not preserve classifications: %v", err) + } +} + +func TestOperationalWrapsPreserveClassificationAndCause(t *testing.T) { + cause := Define("dependency failure") + tests := []struct { + name string + err error + kind error + }{ + {name: "failed precondition", err: FailedPreconditionWrap(cause, "AA capability unavailable"), kind: ErrFailedPrecondition}, + {name: "service unavailable", err: ServiceUnavailableWrap(cause, "Arbitrum RPC unavailable"), kind: ErrServiceUnavailable}, + {name: "data conflict", err: DataConflictWrap(cause, "managed wallet replay differs"), kind: ErrDataConflict}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !errors.Is(tt.err, tt.kind) || !errors.Is(tt.err, cause) { + t.Fatalf("wrapped error should preserve classification and cause: %s", tt.err) + } + }) + } +} diff --git a/backend/pkg/merrors/invalid_argument.go b/backend/pkg/merrors/invalid_argument.go new file mode 100644 index 0000000..954402a --- /dev/null +++ b/backend/pkg/merrors/invalid_argument.go @@ -0,0 +1,44 @@ +package merrors + +import ( + "fmt" + "strings" +) + +// InvalidArgumentClassified returns an invalid-argument error with a stable +// machine reason and structured field names for transport adapters. +func InvalidArgumentClassified(reason, msg string, argumentNames ...string) error { + return classifiedError{ + base: ErrInvalidArg, + msg: invalidArgumentMessage(msg, argumentNames...), + reason: strings.TrimSpace(reason), + argumentNames: normalizeArgumentNames(argumentNames), + } +} + +func invalidArgumentMessage(msg string, argumentNames ...string) string { + normalizedNames := normalizeArgumentNames(argumentNames) + names := make([]string, 0, len(normalizedNames)) + for _, name := range normalizedNames { + names = append(names, fmt.Sprintf("%q", name)) + } + if len(names) == 0 { + return msg + } + + prefix := "broken argument" + if len(names) > 1 { + prefix = "broken arguments" + } + return fmt.Sprintf("%s %s: %s", prefix, strings.Join(names, ", "), msg) +} + +func normalizeArgumentNames(argumentNames []string) []string { + names := make([]string, 0, len(argumentNames)) + for _, name := range argumentNames { + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + return names +} diff --git a/backend/pkg/merrors/ledger.go b/backend/pkg/merrors/ledger.go new file mode 100644 index 0000000..bfaa6f9 --- /dev/null +++ b/backend/pkg/merrors/ledger.go @@ -0,0 +1,65 @@ +package merrors + +import "strings" + +const ( + ReasonLedgerIdempotencyConflict = "LEDGER_IDEMPOTENCY_CONFLICT" + ReasonLedgerCommandIdentityMismatch = "LEDGER_COMMAND_IDENTITY_MISMATCH" + ReasonLedgerAccessViolation = "LEDGER_ACCESS_VIOLATION" + ReasonAccountNotEligible = "ACCOUNT_NOT_ELIGIBLE" +) + +var ErrLedgerIdempotencyConflict = Define("ledger.idempotency_conflict") + +var ErrLedgerCommandIdentityMismatch = Define("ledger.command_identity_mismatch") + +var ErrLedgerAccessViolation = Define("ledger.access_violation") + +var ErrAccountNotEligible = Define("account_not_eligible") + +func LedgerIdempotencyConflict(msg string) error { + return classifiedError{ + base: ErrDataConflict, + code: ErrLedgerIdempotencyConflict, + msg: ledgerErrorMessage(msg, ErrLedgerIdempotencyConflict), + reason: ReasonLedgerIdempotencyConflict, + } +} + +func LedgerCommandIdentityMismatch(msg string) error { + return classifiedError{ + base: ErrDataConflict, + code: ErrLedgerCommandIdentityMismatch, + msg: ledgerErrorMessage(msg, ErrLedgerCommandIdentityMismatch), + reason: ReasonLedgerCommandIdentityMismatch, + } +} + +func LedgerAccessViolation(msg string) error { + return classifiedError{ + base: ErrFailedPrecondition, + code: ErrLedgerAccessViolation, + msg: ledgerErrorMessage(msg, ErrLedgerAccessViolation), + reason: ReasonLedgerAccessViolation, + } +} + +func AccountNotEligible(msg string) error { + return classifiedError{ + base: ErrFailedPrecondition, + code: ErrAccountNotEligible, + msg: ledgerErrorMessage(msg, ErrAccountNotEligible), + reason: ReasonAccountNotEligible, + } +} + +func ledgerErrorMessage(msg string, fallback error) string { + msg = strings.TrimSpace(msg) + if msg != "" { + return msg + } + if fallback == nil { + return "" + } + return fallback.Error() +} diff --git a/backend/pkg/merrors/ledger_test.go b/backend/pkg/merrors/ledger_test.go new file mode 100644 index 0000000..05287d7 --- /dev/null +++ b/backend/pkg/merrors/ledger_test.go @@ -0,0 +1,48 @@ +package merrors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLedgerReasonRoundTrip(t *testing.T) { + err := LedgerIdempotencyConflict("conflicting command") + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, ReasonLedgerIdempotencyConflict, reason) + + restored, ok := FromReason(reason, "conflicting command") + require.True(t, ok) + require.True(t, errors.Is(restored, ErrLedgerIdempotencyConflict)) + require.True(t, errors.Is(restored, ErrDataConflict)) +} + +func TestAccountNotEligibleReasonRoundTrip(t *testing.T) { + err := AccountNotEligible("account is frozen") + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, ReasonAccountNotEligible, reason) + require.True(t, errors.Is(err, ErrAccountNotEligible)) + require.True(t, errors.Is(err, ErrFailedPrecondition)) + + restored, ok := FromReason(reason, "account is frozen") + require.True(t, ok) + require.True(t, errors.Is(restored, ErrAccountNotEligible)) + require.True(t, errors.Is(restored, ErrFailedPrecondition)) +} + +func TestLedgerAccessViolationReasonRoundTrip(t *testing.T) { + err := LedgerAccessViolation("from_account: account does not belong to organization") + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, ReasonLedgerAccessViolation, reason) + require.True(t, errors.Is(err, ErrLedgerAccessViolation)) + require.True(t, errors.Is(err, ErrFailedPrecondition)) + + restored, ok := FromReason(reason, "from_account: account does not belong to organization") + require.True(t, ok) + require.True(t, errors.Is(restored, ErrLedgerAccessViolation)) + require.True(t, errors.Is(restored, ErrFailedPrecondition)) +} diff --git a/backend/pkg/merrors/limits.go b/backend/pkg/merrors/limits.go new file mode 100644 index 0000000..8e16a8f --- /dev/null +++ b/backend/pkg/merrors/limits.go @@ -0,0 +1,19 @@ +package merrors + +var ErrLimitsRuntimeInvalidRecord = Define("limits.runtime.invalid_record") + +var ErrLimitsRuntimeCASConflict = Define("limits.runtime.cas_conflict") + +var ErrLimitsRuntimeDuplicateStateKey = Define("limits.runtime.duplicate_state_key") + +var ErrLimitsRuntimeDuplicateStateRef = Define("limits.runtime.duplicate_state_ref") + +var ErrLimitsRuntimeDuplicateReservationRef = Define("limits.runtime.duplicate_reservation_ref") + +var ErrLimitsRuntimeDuplicateReservationOperation = Define("limits.runtime.duplicate_reservation_operation") + +var ErrLimitsRuntimeIdentityConflict = Define("limits.runtime.identity_conflict") + +var ErrLimitsRuntimeDataConflict = Define("limits.runtime.data_conflict") + +var ErrLimitsRuntimeRepositoryRequired = Define("limits.runtime.repository_required") diff --git a/backend/pkg/merrors/organization.go b/backend/pkg/merrors/organization.go new file mode 100644 index 0000000..591ada7 --- /dev/null +++ b/backend/pkg/merrors/organization.go @@ -0,0 +1,66 @@ +package merrors + +import "strings" + +// Reasons for organization provisioning outcomes. They travel to callers in +// gRPC error details so every outcome stays distinguishable regardless of the +// status code it shares with others. +const ( + ReasonOrganizationProvisioningInProgress = "organization.provisioning_in_progress" + ReasonOrganizationAlreadyActive = "organization.already_active" + ReasonOrganizationProvisioningBlocked = "organization.provisioning_blocked" + ReasonOrganizationOwnerMismatch = "organization.owner_mismatch" + ReasonOrganizationNotOperatorProvisioned = "organization.not_operator_provisioned" +) + +// ErrOrganizationProvisioningInProgress means another attempt currently holds +// the provisioning lease of the organization. +var ErrOrganizationProvisioningInProgress = Define(ReasonOrganizationProvisioningInProgress) + +// ErrOrganizationAlreadyActive means the organization finished provisioning already. +var ErrOrganizationAlreadyActive = Define(ReasonOrganizationAlreadyActive) + +// ErrOrganizationProvisioningBlocked means a failed signup compensated the +// organization and it requires manual recovery before it can be activated. +var ErrOrganizationProvisioningBlocked = Define(ReasonOrganizationProvisioningBlocked) + +// ErrOrganizationOwnerMismatch means the organization is being provisioned for +// a different account than the one supplied. +var ErrOrganizationOwnerMismatch = Define(ReasonOrganizationOwnerMismatch) + +// ErrOrganizationNotOperatorProvisioned means the organization was created through signup: +// organizations created through general signup are never finished by someone in backoffice. +var ErrOrganizationNotOperatorProvisioned = Define(ReasonOrganizationNotOperatorProvisioned) + +func OrganizationProvisioningInProgress(msg string) error { + return organizationError(ErrOrganizationProvisioningInProgress, ReasonOrganizationProvisioningInProgress, msg) +} + +func OrganizationAlreadyActive(msg string) error { + return organizationError(ErrOrganizationAlreadyActive, ReasonOrganizationAlreadyActive, msg) +} + +func OrganizationProvisioningBlocked(msg string) error { + return organizationError(ErrOrganizationProvisioningBlocked, ReasonOrganizationProvisioningBlocked, msg) +} + +func OrganizationOwnerMismatch(msg string) error { + return organizationError(ErrOrganizationOwnerMismatch, ReasonOrganizationOwnerMismatch, msg) +} + +func OrganizationNotOperatorProvisioned(msg string) error { + return organizationError(ErrOrganizationNotOperatorProvisioned, ReasonOrganizationNotOperatorProvisioned, msg) +} + +func organizationError(code error, reason, msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" && code != nil { + msg = code.Error() + } + return classifiedError{ + base: ErrDataConflict, + code: code, + msg: msg, + reason: reason, + } +} diff --git a/backend/pkg/merrors/paymaster_control.go b/backend/pkg/merrors/paymaster_control.go new file mode 100644 index 0000000..db12bec --- /dev/null +++ b/backend/pkg/merrors/paymaster_control.go @@ -0,0 +1,9 @@ +package merrors + +var ErrPaymasterPaused = Define("paymaster_paused") + +var ErrControlWalletInsufficientNativeBalance = Define("control_wallet_insufficient_native_balance") + +var ErrPaymasterControlDurableWalletFailure = Define("paymaster_control_durable_wallet_failure") + +var ErrPaymasterControlKeyActivityMismatch = Define("paymaster_control_key_activity_mismatch") diff --git a/backend/pkg/merrors/payment_execution.go b/backend/pkg/merrors/payment_execution.go new file mode 100644 index 0000000..86b0b6a --- /dev/null +++ b/backend/pkg/merrors/payment_execution.go @@ -0,0 +1,47 @@ +package merrors + +import "strings" + +const ReasonPaymentExecutionNotAccepted = "PAYMENT_EXECUTION_NOT_ACCEPTED" + +var ErrPaymentExecutionNotAccepted = Define("payment.execution_not_accepted") + +type paymentExecutionNotAcceptedError struct { + classifiedError + cause error +} + +func (e paymentExecutionNotAcceptedError) Unwrap() []error { + causes := e.classifiedError.Unwrap() + if e.cause != nil { + causes = append(causes, e.cause) + } + return causes +} + +// PaymentExecutionNotAccepted reports that the orchestrator proved an +// execution request did not create or reuse any payment. Callers may release +// request-side reservations only for this outcome; ordinary transport and +// internal errors have an ambiguous ownership result. +func PaymentExecutionNotAccepted(msg string) error { + return PaymentExecutionNotAcceptedWrap(nil, msg) +} + +// PaymentExecutionNotAcceptedWrap adds the definitive rejection +// classification while retaining the underlying cause for diagnostics. +func PaymentExecutionNotAcceptedWrap(cause error, msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" { + msg = "payment execution was not accepted before a payment was created" + } + return paymentExecutionNotAcceptedError{ + classifiedError: classifiedError{ + base: ErrFailedPrecondition, + code: ErrPaymentExecutionNotAccepted, + msg: msg, + reason: ReasonPaymentExecutionNotAccepted, + publicMessage: true, + }, + cause: cause, + } +} diff --git a/backend/pkg/merrors/payment_execution_test.go b/backend/pkg/merrors/payment_execution_test.go new file mode 100644 index 0000000..82c5ae9 --- /dev/null +++ b/backend/pkg/merrors/payment_execution_test.go @@ -0,0 +1,26 @@ +package merrors + +import ( + "errors" + "testing" +) + +func TestPaymentExecutionNotAcceptedPreservesClassificationAndCause(t *testing.T) { + cause := ServiceUnavailable("orchestrator unavailable") + err := PaymentExecutionNotAcceptedWrap(cause, "execution was rejected") + + if !errors.Is(err, ErrFailedPrecondition) || + !errors.Is(err, ErrPaymentExecutionNotAccepted) || + !errors.Is(err, cause) { + t.Fatalf("PaymentExecutionNotAcceptedWrap() classification = %v", err) + } + reason, ok := Reason(err) + if !ok || reason != ReasonPaymentExecutionNotAccepted { + t.Fatalf("PaymentExecutionNotAcceptedWrap() reason = %q, %t", reason, ok) + } + restored, ok := FromReason(reason, err.Error()) + if !ok || !errors.Is(restored, ErrPaymentExecutionNotAccepted) || + !errors.Is(restored, ErrFailedPrecondition) { + t.Fatalf("FromReason() = %v, %t", restored, ok) + } +} diff --git a/backend/pkg/merrors/quotation.go b/backend/pkg/merrors/quotation.go new file mode 100644 index 0000000..b65c158 --- /dev/null +++ b/backend/pkg/merrors/quotation.go @@ -0,0 +1,32 @@ +package merrors + +const ( + QuotationFeeConfigurationMissingCode = "quotation_fee_configuration_missing" + QuotationFeeConfigurationMissingDetails = "quotation fee configuration is missing" + QuotationReplayMissingCode = "quotation_replay_missing" + QuotationReplayMissingDetails = "the expected payment quotation is no longer available" +) + +var ErrQuotationFeeConfigurationMissing = Define(QuotationFeeConfigurationMissingCode) + +var ErrQuotationReplayMissing = Define(QuotationReplayMissingCode) + +func QuotationFeeConfigurationMissing() error { + return classifiedError{ + base: ErrFailedPrecondition, + code: ErrQuotationFeeConfigurationMissing, + msg: QuotationFeeConfigurationMissingDetails, + reason: QuotationFeeConfigurationMissingCode, + publicMessage: true, + } +} + +func QuotationReplayMissing() error { + return classifiedError{ + base: ErrNoData, + code: ErrQuotationReplayMissing, + msg: QuotationReplayMissingDetails, + reason: QuotationReplayMissingCode, + publicMessage: true, + } +} diff --git a/backend/pkg/merrors/quotation_test.go b/backend/pkg/merrors/quotation_test.go new file mode 100644 index 0000000..584af1e --- /dev/null +++ b/backend/pkg/merrors/quotation_test.go @@ -0,0 +1,44 @@ +package merrors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQuotationFeeConfigurationMissingReasonRoundTrip(t *testing.T) { + err := QuotationFeeConfigurationMissing() + + require.True(t, errors.Is(err, ErrFailedPrecondition)) + require.True(t, errors.Is(err, ErrQuotationFeeConfigurationMissing)) + + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, QuotationFeeConfigurationMissingCode, reason) + require.Equal(t, QuotationFeeConfigurationMissingDetails, err.Error()) + + restored, ok := FromReason(reason, "ignored message") + require.True(t, ok) + require.True(t, errors.Is(restored, ErrFailedPrecondition)) + require.True(t, errors.Is(restored, ErrQuotationFeeConfigurationMissing)) + require.Equal(t, QuotationFeeConfigurationMissingDetails, restored.Error()) +} + +func TestQuotationReplayMissingReasonRoundTrip(t *testing.T) { + err := QuotationReplayMissing() + + require.True(t, errors.Is(err, ErrNoData)) + require.True(t, errors.Is(err, ErrQuotationReplayMissing)) + + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, QuotationReplayMissingCode, reason) + require.Equal(t, QuotationReplayMissingDetails, err.Error()) + + restored, ok := FromReason(reason, "ignored message") + require.True(t, ok) + require.True(t, errors.Is(restored, ErrNoData)) + require.True(t, errors.Is(restored, ErrQuotationReplayMissing)) + require.Equal(t, QuotationReplayMissingDetails, restored.Error()) +} diff --git a/backend/pkg/merrors/reason.go b/backend/pkg/merrors/reason.go new file mode 100644 index 0000000..9205bac --- /dev/null +++ b/backend/pkg/merrors/reason.go @@ -0,0 +1,105 @@ +package merrors + +import ( + "errors" + "strings" +) + +func Reason(err error) (string, bool) { + var reasoned interface { + Reason() string + } + if errors.As(err, &reasoned) { + reason := strings.TrimSpace(reasoned.Reason()) + if reason != "" { + return reason, true + } + } + switch { + case errors.Is(err, ErrAMLSourceIdempotencyConflict): + return ReasonAMLSourceIdempotencyConflict, true + case errors.Is(err, ErrLedgerIdempotencyConflict): + return ReasonLedgerIdempotencyConflict, true + case errors.Is(err, ErrLedgerCommandIdentityMismatch): + return ReasonLedgerCommandIdentityMismatch, true + case errors.Is(err, ErrLedgerAccessViolation): + return ReasonLedgerAccessViolation, true + case errors.Is(err, ErrAccountNotEligible): + return ReasonAccountNotEligible, true + case errors.Is(err, ErrQuotationFeeConfigurationMissing): + return QuotationFeeConfigurationMissingCode, true + case errors.Is(err, ErrQuotationReplayMissing): + return QuotationReplayMissingCode, true + default: + return "", false + } +} + +func FromReason(reason string, msg string) (error, bool) { + switch strings.TrimSpace(reason) { + case ReasonRouteNotFound: + return RouteNotFound(msg), true + case ReasonRouteAmbiguous: + return RouteAmbiguous(msg), true + case ReasonRouteDisabled: + return RouteDisabled(msg), true + case ReasonRouteInvalidSource: + return RouteInvalidSource(msg), true + case ReasonRouteDestinationNotFound: + return RouteDestinationNotFound(msg), true + case ReasonRouteDestinationInactive: + return RouteDestinationInactive(msg), true + case ReasonRouteDestinationCurrencyMismatch: + return RouteDestinationCurrencyMismatch(msg), true + case ReasonRouteDestinationOrgMismatch: + return RouteDestinationOrgMismatch(msg), true + case ReasonRouteDestinationAmbiguous: + return RouteDestinationAmbiguous(msg), true + case ReasonRouteDestinationOwnerMismatch: + return RouteDestinationOwnerMismatch(msg), true + case ReasonRouteActiveSourceConflict: + return RouteActiveSourceConflict(msg), true + case ReasonRouteDestinationTypeMismatch: + return RouteDestinationTypeMismatch(msg), true + case ReasonRouteDestinationRoleMismatch: + return RouteDestinationRoleMismatch(msg), true + case ReasonRouteIdempotencyConflict: + return RouteIdempotencyConflict(msg), true + case ReasonRouteVersionConflict: + return RouteVersionConflict(msg), true + case ReasonRouteInvalidState: + return RouteInvalidState(msg), true + case ReasonAMLSourceIdempotencyConflict: + return AMLSourceIdempotencyConflict(msg), true + case ReasonLedgerIdempotencyConflict: + return LedgerIdempotencyConflict(msg), true + case ReasonLedgerCommandIdentityMismatch: + return LedgerCommandIdentityMismatch(msg), true + case ReasonLedgerAccessViolation: + return LedgerAccessViolation(msg), true + case ReasonAccountNotEligible: + return AccountNotEligible(msg), true + case ReasonDocumentsReportOwned: + return DocumentsReportOwned(msg), true + case ReasonDocumentsQuotationExpired: + return DocumentsQuotationExpired(msg), true + case ReasonPaymentExecutionNotAccepted: + return PaymentExecutionNotAccepted(msg), true + case QuotationFeeConfigurationMissingCode: + return QuotationFeeConfigurationMissing(), true + case QuotationReplayMissingCode: + return QuotationReplayMissing(), true + case ReasonOrganizationProvisioningInProgress: + return OrganizationProvisioningInProgress(msg), true + case ReasonOrganizationAlreadyActive: + return OrganizationAlreadyActive(msg), true + case ReasonOrganizationProvisioningBlocked: + return OrganizationProvisioningBlocked(msg), true + case ReasonOrganizationOwnerMismatch: + return OrganizationOwnerMismatch(msg), true + case ReasonOrganizationNotOperatorProvisioned: + return OrganizationNotOperatorProvisioned(msg), true + default: + return nil, false + } +} diff --git a/backend/pkg/merrors/routes.go b/backend/pkg/merrors/routes.go new file mode 100644 index 0000000..546018c --- /dev/null +++ b/backend/pkg/merrors/routes.go @@ -0,0 +1,127 @@ +package merrors + +import "strings" + +const routeErrorCodePrefix = "ROUTE_ERROR_CODE_" + +type routeMetadataError struct { + classifiedError + routeCode string +} + +func (e routeMetadataError) ErrorInfoMetadata() map[string]string { + return map[string]string{"route_error_code": e.routeCode} +} + +const ( + ReasonRouteNotFound = "ROUTE_NOT_FOUND" + ReasonRouteAmbiguous = "ROUTE_AMBIGUOUS" + ReasonRouteDisabled = "ROUTE_DISABLED" + ReasonRouteInvalidSource = "ROUTE_INVALID_SOURCE" + ReasonRouteDestinationNotFound = "ROUTE_DESTINATION_NOT_FOUND" + ReasonRouteDestinationInactive = "ROUTE_DESTINATION_INACTIVE" + ReasonRouteDestinationCurrencyMismatch = "ROUTE_DESTINATION_CURRENCY_MISMATCH" + ReasonRouteDestinationOrgMismatch = "ROUTE_DESTINATION_ORG_MISMATCH" + ReasonRouteDestinationAmbiguous = "ROUTE_DESTINATION_AMBIGUOUS" + ReasonRouteDestinationOwnerMismatch = "ROUTE_DESTINATION_OWNER_MISMATCH" + ReasonRouteActiveSourceConflict = "ROUTE_ACTIVE_SOURCE_CONFLICT" + ReasonRouteDestinationTypeMismatch = "ROUTE_DESTINATION_TYPE_MISMATCH" + ReasonRouteDestinationRoleMismatch = "ROUTE_DESTINATION_ROLE_MISMATCH" + ReasonRouteIdempotencyConflict = "ROUTE_IDEMPOTENCY_CONFLICT" + ReasonRouteVersionConflict = "ROUTE_VERSION_CONFLICT" + ReasonRouteInvalidState = "ROUTE_INVALID_STATE" +) + +var ( + ErrRouteNotFound = Define("route.not_found") + ErrRouteAmbiguous = Define("route.ambiguous") + ErrRouteDisabled = Define("route.disabled") + ErrRouteInvalidSource = Define("route.invalid_source") + ErrRouteDestinationNotFound = Define("route.destination_not_found") + ErrRouteDestinationInactive = Define("route.destination_inactive") + ErrRouteDestinationCurrencyMismatch = Define("route.destination_currency_mismatch") + ErrRouteDestinationOrgMismatch = Define("route.destination_org_mismatch") + ErrRouteDestinationAmbiguous = Define("route.destination_ambiguous") + ErrRouteDestinationOwnerMismatch = Define("route.destination_owner_mismatch") + ErrRouteActiveSourceConflict = Define("route.active_source_conflict") + ErrRouteDestinationTypeMismatch = Define("route.destination_type_mismatch") + ErrRouteDestinationRoleMismatch = Define("route.destination_role_mismatch") + ErrRouteIdempotencyConflict = Define("route.idempotency_conflict") + ErrRouteVersionConflict = Define("route.version_conflict") + ErrRouteInvalidState = Define("route.invalid_state") +) + +func RouteNotFound(msg string) error { + return newRouteClassifiedError(ErrNoData, ErrRouteNotFound, ReasonRouteNotFound, msg) +} + +func RouteAmbiguous(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteAmbiguous, ReasonRouteAmbiguous, msg) +} + +func RouteDisabled(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDisabled, ReasonRouteDisabled, msg) +} + +func RouteInvalidSource(msg string) error { + return newRouteClassifiedError(ErrInvalidArg, ErrRouteInvalidSource, ReasonRouteInvalidSource, msg) +} + +func RouteDestinationNotFound(msg string) error { + return newRouteClassifiedError(ErrNoData, ErrRouteDestinationNotFound, ReasonRouteDestinationNotFound, msg) +} + +func RouteDestinationInactive(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationInactive, ReasonRouteDestinationInactive, msg) +} + +func RouteDestinationCurrencyMismatch(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationCurrencyMismatch, ReasonRouteDestinationCurrencyMismatch, msg) +} + +func RouteDestinationOrgMismatch(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOrgMismatch, ReasonRouteDestinationOrgMismatch, msg) +} + +func RouteDestinationAmbiguous(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationAmbiguous, ReasonRouteDestinationAmbiguous, msg) +} + +func RouteDestinationOwnerMismatch(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOwnerMismatch, ReasonRouteDestinationOwnerMismatch, msg) +} + +func RouteActiveSourceConflict(msg string) error { + return newRouteClassifiedError(ErrDataConflict, ErrRouteActiveSourceConflict, ReasonRouteActiveSourceConflict, msg) +} + +func RouteDestinationTypeMismatch(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationTypeMismatch, ReasonRouteDestinationTypeMismatch, msg) +} + +func RouteDestinationRoleMismatch(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationRoleMismatch, ReasonRouteDestinationRoleMismatch, msg) +} + +func RouteIdempotencyConflict(msg string) error { + return newRouteClassifiedError(ErrDataConflict, ErrRouteIdempotencyConflict, ReasonRouteIdempotencyConflict, msg) +} + +func RouteVersionConflict(msg string) error { + return newRouteClassifiedError(ErrDataConflict, ErrRouteVersionConflict, ReasonRouteVersionConflict, msg) +} + +func RouteInvalidState(msg string) error { + return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteInvalidState, ReasonRouteInvalidState, msg) +} + +func newRouteClassifiedError(base, code error, reason, msg string) error { + msg = strings.TrimSpace(msg) + if msg == "" && code != nil { + msg = code.Error() + } + return routeMetadataError{ + classifiedError: classifiedError{base: base, code: code, msg: msg, reason: reason, publicMessage: true}, + routeCode: routeErrorCodePrefix + reason, + } +} diff --git a/backend/pkg/merrors/routes_test.go b/backend/pkg/merrors/routes_test.go new file mode 100644 index 0000000..40f2fbd --- /dev/null +++ b/backend/pkg/merrors/routes_test.go @@ -0,0 +1,52 @@ +package merrors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRouteReasonsRoundTrip(t *testing.T) { + tests := []struct { + name string + create func(string) error + code error + base error + reason string + }{ + {name: "not found", create: RouteNotFound, code: ErrRouteNotFound, base: ErrNoData, reason: ReasonRouteNotFound}, + {name: "ambiguous", create: RouteAmbiguous, code: ErrRouteAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteAmbiguous}, + {name: "disabled", create: RouteDisabled, code: ErrRouteDisabled, base: ErrFailedPrecondition, reason: ReasonRouteDisabled}, + {name: "invalid source", create: RouteInvalidSource, code: ErrRouteInvalidSource, base: ErrInvalidArg, reason: ReasonRouteInvalidSource}, + {name: "destination not found", create: RouteDestinationNotFound, code: ErrRouteDestinationNotFound, base: ErrNoData, reason: ReasonRouteDestinationNotFound}, + {name: "destination inactive", create: RouteDestinationInactive, code: ErrRouteDestinationInactive, base: ErrFailedPrecondition, reason: ReasonRouteDestinationInactive}, + {name: "destination currency mismatch", create: RouteDestinationCurrencyMismatch, code: ErrRouteDestinationCurrencyMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationCurrencyMismatch}, + {name: "destination organization mismatch", create: RouteDestinationOrgMismatch, code: ErrRouteDestinationOrgMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOrgMismatch}, + {name: "destination ambiguous", create: RouteDestinationAmbiguous, code: ErrRouteDestinationAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteDestinationAmbiguous}, + {name: "destination owner mismatch", create: RouteDestinationOwnerMismatch, code: ErrRouteDestinationOwnerMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOwnerMismatch}, + {name: "active source conflict", create: RouteActiveSourceConflict, code: ErrRouteActiveSourceConflict, base: ErrDataConflict, reason: ReasonRouteActiveSourceConflict}, + {name: "destination type mismatch", create: RouteDestinationTypeMismatch, code: ErrRouteDestinationTypeMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationTypeMismatch}, + {name: "destination role mismatch", create: RouteDestinationRoleMismatch, code: ErrRouteDestinationRoleMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationRoleMismatch}, + {name: "idempotency conflict", create: RouteIdempotencyConflict, code: ErrRouteIdempotencyConflict, base: ErrDataConflict, reason: ReasonRouteIdempotencyConflict}, + {name: "version conflict", create: RouteVersionConflict, code: ErrRouteVersionConflict, base: ErrDataConflict, reason: ReasonRouteVersionConflict}, + {name: "invalid state", create: RouteInvalidState, code: ErrRouteInvalidState, base: ErrFailedPrecondition, reason: ReasonRouteInvalidState}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.create("route error") + require.Equal(t, "route error", err.Error()) + reason, ok := Reason(err) + require.True(t, ok) + require.Equal(t, tt.reason, reason) + require.True(t, errors.Is(err, tt.code)) + require.True(t, errors.Is(err, tt.base)) + + restored, ok := FromReason(reason, "route error") + require.True(t, ok) + require.True(t, errors.Is(restored, tt.code)) + require.True(t, errors.Is(restored, tt.base)) + }) + } +} diff --git a/backend/pkg/merrors/signup_email_policy.go b/backend/pkg/merrors/signup_email_policy.go new file mode 100644 index 0000000..7fdc705 --- /dev/null +++ b/backend/pkg/merrors/signup_email_policy.go @@ -0,0 +1,78 @@ +package merrors + +import ( + "errors" + "strings" +) + +const ( + SignupEmailPolicyCode = "signup_email_rejected" + + SignupEmailPolicyReasonInvalidEmail = "invalid_email" + SignupEmailPolicyReasonDisposableDomain = "disposable_domain" + SignupEmailPolicyReasonFreeDomain = "free_domain" +) + +var ErrSignupEmailPolicyRejected = Define(SignupEmailPolicyCode) + +type SignupEmailPolicyError struct { + reason string + domain string +} + +func NormalizeSignupEmailPolicyReason(reason string) string { + switch strings.TrimSpace(reason) { + case SignupEmailPolicyReasonInvalidEmail: + return SignupEmailPolicyReasonInvalidEmail + case SignupEmailPolicyReasonDisposableDomain: + return SignupEmailPolicyReasonDisposableDomain + case SignupEmailPolicyReasonFreeDomain: + return SignupEmailPolicyReasonFreeDomain + default: + return SignupEmailPolicyReasonInvalidEmail + } +} + +func IsSignupEmailPolicyReason(reason string) bool { + return strings.TrimSpace(reason) == NormalizeSignupEmailPolicyReason(reason) +} + +func (e *SignupEmailPolicyError) Error() string { + if e == nil || e.reason == "" { + return SignupEmailPolicyCode + } + return SignupEmailPolicyCode + ": " + e.reason +} + +func (e *SignupEmailPolicyError) Unwrap() error { + return ErrSignupEmailPolicyRejected +} + +func (e *SignupEmailPolicyError) Reason() string { + if e == nil { + return "" + } + return strings.TrimSpace(e.reason) +} + +func (e *SignupEmailPolicyError) Domain() string { + if e == nil { + return "" + } + return strings.TrimSpace(e.domain) +} + +func SignupEmailPolicyRejected(reason, domain string) error { + return &SignupEmailPolicyError{ + reason: NormalizeSignupEmailPolicyReason(reason), + domain: strings.TrimSpace(domain), + } +} + +func AsSignupEmailPolicyError(err error) (*SignupEmailPolicyError, bool) { + var policyErr *SignupEmailPolicyError + if errors.As(err, &policyErr) && policyErr != nil { + return policyErr, true + } + return nil, false +} diff --git a/backend/pkg/merrors/signup_email_policy_test.go b/backend/pkg/merrors/signup_email_policy_test.go new file mode 100644 index 0000000..40b44ba --- /dev/null +++ b/backend/pkg/merrors/signup_email_policy_test.go @@ -0,0 +1,52 @@ +package merrors + +import ( + "errors" + "testing" +) + +func TestSignupEmailPolicyError(t *testing.T) { + err := SignupEmailPolicyRejected(SignupEmailPolicyReasonFreeDomain, "gmail.com") + policyErr, ok := AsSignupEmailPolicyError(err) + if !ok { + t.Fatalf("expected signup email policy error") + } + if policyErr.Reason() != SignupEmailPolicyReasonFreeDomain { + t.Fatalf("reason mismatch: got=%q", policyErr.Reason()) + } + if policyErr.Domain() != "gmail.com" { + t.Fatalf("domain mismatch: got=%q", policyErr.Domain()) + } + if !errors.Is(err, ErrSignupEmailPolicyRejected) { + t.Fatalf("expected signup email policy sentinel") + } + + reason, ok := Reason(err) + if !ok { + t.Fatalf("expected generic reason") + } + if reason != SignupEmailPolicyReasonFreeDomain { + t.Fatalf("generic reason mismatch: got=%q", reason) + } +} + +func TestSignupEmailPolicyReasonNormalization(t *testing.T) { + for _, reason := range []string{ + SignupEmailPolicyReasonInvalidEmail, + SignupEmailPolicyReasonDisposableDomain, + SignupEmailPolicyReasonFreeDomain, + } { + if !IsSignupEmailPolicyReason(reason) { + t.Fatalf("expected supported reason: %q", reason) + } + } + + err := SignupEmailPolicyRejected("unexpected_reason", "") + policyErr, ok := AsSignupEmailPolicyError(err) + if !ok { + t.Fatalf("expected signup email policy error") + } + if policyErr.Reason() != SignupEmailPolicyReasonInvalidEmail { + t.Fatalf("unexpected reasons must not leak: got=%q", policyErr.Reason()) + } +} diff --git a/backend/pkg/merrors/throttle.go b/backend/pkg/merrors/throttle.go new file mode 100644 index 0000000..3ff3da6 --- /dev/null +++ b/backend/pkg/merrors/throttle.go @@ -0,0 +1,50 @@ +package merrors + +import ( + "errors" + "strings" + "time" +) + +// ThrottleDetails describes a temporary request limit without coupling callers +// to a transport-specific response format. +type ThrottleDetails struct { + ScopeKind string + RetryAfter time.Duration +} + +type throttleDetailsError struct { + base error + msg string + details ThrottleDetails +} + +func (e throttleDetailsError) Error() string { + return Wrap(e.base, e.msg).Error() +} + +func (e throttleDetailsError) Unwrap() error { + return e.base +} + +// WithThrottle annotates an error with retry information while preserving its +// classification for errors.Is and errors.As. +func WithThrottle(base error, scopeKind string, retryAfter time.Duration, msg string) error { + return throttleDetailsError{ + base: base, + msg: msg, + details: ThrottleDetails{ + ScopeKind: strings.TrimSpace(scopeKind), + RetryAfter: retryAfter, + }, + } +} + +// ThrottleDetailsFrom returns retry information attached by WithThrottle. +func ThrottleDetailsFrom(err error) (ThrottleDetails, bool) { + var throttled throttleDetailsError + if !errors.As(err, &throttled) { + return ThrottleDetails{}, false + } + return throttled.details, true +} diff --git a/backend/pkg/merrors/throttle_test.go b/backend/pkg/merrors/throttle_test.go new file mode 100644 index 0000000..10014a3 --- /dev/null +++ b/backend/pkg/merrors/throttle_test.go @@ -0,0 +1,35 @@ +package merrors + +import ( + "errors" + "testing" + "time" +) + +func TestWithThrottlePreservesClassificationAndDetails(t *testing.T) { + base := Define("verificationLimited") + err := Wrap( + WithThrottle(base, " destination ", 45*time.Second, "request is temporarily limited"), + "verification failed", + ) + + if !errors.Is(err, base) { + t.Fatalf("throttle error did not preserve classification: %v", err) + } + details, ok := ThrottleDetailsFrom(err) + if !ok { + t.Fatal("throttle details were not available through wrapping") + } + if details.ScopeKind != "destination" || details.RetryAfter != 45*time.Second { + t.Fatalf("unexpected throttle details: %+v", details) + } + if got, want := err.Error(), "verificationLimited: request is temporarily limited: verification failed"; got != want { + t.Fatalf("unexpected throttle error text: got=%q want=%q", got, want) + } +} + +func TestThrottleDetailsFromRejectsUnannotatedError(t *testing.T) { + if details, ok := ThrottleDetailsFrom(ServiceUnavailable("dependency unavailable")); ok { + t.Fatalf("unexpected throttle details: %+v", details) + } +} diff --git a/backend/pkg/merrors/treasury.go b/backend/pkg/merrors/treasury.go new file mode 100644 index 0000000..d4c4d53 --- /dev/null +++ b/backend/pkg/merrors/treasury.go @@ -0,0 +1,15 @@ +package merrors + +var ErrTreasuryInvalidArgument = Define("treasury.monitoring.invalid_argument") + +var ErrTreasuryConfiguration = Define("treasury.monitoring.configuration_error") + +var ErrTreasuryNotFound = Define("treasury.monitoring.not_found") + +var ErrTreasuryOutOfOrder = Define("treasury.monitoring.out_of_order") + +var ErrTreasuryNegativeBalance = Define("treasury.monitoring.negative_balance") + +var ErrTreasuryAlreadyReversed = Define("treasury.monitoring.movement_already_reversed") + +var ErrTreasuryProjectionMismatch = Define("treasury.monitoring.projection_mismatch") diff --git a/backend/pkg/mlogger/factory/mlogger.go b/backend/pkg/mlogger/factory/mlogger.go new file mode 100644 index 0000000..0f8a709 --- /dev/null +++ b/backend/pkg/mlogger/factory/mlogger.go @@ -0,0 +1,10 @@ +package mlogger + +import ( + lt "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + li "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/internal/mlogger" +) + +func NewLogger(debug bool) lt.Logger { + return li.NewLoggerImp(debug) +} diff --git a/backend/pkg/mlogger/internal/mlogger/mlogger.go b/backend/pkg/mlogger/internal/mlogger/mlogger.go new file mode 100644 index 0000000..2c0f1cb --- /dev/null +++ b/backend/pkg/mlogger/internal/mlogger/mlogger.go @@ -0,0 +1,23 @@ +package mlogger + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "github.com/mattn/go-colorable" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func NewLoggerImp(debug bool) mlogger.Logger { + if debug { + dc := zap.NewDevelopmentEncoderConfig() + dc.EncodeLevel = zapcore.CapitalColorLevelEncoder + logger := zap.New(zapcore.NewCore( + zapcore.NewConsoleEncoder(dc), + zapcore.AddSync(colorable.NewColorableStdout()), + zapcore.DebugLevel, + )) + return logger + } + + return zap.Must(zap.NewProduction()) +} diff --git a/backend/pkg/mlogger/logger.go b/backend/pkg/mlogger/logger.go new file mode 100644 index 0000000..71172f8 --- /dev/null +++ b/backend/pkg/mlogger/logger.go @@ -0,0 +1,119 @@ +package mlogger + +import ( + "fmt" + "strings" + + "go.uber.org/zap" +) + +type Logger = *zap.Logger + +// Named appends logger name components while avoiding adjacent or already-present +// name stutter such as "notification.notification" or "vault.vault.kv". +func Named(logger Logger, names ...any) Logger { + if logger == nil { + logger = zap.NewNop() + } + + current := splitName(logger.Name()) + for _, name := range names { + if name == nil { + continue + } + parts := splitName(fmt.Sprint(name)) + if len(parts) == 0 { + continue + } + if containsParts(current, parts) { + continue + } + + parts = trimOverlappingPrefix(current, parts) + parts = trimStutteringPrefix(current, parts) + if len(parts) == 0 { + continue + } + + child := strings.Join(parts, ".") + logger = logger.Named(child) + current = append(current, parts...) + } + + return logger +} + +func splitName(name string) []string { + raw := strings.Split(name, ".") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + part = strings.TrimSpace(part) + if part == "" { + continue + } + parts = append(parts, part) + } + return parts +} + +func containsParts(parent, child []string) bool { + if len(parent) == 0 || len(child) == 0 || len(child) > len(parent) { + return false + } + for i := 0; i <= len(parent)-len(child); i++ { + matches := true + for j := range child { + if parent[i+j] != child[j] { + matches = false + break + } + } + if matches { + return true + } + } + return false +} + +func trimOverlappingPrefix(parent, child []string) []string { + max := len(parent) + if len(child) < max { + max = len(child) + } + for n := max; n > 0; n-- { + matches := true + for i := 0; i < n; i++ { + if parent[len(parent)-n+i] != child[i] { + matches = false + break + } + } + if matches { + return child[n:] + } + } + return child +} + +func trimStutteringPrefix(parent, child []string) []string { + if len(parent) == 0 || len(child) == 0 { + return child + } + + last := parent[len(parent)-1] + first := child[0] + for _, separator := range []string{"_", "-"} { + prefix := last + separator + if !strings.HasPrefix(first, prefix) { + continue + } + trimmed := strings.TrimSpace(strings.TrimPrefix(first, prefix)) + if trimmed == "" { + return child[1:] + } + out := append([]string{trimmed}, child[1:]...) + return out + } + + return child +} diff --git a/backend/pkg/mlogger/logger_test.go b/backend/pkg/mlogger/logger_test.go new file mode 100644 index 0000000..9c7b9e8 --- /dev/null +++ b/backend/pkg/mlogger/logger_test.go @@ -0,0 +1,71 @@ +package mlogger + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestNamedAvoidsLoggerNameStutter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base string + add []any + want string + }{ + { + name: "skips duplicate service name", + base: "notification", + add: []any{"notification"}, + want: "notification", + }, + { + name: "overlaps dotted component", + base: "vault", + add: []any{"vault.kv"}, + want: "vault.kv", + }, + { + name: "drops child prefix stutter", + base: "server.client", + add: []any{"client_v2"}, + want: "server.client.v2", + }, + { + name: "keeps already present module name", + base: "payments.quotation.server", + add: []any{"payments.quotation"}, + want: "payments.quotation.server", + }, + { + name: "extends partial overlap", + base: "payments", + add: []any{"payments.quotation"}, + want: "payments.quotation", + }, + { + name: "keeps unrelated names", + base: "gateway.server", + add: []any{"chain", "storage.mongo"}, + want: "gateway.server.chain.storage.mongo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + observed, logs := observer.New(zap.InfoLevel) + logger := Named(zap.New(observed).Named(tt.base), tt.add...) + logger.Info("test") + + entries := logs.All() + require.Len(t, entries, 1) + require.Equal(t, tt.want, entries[0].LoggerName) + }) + } +} diff --git a/backend/pkg/model/account.go b/backend/pkg/model/account.go new file mode 100644 index 0000000..e4ab43e --- /dev/null +++ b/backend/pkg/model/account.go @@ -0,0 +1,110 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/crypto/bcrypt" +) + +type Filter int + +type AccountStatus string + +const ( + AccountPendingVerification AccountStatus = "pending_verification" + AccountActive AccountStatus = "active" + AccountBlocked AccountStatus = "blocked" +) + +type AccountBase struct { + storable.Base `bson:",inline" json:",inline"` + ArchivableBase `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + LastName string `bson:"lastName" json:"lastName"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` + Metadata *AccountMetadata `bson:"metadata,omitempty" json:"-"` + + // INN is the account owner's taxpayer identifier. It is the canonical source + // of the INN for personal (account-owned) payouts, flowing into + // customer.tax_identifier. + INN string `bson:"inn,omitempty" json:"inn,omitempty"` +} + +func (*AccountBase) Collection() string { + return mservice.Accounts +} + +type AccountPublic struct { + AccountBase `bson:",inline" json:",inline"` + UserDataBase `bson:",inline" json:",inline"` +} + +type Account struct { + AccountPublic `bson:",inline" json:",inline"` + Password string `bson:"password" json:"-"` // password hash + Status AccountStatus `bson:"status" json:"-"` +} + +func (a *Account) Copy() *Account { + if a == nil { + return nil + } + copied := *a + copied.Metadata = a.Metadata.Copy() + return &copied +} + +func (a *Account) IsActive() bool { + return a.Status == AccountActive +} + +func (a *Account) HashPassword() error { + key, err := bcrypt.GenerateFromPassword([]byte(a.Password), bcrypt.DefaultCost) + if err == nil { + a.Password = string(key) + } + + return err +} + +func (a *Account) MatchPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password)) + + return err == nil +} + +func AnonymousUserName(orgRef bson.ObjectID) string { + return "anonymous@" + orgRef.Hex() +} + +func AccountIsAnonymous(account *UserDataBase, orgRef bson.ObjectID) bool { + if account == nil { + return false + } + return AnonymousUserName(orgRef) == account.Login +} + +type AccountBound interface { + GetAccountRef() bson.ObjectID +} + +type AccountBoundStorable interface { + storable.Storable + OrganizationBound + GetAccountRef() *bson.ObjectID +} + +const ( + AccountRefField = "accountRef" +) + +type AccountBoundBase struct { + storable.Base `bson:",inline" json:",inline"` + OrganizationBoundBase `bson:",inline" json:",inline"` + AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"` +} + +func (a *AccountBoundBase) GetAccountRef() *bson.ObjectID { + return a.AccountRef +} diff --git a/backend/pkg/model/account_metadata.go b/backend/pkg/model/account_metadata.go new file mode 100644 index 0000000..5f0e82c --- /dev/null +++ b/backend/pkg/model/account_metadata.go @@ -0,0 +1,45 @@ +package model + +import "time" + +type AccountMetadata struct { + PersonalLedgerProvisioning *LedgerProvisioningMetadata `bson:"personalLedgerProvisioning,omitempty" json:"-"` +} + +type LedgerProvisioningMetadata struct { + Source string `bson:"source,omitempty" json:"-"` + InvitationRef string `bson:"invitationRef,omitempty" json:"-"` + TargetCurrencies []string `bson:"targetCurrencies,omitempty" json:"-"` + Created []string `bson:"created,omitempty" json:"-"` + AlreadyExisting []string `bson:"alreadyExisting,omitempty" json:"-"` + Failed []LedgerProvisioningFailure `bson:"failed,omitempty" json:"-"` + Outcome string `bson:"outcome,omitempty" json:"-"` + LastErrorClass string `bson:"lastErrorClass,omitempty" json:"-"` + LastAttemptAt time.Time `bson:"lastAttemptAt,omitempty" json:"-"` +} + +type LedgerProvisioningFailure struct { + Currency string `bson:"currency,omitempty" json:"-"` + ErrorClass string `bson:"errorClass,omitempty" json:"-"` +} + +func (m *AccountMetadata) Copy() *AccountMetadata { + if m == nil { + return nil + } + copied := *m + copied.PersonalLedgerProvisioning = m.PersonalLedgerProvisioning.Copy() + return &copied +} + +func (m *LedgerProvisioningMetadata) Copy() *LedgerProvisioningMetadata { + if m == nil { + return nil + } + copied := *m + copied.TargetCurrencies = append([]string(nil), m.TargetCurrencies...) + copied.Created = append([]string(nil), m.Created...) + copied.AlreadyExisting = append([]string(nil), m.AlreadyExisting...) + copied.Failed = append([]LedgerProvisioningFailure(nil), m.Failed...) + return &copied +} diff --git a/backend/pkg/model/account_role/account_role.go b/backend/pkg/model/account_role/account_role.go new file mode 100644 index 0000000..02de143 --- /dev/null +++ b/backend/pkg/model/account_role/account_role.go @@ -0,0 +1,57 @@ +package account_role + +import ( + "strings" +) + +type AccountRole string + +const ( + AccountRoleOperating AccountRole = "operating" + AccountRoleHold AccountRole = "hold" + AccountRoleTransit AccountRole = "transit" + AccountRoleSettlement AccountRole = "settlement" + AccountRoleClearing AccountRole = "clearing" + AccountRolePending AccountRole = "pending" + AccountRoleReserve AccountRole = "reserve" + AccountRoleLiquidity AccountRole = "liquidity" + AccountRoleFee AccountRole = "fee" + AccountRoleChargeback AccountRole = "chargeback" + AccountRoleAdjustment AccountRole = "adjustment" +) + +const ( + MetadataKeyFromRole = "from_role" + MetadataKeyToRole = "to_role" +) + +func Parse(value string) (AccountRole, bool) { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ACCOUNT_ROLE_OPERATING", "OPERATING": + return AccountRoleOperating, true + case "ACCOUNT_ROLE_HOLD", "HOLD": + return AccountRoleHold, true + case "ACCOUNT_ROLE_TRANSIT", "TRANSIT": + return AccountRoleTransit, true + case "ACCOUNT_ROLE_SETTLEMENT", "SETTLEMENT": + return AccountRoleSettlement, true + case "ACCOUNT_ROLE_CLEARING", "CLEARING": + return AccountRoleClearing, true + case "ACCOUNT_ROLE_PENDING", "PENDING": + return AccountRolePending, true + case "ACCOUNT_ROLE_RESERVE", "RESERVE": + return AccountRoleReserve, true + case "ACCOUNT_ROLE_LIQUIDITY", "LIQUIDITY": + return AccountRoleLiquidity, true + case "ACCOUNT_ROLE_FEE", "FEE": + return AccountRoleFee, true + case "ACCOUNT_ROLE_CHARGEBACK", "CHARGEBACK": + return AccountRoleChargeback, true + case "ACCOUNT_ROLE_ADJUSTMENT", "ADJUSTMENT": + return AccountRoleAdjustment, true + case "ACCOUNT_ROLE_UNSPECIFIED", "UNSPECIFIED", "": + return "", true + default: + return "", false + } +} diff --git a/backend/pkg/model/account_test.go b/backend/pkg/model/account_test.go new file mode 100644 index 0000000..0aff70c --- /dev/null +++ b/backend/pkg/model/account_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "testing" + "time" +) + +func TestAccountCopyDeepCopiesMetadata(t *testing.T) { + account := &Account{ + AccountPublic: AccountPublic{ + AccountBase: AccountBase{ + Metadata: &AccountMetadata{ + PersonalLedgerProvisioning: &LedgerProvisioningMetadata{ + Source: "invitation", + InvitationRef: "invitation-ref", + TargetCurrencies: []string{"RUB", "USDT"}, + Created: []string{"RUB"}, + AlreadyExisting: []string{"USDT"}, + Failed: []LedgerProvisioningFailure{{Currency: "EUR", ErrorClass: "timeout"}}, + Outcome: "partial_failure", + LastErrorClass: "timeout", + LastAttemptAt: time.Now().UTC(), + }, + }, + }, + UserDataBase: UserDataBase{Login: "invitee@example.com"}, + }, + Password: "secret", + Status: AccountActive, + } + + copied := account.Copy() + copied.Metadata.PersonalLedgerProvisioning.TargetCurrencies[0] = "EUR" + copied.Metadata.PersonalLedgerProvisioning.Created[0] = "EUR" + copied.Metadata.PersonalLedgerProvisioning.AlreadyExisting[0] = "EUR" + copied.Metadata.PersonalLedgerProvisioning.Failed[0].ErrorClass = "ledger_unavailable" + + metadata := account.Metadata.PersonalLedgerProvisioning + if metadata.TargetCurrencies[0] != "RUB" { + t.Fatalf("expected original target currency to remain RUB, got %q", metadata.TargetCurrencies[0]) + } + if metadata.Created[0] != "RUB" { + t.Fatalf("expected original created currency to remain RUB, got %q", metadata.Created[0]) + } + if metadata.AlreadyExisting[0] != "USDT" { + t.Fatalf("expected original existing currency to remain USDT, got %q", metadata.AlreadyExisting[0]) + } + if metadata.Failed[0].ErrorClass != "timeout" { + t.Fatalf("expected original failure class to remain timeout, got %q", metadata.Failed[0].ErrorClass) + } +} diff --git a/backend/pkg/model/archivable.go b/backend/pkg/model/archivable.go new file mode 100644 index 0000000..9b51c66 --- /dev/null +++ b/backend/pkg/model/archivable.go @@ -0,0 +1,18 @@ +package model + +type Archivable interface { + IsArchived() bool + SetArchived(archived bool) +} + +type ArchivableBase struct { + Archived bool `bson:"isArchived" json:"isArchived"` +} + +func (a *ArchivableBase) IsArchived() bool { + return a.Archived +} + +func (a *ArchivableBase) SetArchived(archived bool) { + a.Archived = archived +} diff --git a/backend/pkg/model/auth.go b/backend/pkg/model/auth.go new file mode 100644 index 0000000..650d568 --- /dev/null +++ b/backend/pkg/model/auth.go @@ -0,0 +1,84 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Action represents a permissible action on a resource. +type Action string + +// Common actions for resources. +const ( + ActionCreate Action = "create" // Create a resource + ActionRead Action = "read" // Read or view a resource + ActionUpdate Action = "update" // Update or modify a resource + ActionDelete Action = "delete" // Delete a resource +) + +// Effect determines whether an action is allowed or denied. +type Effect string + +const ( + EffectAllow Effect = "allow" // Permit the action + EffectDeny Effect = "deny" // Deny the action +) + +// RoleDescription provides metadata about a role. +type RoleDescription struct { + storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents + Describable `bson:",inline" json:",inline"` // Name and description fields + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the role +} + +// Collection specifies the MongoDB collection for RoleDescription. +func (*RoleDescription) Collection() string { + return mservice.Roles +} + +// Role represents a role assignment for an account within an organization. +type Role struct { + AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned to the role + DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the role's description + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization where the role is applicable +} + +// ActionEffect represents a combination of an action and its effect (allow/deny). +type ActionEffect struct { + Action Action `bson:"action" json:"action"` // The action to perform (e.g., read, write) + Effect Effect `bson:"effect" json:"effect"` // Whether the action is allowed or denied +} + +// Policy defines access control rules for a role within an organization. +type Policy struct { + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the policy + DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the policy's metadata + ObjectRef *bson.ObjectID `bson:"objectRef,omitempty" json:"objectRef,omitempty"` // Target object (NilObjectID for all objects) + Effect ActionEffect `bson:"effect" json:"effect"` // Action and effect for the policy +} + +// RolePolicy defines access control rules for a role within an organization. +type RolePolicy struct { + Policy `bson:",inline" json:",inline"` + RoleDescriptionRef bson.ObjectID `bson:"roleDescriptionRef" json:"roleDescriptionRef"` // Reference to the associated role +} + +// PolicyDescription provides metadata for policies. +type PolicyDescription struct { + storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents + Describable `bson:",inline" json:",inline"` // Name and description fields + ResourceTypes *[]mservice.Type `bson:"resourceTypes,omitempty" json:"resourceTypes,omitempty"` // nil for custom policies, non-nil for built-in permissisons + OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // nil for built-in policies, non-nil for custom +} + +// Collection specifies the MongoDB collection for PolicyDescription. +func (*PolicyDescription) Collection() string { + return mservice.Policies +} + +// Permission ties a policy to a specific account. +type Permission struct { + RolePolicy `bson:",inline" json:",inline"` // Embedded policy definition + AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned the permission +} diff --git a/backend/pkg/model/card.go b/backend/pkg/model/card.go new file mode 100644 index 0000000..3d7a196 --- /dev/null +++ b/backend/pkg/model/card.go @@ -0,0 +1,169 @@ +package model + +import ( + "fmt" + "math" + "strconv" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type CardPaymentData struct { + Pan string `bson:"pan" json:"pan"` + FirstName string `bson:"firstName" json:"firstName"` + LastName string `bson:"lastName" json:"lastName"` + ExpMonth *int `bson:"exp_month" json:"exp_month"` + ExpYear *int `bson:"exp_year" json:"exp_year"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` +} + +func (d *CardPaymentData) UnmarshalBSON(data []byte) error { + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + return err + } + d.Pan = paymentDataString(raw["pan"]) + d.FirstName = firstPaymentDataString(raw, "firstName", "first_name") + d.LastName = firstPaymentDataString(raw, "lastName", "last_name") + d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") + d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") + d.Network = paymentDataString(raw["network"]) + d.Country = paymentDataString(raw["country"]) + return nil +} + +func (m *PaymentMethod) AsCard() (*CardPaymentData, error) { + if m.Type != PaymentTypeCard { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not card", m.Type), "type") + } + var d CardPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + return &d, nil +} + +func firstPaymentDataString(raw map[string]any, keys ...string) string { + for _, key := range keys { + if value := paymentDataString(raw[key]); value != "" { + return value + } + } + return "" +} + +// paymentDataDocument reads a nested payment data document as a map, whatever +// document shape the BSON decoder produced: a generic decode yields bson.D, +// while a typed or hand-built payload yields a map. +func paymentDataDocument(value any) map[string]any { + switch document := value.(type) { + case map[string]any: + return document + case bson.M: + return document + case bson.D: + out := make(map[string]any, len(document)) + for _, element := range document { + out[element.Key] = element.Value + } + return out + default: + return nil + } +} + +func firstPaymentDataInt(raw map[string]any, keys ...string) *int { + for _, key := range keys { + if value := paymentDataInt(raw[key]); value != nil { + return value + } + } + return nil +} + +func paymentDataInt(value any) *int { + switch v := value.(type) { + case nil: + return nil + case int: + return &v + case int32: + out := int(v) + return &out + case int64: + out := int(v) + return &out + case uint: + out := int(v) + return &out + case uint32: + out := int(v) + return &out + case uint64: + return paymentDataIntFromUint64(v) + case float32: + out := int(v) + return &out + case float64: + out := int(v) + return &out + case string: + clean := strings.TrimSpace(v) + if clean == "" { + return nil + } + parsed, err := strconv.Atoi(clean) + if err != nil { + return nil + } + return &parsed + default: + clean := strings.TrimSpace(fmt.Sprint(v)) + if clean == "" { + return nil + } + parsed, err := strconv.Atoi(clean) + if err != nil { + return nil + } + return &parsed + } +} + +func paymentDataIntFromUint64(value uint64) *int { + if value > uint64(math.MaxInt) { + return nil + } + out := int(value) + return &out +} + +func paymentDataString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case int: + return fmt.Sprint(v) + case int32: + return fmt.Sprint(v) + case int64: + return fmt.Sprint(v) + case uint: + return fmt.Sprint(v) + case uint32: + return fmt.Sprint(v) + case uint64: + return fmt.Sprint(v) + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} diff --git a/backend/pkg/model/card_test.go b/backend/pkg/model/card_test.go new file mode 100644 index 0000000..2ca8c0a --- /dev/null +++ b/backend/pkg/model/card_test.go @@ -0,0 +1,68 @@ +package model + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestPaymentMethodAsCardReadsStoredSnakeCaseExpiry(t *testing.T) { + data, err := bson.Marshal(bson.M{ + "pan": "2200001100000001", + "firstName": "S", + "lastName": "S", + "exp_month": 12.0, + "exp_year": 2031.0, + "country": nil, + }) + if err != nil { + t.Fatalf("marshal card data: %v", err) + } + + card, err := (&PaymentMethod{ + Type: PaymentTypeCard, + Data: data, + }).AsCard() + if err != nil { + t.Fatalf("AsCard returned error: %v", err) + } + + if got, want := card.ExpMonth, 12; got == nil || *got != want { + t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) + } + if got, want := card.ExpYear, 2031; got == nil || *got != want { + t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) + } +} + +func TestCardPaymentDataMarshalUsesStoredSnakeCaseExpiry(t *testing.T) { + expMonth := 12 + expYear := 2031 + raw, err := bson.Marshal(CardPaymentData{ + Pan: "2200001100000001", + FirstName: "S", + LastName: "S", + ExpMonth: &expMonth, + ExpYear: &expYear, + }) + if err != nil { + t.Fatalf("marshal card data: %v", err) + } + + var data bson.M + if err := bson.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal card data: %v", err) + } + if got, want := paymentDataInt(data["exp_month"]), 12; got == nil || *got != want { + t.Fatalf("stored exp_month mismatch: got=%v want=%d", got, want) + } + if got, want := paymentDataInt(data["exp_year"]), 2031; got == nil || *got != want { + t.Fatalf("stored exp_year mismatch: got=%v want=%d", got, want) + } + if _, ok := data["expMonth"]; ok { + t.Fatalf("card data must not store camelCase expMonth: %#v", data) + } + if _, ok := data["expYear"]; ok { + t.Fatalf("card data must not store camelCase expYear: %#v", data) + } +} diff --git a/backend/pkg/model/chains.go b/backend/pkg/model/chains.go new file mode 100644 index 0000000..b0e8b27 --- /dev/null +++ b/backend/pkg/model/chains.go @@ -0,0 +1,97 @@ +package model + +import "strings" + +// ChainNetwork identifies a blockchain network for routing and discovery. +type ChainNetwork string + +const ( + ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one" + ChainNetworkArbitrumSepolia ChainNetwork = "arbitrum_sepolia" + ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet" + ChainNetworkTronMainnet ChainNetwork = "tron_mainnet" + ChainNetworkTronNile ChainNetwork = "tron_nile" + ChainNetworkUnspecified ChainNetwork = "" +) + +// AllChainNetworks returns all valid chain networks. +func AllChainNetworks() []ChainNetwork { + return []ChainNetwork{ + ChainNetworkArbitrumOne, + ChainNetworkArbitrumSepolia, + ChainNetworkEthereumMainnet, + ChainNetworkTronMainnet, + ChainNetworkTronNile, + } +} + +// ParseChainNetwork parses a string into a ChainNetwork. +// Returns the network and true if valid, or empty and false if invalid. +func ParseChainNetwork(value string) (ChainNetwork, bool) { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "arbitrum_one", "arbitrum", "arb": + return ChainNetworkArbitrumOne, true + case "arbitrum_sepolia": + return ChainNetworkArbitrumSepolia, true + case "ethereum_mainnet", "ethereum", "eth": + return ChainNetworkEthereumMainnet, true + case "tron_mainnet", "tron": + return ChainNetworkTronMainnet, true + case "tron_nile", "nile": + return ChainNetworkTronNile, true + case "unspecified", "": + return ChainNetworkUnspecified, true + default: + return ChainNetworkUnspecified, false + } +} + +// String returns the string representation of the network. +func (n ChainNetwork) String() string { + return string(n) +} + +// IsValid returns true if the network is a known valid network. +func (n ChainNetwork) IsValid() bool { + switch n { + case ChainNetworkArbitrumOne, + ChainNetworkArbitrumSepolia, + ChainNetworkEthereumMainnet, + ChainNetworkTronMainnet, + ChainNetworkTronNile: + return true + default: + return false + } +} + +// IsTron returns true if the network is a TRON network. +func (n ChainNetwork) IsTron() bool { + switch n { + case ChainNetworkTronMainnet, ChainNetworkTronNile: + return true + default: + return false + } +} + +// IsEVM returns true if the network is an EVM-compatible network. +func (n ChainNetwork) IsEVM() bool { + switch n { + case ChainNetworkArbitrumOne, ChainNetworkArbitrumSepolia, ChainNetworkEthereumMainnet: + return true + default: + return false + } +} + +// IsTestnet returns true if the network is a testnet. +func (n ChainNetwork) IsTestnet() bool { + switch n { + case ChainNetworkTronNile, ChainNetworkArbitrumSepolia: + return true + default: + } + return false +} diff --git a/backend/pkg/model/chains_test.go b/backend/pkg/model/chains_test.go new file mode 100644 index 0000000..84c4a9b --- /dev/null +++ b/backend/pkg/model/chains_test.go @@ -0,0 +1,33 @@ +package model + +import "testing" + +func TestChainNetworkArbitrumSepoliaSupport(t *testing.T) { + network, ok := ParseChainNetwork("arbitrum_sepolia") + if !ok { + t.Fatal("ParseChainNetwork(arbitrum_sepolia) returned ok=false") + } + if network != ChainNetworkArbitrumSepolia { + t.Fatalf("ParseChainNetwork(arbitrum_sepolia) = %q, want %q", network, ChainNetworkArbitrumSepolia) + } + if !network.IsValid() { + t.Fatal("ChainNetworkArbitrumSepolia should be valid") + } + if !network.IsEVM() { + t.Fatal("ChainNetworkArbitrumSepolia should be EVM") + } + if !network.IsTestnet() { + t.Fatal("ChainNetworkArbitrumSepolia should be testnet") + } + + found := false + for _, candidate := range AllChainNetworks() { + if candidate == ChainNetworkArbitrumSepolia { + found = true + break + } + } + if !found { + t.Fatal("AllChainNetworks() is missing ChainNetworkArbitrumSepolia") + } +} diff --git a/backend/pkg/model/chainwalletroute.go b/backend/pkg/model/chainwalletroute.go new file mode 100644 index 0000000..27d84d9 --- /dev/null +++ b/backend/pkg/model/chainwalletroute.go @@ -0,0 +1,29 @@ +package model + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +// ChainWalletRoute stores authoritative wallet-to-gateway routing metadata. +type ChainWalletRoute struct { + storable.Base `bson:",inline" json:",inline"` + + OrganizationRef string `bson:"organizationRef" json:"organizationRef"` + WalletRef string `bson:"walletRef" json:"walletRef"` + Network string `bson:"network" json:"network"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` +} + +func (*ChainWalletRoute) Collection() string { + return mservice.WalletRoutes +} + +func (r *ChainWalletRoute) Normalize() { + r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) + r.WalletRef = strings.TrimSpace(r.WalletRef) + r.Network = strings.ToLower(strings.TrimSpace(r.Network)) + r.GatewayID = strings.TrimSpace(r.GatewayID) +} diff --git a/backend/pkg/model/client.go b/backend/pkg/model/client.go new file mode 100644 index 0000000..dd43e40 --- /dev/null +++ b/backend/pkg/model/client.go @@ -0,0 +1,25 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Client struct { + storable.Base `bson:",inline" json:",inline"` + ClientID string `bson:"clientId"` + ClientName string `bson:"clientName"` + ClientSecret string `bson:"clientSecret,omitempty"` + AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"` + AllowedScopes []string `bson:"allowedScopes"` + RedirectURIs []string `bson:"redirectURIs"` + GrantTypes []string `bson:"grantTypes"` + TokenEndpointAuthMethod string `bson:"tokenEndpointAuthMethod"` + AccountRef *bson.ObjectID `bson:"accountRef,omitempty"` // owner reference + IsRevoked bool `bson:"isRevoked"` +} + +func (*Client) Collection() string { + return mservice.Clients +} diff --git a/backend/pkg/model/cryptoaddress.go b/backend/pkg/model/cryptoaddress.go new file mode 100644 index 0000000..41c1c42 --- /dev/null +++ b/backend/pkg/model/cryptoaddress.go @@ -0,0 +1,127 @@ +package model + +import ( + "encoding/json" + "fmt" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type CryptoAddressPaymentData struct { + Currency Currency `bson:"currency" json:"currency"` + Address string `bson:"address" json:"address"` + Network string `bson:"network" json:"network"` + DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"` +} + +type cryptoAddressAssetJSON struct { + Chain string `json:"chain,omitempty"` + TokenSymbol string `json:"token_symbol,omitempty"` + ContractAddress string `json:"contract_address,omitempty"` +} + +func (d CryptoAddressPaymentData) MarshalJSON() ([]byte, error) { + var payload struct { + Asset *cryptoAddressAssetJSON `json:"asset,omitempty"` + Address string `json:"address"` + Memo *string `json:"memo,omitempty"` + } + payload.Address = strings.TrimSpace(d.Address) + if strings.TrimSpace(d.Network) != "" || strings.TrimSpace(string(d.Currency)) != "" { + payload.Asset = &cryptoAddressAssetJSON{ + Chain: strings.TrimSpace(d.Network), + TokenSymbol: strings.TrimSpace(string(d.Currency)), + } + } + if memo := strings.TrimSpace(strPtr(d.DestinationTag)); memo != "" { + payload.Memo = &memo + } + return json.Marshal(payload) +} + +func (d *CryptoAddressPaymentData) UnmarshalJSON(data []byte) error { + var raw struct { + Asset *cryptoAddressAssetJSON `json:"asset"` + Address string `json:"address"` + Memo string `json:"memo"` + Currency string `json:"currency"` + Network string `json:"network"` + DestinationTag *string `json:"destinationTag"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + currency := strings.TrimSpace(raw.Currency) + network := strings.TrimSpace(raw.Network) + if raw.Asset != nil { + if assetCurrency := strings.TrimSpace(raw.Asset.TokenSymbol); assetCurrency != "" { + currency = assetCurrency + } + if assetNetwork := strings.TrimSpace(raw.Asset.Chain); assetNetwork != "" { + network = assetNetwork + } + } + + d.Currency = Currency(strings.ToUpper(currency)) + d.Address = strings.TrimSpace(raw.Address) + d.Network = strings.ToUpper(network) + d.DestinationTag = clonePaymentDataStringPtr(raw.Memo) + if d.DestinationTag == nil && raw.DestinationTag != nil { + d.DestinationTag = clonePaymentDataStringPtr(*raw.DestinationTag) + } + return nil +} + +func (d *CryptoAddressPaymentData) UnmarshalBSON(data []byte) error { + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + return err + } + currency := firstPaymentDataString(raw, "currency") + network := firstPaymentDataString(raw, "network") + if asset := paymentDataDocument(raw["asset"]); asset != nil { + if assetCurrency := firstPaymentDataString(asset, "token_symbol", "tokenSymbol"); assetCurrency != "" { + currency = assetCurrency + } + if assetNetwork := firstPaymentDataString(asset, "chain"); assetNetwork != "" { + network = assetNetwork + } + } + + d.Currency = Currency(strings.ToUpper(strings.TrimSpace(currency))) + d.Address = firstPaymentDataString(raw, "address") + d.Network = strings.ToUpper(strings.TrimSpace(network)) + d.DestinationTag = clonePaymentDataStringPtr(firstPaymentDataString(raw, "destinationTag", "destination_tag", "memo")) + return nil +} + +func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) { + if m.Type != PaymentTypeCryptoAddress { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cryptoAddress", m.Type), "type") + } + + var d CryptoAddressPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + + return &d, nil +} + +func clonePaymentDataStringPtr(value string) *string { + out := strings.TrimSpace(value) + if out == "" { + return nil + } + return &out +} + +func strPtr(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/pkg/model/cryptoaddress_test.go b/backend/pkg/model/cryptoaddress_test.go new file mode 100644 index 0000000..71601d1 --- /dev/null +++ b/backend/pkg/model/cryptoaddress_test.go @@ -0,0 +1,84 @@ +package model + +import ( + "encoding/json" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestCryptoAddressPaymentDataReadsAssetFromGenericDocument(t *testing.T) { + // The intermediate BSON document produced by PATCH decodes a nested asset + // as bson.D before the payment service record is encoded. + var payload any + if err := json.Unmarshal([]byte(`{"asset":{"chain":"tron_mainnet","token_symbol":"usdt"},"address":"TXabc","memo":"tag-1"}`), &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + raw, err := bson.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + var data CryptoAddressPaymentData + if err := bson.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal crypto data: %v", err) + } + + if got, want := string(data.Currency), "USDT"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := data.Network, "TRON_MAINNET"; got != want { + t.Fatalf("network mismatch: got=%q want=%q", got, want) + } + if got, want := data.Address, "TXabc"; got != want { + t.Fatalf("address mismatch: got=%q want=%q", got, want) + } + if got, want := strPtr(data.DestinationTag), "tag-1"; got != want { + t.Fatalf("destination tag mismatch: got=%q want=%q", got, want) + } +} + +func TestCryptoAddressPaymentDataReadsAssetFromMapDocument(t *testing.T) { + raw, err := bson.Marshal(bson.M{ + "asset": bson.M{"chain": "arbitrum_one", "tokenSymbol": "usdc"}, + "address": "0xabc", + }) + if err != nil { + t.Fatalf("marshal crypto data: %v", err) + } + + var data CryptoAddressPaymentData + if err := bson.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal crypto data: %v", err) + } + + if got, want := string(data.Currency), "USDC"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := data.Network, "ARBITRUM_ONE"; got != want { + t.Fatalf("network mismatch: got=%q want=%q", got, want) + } +} + +func TestCryptoAddressPaymentDataReadsStoredFlatShape(t *testing.T) { + raw, err := bson.Marshal(CryptoAddressPaymentData{ + Currency: "USDT", + Address: "TXabc", + Network: "TRON_MAINNET", + }) + if err != nil { + t.Fatalf("marshal crypto data: %v", err) + } + + var data CryptoAddressPaymentData + if err := bson.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal crypto data: %v", err) + } + + if got, want := string(data.Currency), "USDT"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := data.Network, "TRON_MAINNET"; got != want { + t.Fatalf("network mismatch: got=%q want=%q", got, want) + } +} diff --git a/backend/pkg/model/ctoken.go b/backend/pkg/model/ctoken.go new file mode 100644 index 0000000..ff5191d --- /dev/null +++ b/backend/pkg/model/ctoken.go @@ -0,0 +1,47 @@ +package model + +import ( + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// TokenPaymentData represents a network or gateway-issued card token. +type TokenPaymentData struct { + Token string `bson:"token" json:"token"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Last4 string `bson:"last4,omitempty" json:"masked_pan,omitempty"` + ExpMonth *int `bson:"exp_month,omitempty" json:"exp_month,omitempty"` + ExpYear *int `bson:"exp_year,omitempty" json:"exp_year,omitempty"` + CardholderName string `bson:"cardholderName,omitempty" json:"cardholderName,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` +} + +func (d *TokenPaymentData) UnmarshalBSON(data []byte) error { + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + return err + } + d.Token = paymentDataString(raw["token"]) + d.Network = paymentDataString(raw["network"]) + d.Last4 = firstPaymentDataString(raw, "last4", "last_4", "masked_pan") + d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") + d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") + d.CardholderName = firstPaymentDataString(raw, "cardholderName", "cardholder_name") + d.Country = paymentDataString(raw["country"]) + return nil +} + +func (m *PaymentMethod) AsCardToken() (*TokenPaymentData, error) { + if m.Type != PaymentTypeCardToken { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cardToken", m.Type), "type") + } + + var d TokenPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + + return &d, nil +} diff --git a/backend/pkg/model/ctoken_test.go b/backend/pkg/model/ctoken_test.go new file mode 100644 index 0000000..51f9066 --- /dev/null +++ b/backend/pkg/model/ctoken_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestTokenPaymentDataUnmarshalReadsSnakeCaseExpiryAsInt(t *testing.T) { + raw, err := bson.Marshal(bson.M{ + "token": "card-token", + "last4": "1234", + "exp_month": "01", + "exp_year": 2031.0, + }) + if err != nil { + t.Fatalf("marshal card token data: %v", err) + } + + var data TokenPaymentData + if err := bson.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal card token data: %v", err) + } + if got, want := data.ExpMonth, 1; got == nil || *got != want { + t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) + } + if got, want := data.ExpYear, 2031; got == nil || *got != want { + t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) + } +} diff --git a/backend/pkg/model/currency.go b/backend/pkg/model/currency.go new file mode 100644 index 0000000..d706321 --- /dev/null +++ b/backend/pkg/model/currency.go @@ -0,0 +1,77 @@ +package model + +import ( + "sort" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" +) + +type Currency string + +const ( + CurrencyEUR Currency = "EUR" // Euro + CurrencyUSD Currency = "USD" // US Dollar + CurrencyRUB Currency = "RUB" // Russian Ruble + CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia + CurrencyPLN Currency = "PLN" // Polish Złoty + CurrencyCZK Currency = "CZK" // Czech Koruna + CurrencyUSDT Currency = "USDT" // Tether USD +) + +const MaxCurrencyScale uint32 = moneybase.MaxScale + +func NormalizeCurrency(value string) Currency { + return Currency(strings.ToUpper(strings.TrimSpace(value))) +} + +func NormalizeExplicitCurrencyScales(values map[Currency]uint32) (map[Currency]uint32, error) { + if len(values) == 0 { + return nil, merrors.InvalidArgument("currency scales are required") + } + out := make(map[Currency]uint32, len(values)) + for rawCurrency, scale := range values { + currency := NormalizeCurrency(string(rawCurrency)) + if currency == "" { + return nil, merrors.InvalidArgument("currency scale key is required") + } + if scale > MaxCurrencyScale { + return nil, merrors.InvalidArgumentf("currency scale for %s exceeds %d", currency, MaxCurrencyScale) + } + out[currency] = scale + } + return out, nil +} + +func CurrencyScale(scales map[Currency]uint32, currency string) (uint32, error) { + normalized := NormalizeCurrency(currency) + if normalized == "" { + return 0, merrors.InvalidArgument("currency is required") + } + if scales == nil { + return 0, merrors.InvalidArgument("currency scale map is required") + } + scale, ok := scales[normalized] + if !ok { + return 0, merrors.InvalidArgument("currency scale for " + string(normalized) + " is not configured") + } + return scale, nil +} + +func CurrencyScaleKeys(scales map[Currency]uint32) []string { + if scales == nil { + return nil + } + keys := make([]string, 0, len(scales)) + for currency := range scales { + keys = append(keys, string(currency)) + } + sort.Strings(keys) + return keys +} + +type Amount struct { + Total float64 `bson:"total" json:"total"` // Total amount billed + Currency Currency `bson:"currency" json:"currency"` // Currency for the invoice +} diff --git a/backend/pkg/model/currency_test.go b/backend/pkg/model/currency_test.go new file mode 100644 index 0000000..7904986 --- /dev/null +++ b/backend/pkg/model/currency_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "errors" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" + "github.com/stretchr/testify/require" +) + +func TestMaxCurrencyScaleUsesSharedMoneyBaseRange(t *testing.T) { + require.Equal(t, moneybase.MaxScale, MaxCurrencyScale) +} + +func TestNormalizeExplicitCurrencyScalesNormalizesKeys(t *testing.T) { + scales, err := NormalizeExplicitCurrencyScales(map[Currency]uint32{"usd": 2, "usdt": 6}) + require.NoError(t, err) + + usd, err := CurrencyScale(scales, "USD") + require.NoError(t, err) + require.Equal(t, uint32(2), usd) + + usdt, err := CurrencyScale(scales, "USDT") + require.NoError(t, err) + require.Equal(t, uint32(6), usdt) +} + +func TestCurrencyScaleRejectsUnconfiguredCurrency(t *testing.T) { + _, err := CurrencyScale(map[Currency]uint32{"USD": 2}, "KWD") + + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) +} + +func TestNormalizeExplicitCurrencyScalesRejectsMissingScales(t *testing.T) { + _, err := NormalizeExplicitCurrencyScales(nil) + + require.Error(t, err) + require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) +} diff --git a/backend/pkg/model/describable.go b/backend/pkg/model/describable.go new file mode 100644 index 0000000..1c9b1ce --- /dev/null +++ b/backend/pkg/model/describable.go @@ -0,0 +1,11 @@ +package model + +type Describable struct { + Name string `bson:"name" json:"name"` // Name + Description *string `bson:"description,omitempty" json:"description,omitempty"` // Optional description +} + +const ( + NameField = "name" + DescriptionField = "description" +) diff --git a/backend/pkg/model/fconfig.go b/backend/pkg/model/fconfig.go new file mode 100644 index 0000000..8806c3b --- /dev/null +++ b/backend/pkg/model/fconfig.go @@ -0,0 +1,8 @@ +package model + +type SettingsT = map[string]any + +type DriverConfig[T any] struct { + Driver T `yaml:"driver"` + Settings SettingsT `yaml:"settings"` +} diff --git a/backend/pkg/model/iban.go b/backend/pkg/model/iban.go new file mode 100644 index 0000000..a4cd7ea --- /dev/null +++ b/backend/pkg/model/iban.go @@ -0,0 +1,26 @@ +package model + +import ( + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type IbanPaymentData struct { + Iban string `bson:"iban" json:"iban"` + AccountHolder string `bson:"accountHolder" json:"accountHolder"` + Bic *string `bson:"bic,omitempty" json:"bic,omitempty"` + BankName *string `bson:"bankName,omitempty" json:"bankName,omitempty"` +} + +func (m *PaymentMethod) AsIban() (*IbanPaymentData, error) { + if m.Type != PaymentTypeIban { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not iban", m.Type), "type") + } + var d IbanPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + return &d, nil +} diff --git a/backend/pkg/model/indexable.go b/backend/pkg/model/indexable.go new file mode 100644 index 0000000..3dee3f8 --- /dev/null +++ b/backend/pkg/model/indexable.go @@ -0,0 +1,12 @@ +package model + +import "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + +type Indexable struct { + Index int `bson:"index" json:"index"` +} + +type IndexableRef struct { + storable.Ref `bson:",inline" json:",inline"` + Indexable `bson:",inline" json:",inline"` +} diff --git a/backend/pkg/model/invitation.go b/backend/pkg/model/invitation.go new file mode 100644 index 0000000..0135457 --- /dev/null +++ b/backend/pkg/model/invitation.go @@ -0,0 +1,72 @@ +package model + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type InvitationStatus string + +const ( + InvitationCreated InvitationStatus = "created" + InvitationSent InvitationStatus = "sent" + InvitationAccepted InvitationStatus = "accepted" + InvitationDeclined InvitationStatus = "declined" + InvitationRevoked InvitationStatus = "revoked" +) + +type invitationDesc struct { + Email string `bson:"email" json:"email"` + Name string `bson:"name" json:"name"` + LastName string `bson:"lastName" json:"lastName"` + Comment string `bson:"comment" json:"comment"` +} + +func (id *invitationDesc) Collection() string { + return mservice.Invitations +} + +type Invitation struct { + PermissionBound `bson:",inline" json:",inline"` + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` + RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` + InviterRef bson.ObjectID `bson:"inviterRef" json:"inviterRef"` + Status InvitationStatus `bson:"status" json:"status"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` + Content invitationDesc `bson:"description" json:"description"` +} + +func (*Invitation) Collection() string { + return mservice.Invitations +} + +type employeeDesc struct { + Description Describable `bson:"description" json:"description"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` +} + +type organizationDesc struct { + Description Describable `bson:"description" json:"description"` + LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` +} + +type invitationStorable struct { + storable.Base `bson:",inline" json:",inline"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` +} + +type PublicInvitation struct { + Storable invitationStorable `bson:"storable" json:"storable"` + Employee employeeDesc `bson:"employee" json:"employee"` + Organization organizationDesc `bson:"organization" json:"organization"` + Role Describable `bson:"role" json:"role"` + Invitation invitationDesc `bson:"invitation" json:"invitation"` + RequiresRegistration bool `bson:"registrationRequired" json:"registrationRequired"` +} + +func (pi *PublicInvitation) Collection() string { + return mservice.Invitations +} diff --git a/backend/pkg/model/invitation_status.go b/backend/pkg/model/invitation_status.go new file mode 100644 index 0000000..b5799c2 --- /dev/null +++ b/backend/pkg/model/invitation_status.go @@ -0,0 +1,18 @@ +package model + +func PendingInvitationStatuses() []InvitationStatus { + return []InvitationStatus{InvitationCreated, InvitationSent} +} + +func PendingInvitationStatusValues() []string { + statuses := PendingInvitationStatuses() + values := make([]string, 0, len(statuses)) + for _, status := range statuses { + values = append(values, string(status)) + } + return values +} + +func IsPendingInvitationStatus(status InvitationStatus) bool { + return status == InvitationCreated || status == InvitationSent +} diff --git a/backend/pkg/model/invitation_test.go b/backend/pkg/model/invitation_test.go new file mode 100644 index 0000000..fd80f2b --- /dev/null +++ b/backend/pkg/model/invitation_test.go @@ -0,0 +1,62 @@ +package model + +import "testing" + +func TestPendingInvitationStatusValues(t *testing.T) { + got := PendingInvitationStatusValues() + want := []string{"created", "sent"} + if len(got) != len(want) { + t.Fatalf("pending status count mismatch: got %d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("pending status %d mismatch: got %q want %q", i, got[i], want[i]) + } + } + + if !IsPendingInvitationStatus(InvitationCreated) { + t.Fatal("created must be pending") + } + if !IsPendingInvitationStatus(InvitationSent) { + t.Fatal("sent must be pending") + } + for _, status := range []InvitationStatus{InvitationAccepted, InvitationDeclined, InvitationRevoked} { + if IsPendingInvitationStatus(status) { + t.Fatalf("%q must not be pending", status) + } + } +} + +func TestInvitationValidateEmail(t *testing.T) { + tests := []struct { + name string + email string + wantErr bool + }{ + {name: "valid", email: "invitee@example.com"}, + {name: "whitespace padded valid", email: " invitee@example.com "}, + {name: "empty", email: "", wantErr: true}, + {name: "whitespace only", email: " ", wantErr: true}, + {name: "missing domain", email: "invitee@", wantErr: true}, + {name: "missing at", email: "invitee.example.com", wantErr: true}, + {name: "display name", email: "Invitee ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv := &Invitation{ + Status: InvitationCreated, + Content: invitationDesc{ + Email: tt.email, + }, + } + err := inv.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected validation error") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected valid invitation, got %v", err) + } + }) + } +} diff --git a/backend/pkg/model/invitation_validation.go b/backend/pkg/model/invitation_validation.go new file mode 100644 index 0000000..dea9556 --- /dev/null +++ b/backend/pkg/model/invitation_validation.go @@ -0,0 +1,23 @@ +package model + +import ( + "net/mail" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +func (i *Invitation) Validate() error { + if i == nil { + return merrors.InvalidArgument("invitation is required", "invitation") + } + if strings.TrimSpace(i.Content.Email) == "" { + return merrors.InvalidArgument("invitation email must not be empty", "description.email") + } + trimmedEmail := strings.TrimSpace(i.Content.Email) + parsed, err := mail.ParseAddress(trimmedEmail) + if err != nil || parsed.Address != trimmedEmail { + return merrors.InvalidArgument("invitation email is malformed", "description.email") + } + return nil +} diff --git a/backend/pkg/model/ledger.go b/backend/pkg/model/ledger.go new file mode 100644 index 0000000..cf8bd88 --- /dev/null +++ b/backend/pkg/model/ledger.go @@ -0,0 +1,132 @@ +package model + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model/account_role" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AccountType defines the category of account (asset, liability, revenue, expense). +type LedgerAccountType string + +const ( + LedgerAccountTypeAsset LedgerAccountType = "asset" + LedgerAccountTypeLiability LedgerAccountType = "liability" + LedgerAccountTypeRevenue LedgerAccountType = "revenue" + LedgerAccountTypeExpense LedgerAccountType = "expense" +) + +// AccountStatus tracks the operational state of an account. +type LedgerAccountStatus string + +const ( + LedgerAccountStatusActive LedgerAccountStatus = "active" + LedgerAccountStatusFrozen LedgerAccountStatus = "frozen" + LedgerAccountStatusClosed LedgerAccountStatus = "closed" +) + +type LedgerAccountScope string + +const ( + LedgerAccountScopeOrganization LedgerAccountScope = "organization" + LedgerAccountScopeSystem LedgerAccountScope = "system" +) + +type SystemAccountPurpose string + +const ( + SystemAccountPurposeExternalSource SystemAccountPurpose = "external_source" + SystemAccountPurposeExternalSink SystemAccountPurpose = "external_sink" +) + +// Account represents a ledger account that holds balances for a specific currency. +type LedgerAccount struct { + storable.Base `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + + // Scope defines whether the account belongs to an organization + // or is a system-level ledger account used for internal accounting. + Scope LedgerAccountScope `bson:"scope" json:"scope"` + + // SystemPurpose specifies the role of a system-scoped account + // (e.g., external source or sink of funds). Must be set for system accounts + // and must be nil for organization accounts. + SystemPurpose *SystemAccountPurpose `bson:"systemPurpose,omitempty" json:"systemPurpose,omitempty"` + + // OrganizationRef links the account to an organization. + // Must be set for organization accounts and nil for system accounts. + OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` + + // Role defines the functional purpose of the account within an organization + // (e.g., pending, operating, settlement, hold, etc.). + // Must be set for organization accounts and omitted for system accounts. + Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"` + + // AccountCode is a logical classification code of the account + // (e.g., "asset:cash:usd") used for reporting and grouping. + AccountCode string `bson:"accountCode" json:"accountCode"` + + // Currency is the ISO 4217 currency code the account operates in. + Currency string `bson:"currency" json:"currency"` + + // AccountType defines the accounting category of the account + // (asset, liability, revenue, expense). + AccountType LedgerAccountType `bson:"accountType" json:"accountType"` + + // Status represents the operational state of the account. + Status LedgerAccountStatus `bson:"status" json:"status"` + + // AllowNegative defines whether the account is allowed to have + // a negative balance (used for system control accounts). + AllowNegative bool `bson:"allowNegative" json:"allowNegative"` + + // OwnerRef optionally links the account to a specific owner entity + // (e.g., user or sub-entity within the organization). + OwnerRef *bson.ObjectID `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` + + // Metadata holds additional arbitrary key-value attributes. + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` +} + +// Collection implements storable.Storable. +func (*LedgerAccount) Collection() string { + return mservice.LedgerAccounts +} + +// Validate enforces scope-specific invariants for ledger accounts. +func (a *LedgerAccount) Validate() error { + if a == nil { + return merrors.InvalidArgument("ledger account is required") + } + + switch a.Scope { + case LedgerAccountScopeOrganization: + if a.OrganizationRef == nil || a.OrganizationRef.IsZero() { + return merrors.InvalidArgument("organization_ref is required for organization accounts") + } + if strings.TrimSpace(string(a.Role)) == "" { + return merrors.InvalidArgument("role is required for organization accounts") + } + if a.SystemPurpose != nil { + return merrors.InvalidArgument("system_purpose must be nil for organization accounts") + } + case LedgerAccountScopeSystem: + if a.OrganizationRef != nil && !a.OrganizationRef.IsZero() { + return merrors.InvalidArgument("organization_ref must be nil for system accounts") + } + if strings.TrimSpace(string(a.Role)) != "" { + return merrors.InvalidArgument("role must be empty for system accounts") + } + if a.SystemPurpose == nil { + return merrors.InvalidArgument("system_purpose is required for system accounts") + } + default: + return merrors.InvalidArgument("scope is required") + } + + return nil +} diff --git a/backend/pkg/model/object.go b/backend/pkg/model/object.go new file mode 100644 index 0000000..7b39819 --- /dev/null +++ b/backend/pkg/model/object.go @@ -0,0 +1,20 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type TypeBound struct { + Type mservice.Type `bson:"type" json:"type"` +} + +type ObjectRefs struct { + TypeBound `bson:"inline" json:"inline"` + Refs []bson.ObjectID `bson:"refs,omitempty" json:"refs,omitempty"` +} + +type ObjectRef struct { + TypeBound `bson:"inline" json:"inline"` + Ref bson.ObjectID `bson:"ref,omitempty" json:"ref,omitempty"` +} diff --git a/backend/pkg/model/organization.go b/backend/pkg/model/organization.go new file mode 100644 index 0000000..0f718b5 --- /dev/null +++ b/backend/pkg/model/organization.go @@ -0,0 +1,111 @@ +package model + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type OrganizationSignupState string + +const ( + OrganizationSignupStatePending OrganizationSignupState = "pending_signup_provisioning" + OrganizationSignupStateBlocked OrganizationSignupState = "blocked_signup_provisioning" + OrganizationSignupStateActive OrganizationSignupState = "active" +) + +type OrganizationBase struct { + PermissionBound `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + TenantRef bson.ObjectID `bson:"tenantRef" json:"tenantRef"` + TimeZone string `bson:"timeZone" json:"timeZone"` + LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` + SignupState OrganizationSignupState `bson:"signupState,omitempty" json:"signupState,omitempty"` +} + +func (*OrganizationBase) Collection() string { + return mservice.Organizations +} + +type Organization struct { + OrganizationBase `bson:",inline" json:",inline"` + Members []bson.ObjectID `bson:"members" json:"members"` + Provisioning *OrganizationProvisioning `bson:"provisioning,omitempty" json:"-"` +} + +// OrganizationProvisioning tracks an organization that is being provisioned and +// has no members or roles yet. It is the only link between such an organization +// and its future owner, and it also guards concurrent provisioning attempts. +// Activation clears only the lease fields: the provenance stays so a retried +// create can recognize the organization it already activated. +// +// It lives on Organization rather than OrganizationBase on purpose: update +// paths replace OrganizationBase wholesale from client payloads, which would +// otherwise wipe it. It is never exposed over the API. +type OrganizationProvisioning struct { + OwnerRef bson.ObjectID `bson:"ownerRef" json:"-"` + // Name snapshots the organization name of the first attempt. It is the + // immutable half of the dedup key: renaming the organization must not + // detach it from retries of the create that produced it. + Name string `bson:"name,omitempty" json:"-"` + Origin string `bson:"origin" json:"-"` + ActorRef string `bson:"actorRef,omitempty" json:"-"` + StartedAt time.Time `bson:"startedAt" json:"-"` + LeaseOwner string `bson:"leaseOwner,omitempty" json:"-"` + LeaseExpiresAt time.Time `bson:"leaseExpiresAt,omitempty" json:"-"` +} + +const ( + OrganizationSignupStateField = "signupState" + OrganizationProvisioningField = "provisioning" + OrganizationProvisioningOwnerRefField = "provisioning.ownerRef" + OrganizationProvisioningNameField = "provisioning.name" + OrganizationProvisioningLeaseOwnerField = "provisioning.leaseOwner" + OrganizationProvisioningLeaseField = "provisioning.leaseExpiresAt" +) + +// LeaseHeld reports whether another attempt still owns the provisioning lease. +func (p *OrganizationProvisioning) LeaseHeld(now time.Time) bool { + return p != nil && p.LeaseExpiresAt.After(now) +} + +// CanFinishProvisioning reports whether an operator can drive this organization +// to active through the backoffice create operation: only pending organizations +// stamped by operator provisioning are resumable. Signup-era organizations +// carry no provisioning stamp. +func (o *Organization) CanFinishProvisioning() bool { + return o != nil && o.Provisioning != nil && o.SignupState == OrganizationSignupStatePending +} + +func (o *Organization) NormalizeSignupState() { + if o == nil { + return + } + switch o.SignupState { + case OrganizationSignupStatePending, OrganizationSignupStateBlocked, OrganizationSignupStateActive: + default: + o.SignupState = OrganizationSignupStateActive + } +} + +type OrganizationBound interface { + GetOrganizationRef() bson.ObjectID + SetOrganizationRef(organizationRef bson.ObjectID) +} + +const ( + OrganizationRefField = "organizationRef" +) + +type OrganizationBoundBase struct { + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` +} + +func (a *OrganizationBoundBase) GetOrganizationRef() bson.ObjectID { + return a.OrganizationRef +} + +func (a *OrganizationBoundBase) SetOrganizationRef(organizationRef bson.ObjectID) { + a.OrganizationRef = organizationRef +} diff --git a/backend/pkg/model/ownerbound.go b/backend/pkg/model/ownerbound.go new file mode 100644 index 0000000..2b13b6d --- /dev/null +++ b/backend/pkg/model/ownerbound.go @@ -0,0 +1,21 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// OwnerBound is implemented by resources that follow the personal-vs-org +// ownership model (ownerKind + ownerRef). It is the type constraint for +// ScopedDB and the canonical contract for scoped-access resources. +// +// permissionRef is deliberately absent: it is only meaningful for org-owned +// resources and is supplied separately (as OrgPolicyRef) to the enforcement +// layer. Mixing it here would re-introduce the very coupling this interface +// is meant to eliminate. +type OwnerBound interface { + storable.Storable + OrganizationBound + GetOwnerKind() OwnerKind + GetOwnerRef() bson.ObjectID +} diff --git a/backend/pkg/model/ownerkind.go b/backend/pkg/model/ownerkind.go new file mode 100644 index 0000000..47313d9 --- /dev/null +++ b/backend/pkg/model/ownerkind.go @@ -0,0 +1,24 @@ +package model + +// OwnerKind describes who owns a scopable resource (ledger account, recipient, +// payment method, …). It is the single source of truth for the personal-vs-org +// access model: a resource is either owned by an individual account (personal) +// or by an organization (org-scoped). +// +// The string values are persisted in Mongo and used in policy checks, so they +// must stay stable. Scopable resources embed model.Owner (which carries an +// OwnerKind) to keep one canonical vocabulary across the codebase. +type OwnerKind string + +const ( + // OwnerKindAccount marks a personal resource. Access is proven by ownership + // alone: caller is allowed iff ownerRef == caller's account. No policy check. + OwnerKindAccount OwnerKind = "account" + // OwnerKindOrganization marks an org-scoped resource. Access is gated by the + // relevant org policy via the Enforcer. + OwnerKindOrganization OwnerKind = "organization" +) + +func (k OwnerKind) IsValid() bool { + return k == OwnerKindAccount || k == OwnerKindOrganization +} diff --git a/backend/pkg/model/payment.go b/backend/pkg/model/payment.go new file mode 100644 index 0000000..f703773 --- /dev/null +++ b/backend/pkg/model/payment.go @@ -0,0 +1,276 @@ +package model + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type PaymentType int + +const ( + PaymentTypeIban PaymentType = iota + PaymentTypeCard + PaymentTypeCardToken + PaymentTypeBankAccount + PaymentTypeWallet + PaymentTypeCryptoAddress + PaymentTypeLedger +) + +var paymentTypeToString = map[PaymentType]string{ + PaymentTypeIban: "iban", + PaymentTypeCard: "card", + PaymentTypeCardToken: "cardToken", + PaymentTypeBankAccount: "bankAccount", + PaymentTypeWallet: "wallet", + PaymentTypeCryptoAddress: "cryptoAddress", + PaymentTypeLedger: "ledger", +} + +var paymentTypeFromString = map[string]PaymentType{ + "iban": PaymentTypeIban, + "card": PaymentTypeCard, + "cardToken": PaymentTypeCardToken, + "bankAccount": PaymentTypeBankAccount, + "wallet": PaymentTypeWallet, + "cryptoAddress": PaymentTypeCryptoAddress, + "ledger": PaymentTypeLedger, +} + +func (t PaymentType) String() string { + if v, ok := paymentTypeToString[t]; ok { + return v + } + return "iban" +} + +func (t PaymentType) IsValid() bool { + _, ok := paymentTypeToString[t] + return ok +} + +func (t PaymentType) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +func (t *PaymentType) UnmarshalJSON(data []byte) error { + var val string + if err := json.Unmarshal(data, &val); err != nil { + return err + } + v, ok := paymentTypeFromString[val] + if !ok { + return merrors.InvalidArgument(fmt.Sprintf("unknown PaymentType: %q", val)) + } + *t = v + return nil +} + +type PaymentMethodOwnerKind string + +const ( + PaymentMethodOwnerKindUnspecified PaymentMethodOwnerKind = "" + PaymentMethodOwnerKindAccount PaymentMethodOwnerKind = "ACCOUNT" + PaymentMethodOwnerKindRecipient PaymentMethodOwnerKind = "RECIPIENT" +) + +func (k PaymentMethodOwnerKind) String() string { + return string(k) +} + +func (k PaymentMethodOwnerKind) IsValid() bool { + switch k { + case PaymentMethodOwnerKindAccount, PaymentMethodOwnerKindRecipient: + return true + default: + return false + } +} + +type PaymentMethod struct { + PermissionBound `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + + OwnerKind PaymentMethodOwnerKind `bson:"ownerKind" json:"ownerKind"` + OwnerRef bson.ObjectID `bson:"ownerRef" json:"ownerRef"` + Type PaymentType `bson:"type" json:"type"` + Data bson.Raw `bson:"data" json:"data"` + IsMain bool `bson:"isMain" json:"isMain"` +} + +type paymentMethodJSON struct { + PermissionBound `json:",inline"` + Describable `json:",inline"` + + OwnerKind PaymentMethodOwnerKind `json:"ownerKind"` + OwnerRef bson.ObjectID `json:"ownerRef"` + Type PaymentType `json:"type"` + Data json.RawMessage `json:"data"` + IsMain bool `json:"isMain"` +} + +func (m PaymentMethod) MarshalJSON() ([]byte, error) { + marshaledData, err := m.marshalDataJSON() + if err != nil { + return nil, err + } + + payload := paymentMethodJSON{ + PermissionBound: m.PermissionBound, + Describable: m.Describable, + OwnerKind: m.OwnerKind, + OwnerRef: m.OwnerRef, + Type: m.Type, + Data: marshaledData, + IsMain: m.IsMain, + } + + return json.Marshal(payload) +} + +func (m *PaymentMethod) UnmarshalJSON(data []byte) error { + var payload paymentMethodJSON + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + m.PermissionBound = payload.PermissionBound + m.Describable = payload.Describable + m.OwnerKind = payload.OwnerKind + m.OwnerRef = payload.OwnerRef + m.Type = payload.Type + m.IsMain = payload.IsMain + + if len(payload.Data) == 0 || bytes.Equal(payload.Data, []byte("null")) { + m.Data = nil + return nil + } + + if raw, ok, err := m.unmarshalTypedDataJSON(payload.Data); ok || err != nil { + if err != nil { + return err + } + m.Data = raw + return nil + } + + var rawData map[string]any + if err := json.Unmarshal(payload.Data, &rawData); err != nil { + return err + } + + raw, err := bson.Marshal(rawData) + if err != nil { + return err + } + + m.Data = raw + return nil +} + +func (m PaymentMethod) marshalDataJSON() (json.RawMessage, error) { + if len(m.Data) == 0 { + return nil, nil + } + + var payload any + var err error + switch m.Type { + case PaymentTypeIban: + payload, err = marshalTypedPaymentData[IbanPaymentData](m.Data) + case PaymentTypeCard: + payload, err = marshalTypedPaymentData[CardPaymentData](m.Data) + case PaymentTypeCardToken: + payload, err = marshalTypedPaymentData[TokenPaymentData](m.Data) + case PaymentTypeBankAccount: + payload, err = marshalTypedPaymentData[RussianBankAccountPaymentData](m.Data) + case PaymentTypeWallet: + payload, err = marshalTypedPaymentData[WalletPaymentData](m.Data) + case PaymentTypeCryptoAddress: + payload, err = marshalTypedPaymentData[CryptoAddressPaymentData](m.Data) + case PaymentTypeLedger: + payload, err = marshalTypedPaymentData[LedgerPaymentData](m.Data) + default: + var data bson.M + if err := bson.Unmarshal(m.Data, &data); err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + payload = data + } + if err != nil { + return nil, err + } + + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return data, nil +} + +func (m PaymentMethod) unmarshalTypedDataJSON(data json.RawMessage) (bson.Raw, bool, error) { + switch m.Type { + case PaymentTypeIban: + return unmarshalTypedPaymentData[IbanPaymentData](data) + case PaymentTypeCard: + return unmarshalTypedPaymentData[CardPaymentData](data) + case PaymentTypeCardToken: + return unmarshalTypedPaymentData[TokenPaymentData](data) + case PaymentTypeBankAccount: + return unmarshalTypedPaymentData[RussianBankAccountPaymentData](data) + case PaymentTypeWallet: + return unmarshalTypedPaymentData[WalletPaymentData](data) + case PaymentTypeCryptoAddress: + return unmarshalTypedPaymentData[CryptoAddressPaymentData](data) + case PaymentTypeLedger: + return unmarshalTypedPaymentData[LedgerPaymentData](data) + default: + return nil, false, nil + } +} + +func marshalTypedPaymentData[T any](raw bson.Raw) (T, error) { + var payload T + err := bson.Unmarshal(raw, &payload) + return payload, err +} + +func unmarshalTypedPaymentData[T any](data json.RawMessage) (bson.Raw, bool, error) { + var payload T + if err := json.Unmarshal(data, &payload); err != nil { + return nil, true, err + } + raw, err := bson.Marshal(payload) + if err != nil { + return nil, true, err + } + return raw, true, nil +} + +type PaymentStatus string + +const ( + PaymentStatusUnspecified PaymentStatus = "unspecified" + + // Intent exists, no funds touched yet. + PaymentStatusCreated PaymentStatus = "created" + + // Internal Sendico operations: holds, ledger, FX, fee calc. + PaymentStatusAuthorizing PaymentStatus = "authorizing" + + // Funds are outside Sendico (rail/gateway/blockchain/provider). + // Observe lives ONLY in this state. + PaymentStatusExecuting PaymentStatus = "executing" + + // Final states. + PaymentStatusSucceeded PaymentStatus = "success" + PaymentStatusFailed PaymentStatus = "failed" + PaymentStatusCancelled PaymentStatus = "cancelled" +) diff --git a/backend/pkg/model/payment_ledger.go b/backend/pkg/model/payment_ledger.go new file mode 100644 index 0000000..ee4c32b --- /dev/null +++ b/backend/pkg/model/payment_ledger.go @@ -0,0 +1,77 @@ +package model + +import ( + "encoding/json" + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type LedgerPaymentData struct { + AccountRef string `bson:"accountRef,omitempty" json:"account_ref,omitempty"` + AccountCode string `bson:"accountCode,omitempty" json:"account_code,omitempty"` + ContraAccountRef string `bson:"contraAccountRef,omitempty" json:"contra_account_ref,omitempty"` + ContraAccountCode string `bson:"contraAccountCode,omitempty" json:"contra_account_code,omitempty"` +} + +func (d *LedgerPaymentData) UnmarshalJSON(data []byte) error { + var raw struct { + AccountRef string `json:"account_ref"` + LedgerAccountRef string `json:"ledger_account_ref"` + AccountCode string `json:"account_code"` + ContraAccountRef string `json:"contra_account_ref"` + ContraLedgerAccountRef string `json:"contra_ledger_account_ref"` + ContraAccountCode string `json:"contra_account_code"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + accountRef := strings.TrimSpace(raw.AccountRef) + ledgerAccountRef := strings.TrimSpace(raw.LedgerAccountRef) + if ledgerAccountRef != "" { + if accountRef != "" && accountRef != ledgerAccountRef { + return merrors.InvalidArgument("account_ref and ledger_account_ref must match") + } + accountRef = ledgerAccountRef + } + + contraAccountRef := strings.TrimSpace(raw.ContraAccountRef) + contraLedgerAccountRef := strings.TrimSpace(raw.ContraLedgerAccountRef) + if contraLedgerAccountRef != "" { + if contraAccountRef != "" && contraAccountRef != contraLedgerAccountRef { + return merrors.InvalidArgument("contra_account_ref and contra_ledger_account_ref must match") + } + contraAccountRef = contraLedgerAccountRef + } + + d.AccountRef = accountRef + d.AccountCode = strings.TrimSpace(raw.AccountCode) + d.ContraAccountRef = contraAccountRef + d.ContraAccountCode = strings.TrimSpace(raw.ContraAccountCode) + return nil +} + +func (d *LedgerPaymentData) UnmarshalBSON(data []byte) error { + var raw map[string]any + if err := bson.Unmarshal(data, &raw); err != nil { + return err + } + d.AccountRef = firstPaymentDataString(raw, "accountRef", "account_ref", "ledgerAccountRef", "ledger_account_ref") + d.AccountCode = firstPaymentDataString(raw, "accountCode", "account_code") + d.ContraAccountRef = firstPaymentDataString(raw, "contraAccountRef", "contra_account_ref", "contraLedgerAccountRef", "contra_ledger_account_ref") + d.ContraAccountCode = firstPaymentDataString(raw, "contraAccountCode", "contra_account_code") + return nil +} + +func (m *PaymentMethod) AsLedger() (*LedgerPaymentData, error) { + if m.Type != PaymentTypeLedger { + return nil, merrors.InvalidArgument("payment method type is "+m.Type.String()+", not ledger", "type") + } + var d LedgerPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + return &d, nil +} diff --git a/backend/pkg/model/payment_method_json_test.go b/backend/pkg/model/payment_method_json_test.go new file mode 100644 index 0000000..c375a6b --- /dev/null +++ b/backend/pkg/model/payment_method_json_test.go @@ -0,0 +1,122 @@ +package model + +import ( + "encoding/json" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/person" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestPaymentMethodMarshalRussianBankAccountUsesJSONRecipientNames(t *testing.T) { + raw, err := bson.Marshal(RussianBankAccountPaymentData{ + Recipient: person.Name{ + FirstName: "Success", + LastName: "Success", + }, + Inn: "7707083893", + Kpp: "770701001", + BankName: "AsterBank Test Bank", + Bik: "044525225", + AccountNumber: "40702810000000080001", + CorrespondentAccount: "30101810400000000225", + }) + if err != nil { + t.Fatalf("marshal bank account data: %v", err) + } + + payload, err := json.Marshal(PaymentMethod{ + Type: PaymentTypeBankAccount, + Data: raw, + }) + if err != nil { + t.Fatalf("marshal payment method: %v", err) + } + + var decoded struct { + Type PaymentType `json:"type"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if decoded.Type != PaymentTypeBankAccount { + t.Fatalf("type mismatch: got=%s want=%s", decoded.Type, PaymentTypeBankAccount) + } + + recipient, ok := decoded.Data["recipient"].(map[string]any) + if !ok { + t.Fatalf("recipient is not an object: %#v", decoded.Data["recipient"]) + } + if got, want := recipient["first_name"], "Success"; got != want { + t.Fatalf("recipient.first_name mismatch: got=%#v want=%q", got, want) + } + if got, want := recipient["last_name"], "Success"; got != want { + t.Fatalf("recipient.last_name mismatch: got=%#v want=%q", got, want) + } + if _, ok := recipient["firstName"]; ok { + t.Fatalf("recipient must not expose BSON firstName: %#v", recipient) + } + if _, ok := recipient["lastName"]; ok { + t.Fatalf("recipient must not expose BSON lastName: %#v", recipient) + } +} + +func TestPaymentMethodUnmarshalRussianBankAccountStoresBSONRecipientNames(t *testing.T) { + payload := []byte(`{ + "type": "bankAccount", + "data": { + "recipient": { + "first_name": "Success", + "last_name": "Success" + }, + "inn": "7707083893", + "kpp": "770701001", + "bankName": "AsterBank Test Bank", + "bik": "044525225", + "accountNumber": "40702810000000080001", + "correspondentAccount": "30101810400000000225" + } + }`) + + var method PaymentMethod + if err := json.Unmarshal(payload, &method); err != nil { + t.Fatalf("unmarshal payment method: %v", err) + } + + var stored bson.M + if err := bson.Unmarshal(method.Data, &stored); err != nil { + t.Fatalf("unmarshal stored data: %v", err) + } + recipient, ok := bsonDocumentMap(stored["recipient"]) + if !ok { + t.Fatalf("stored recipient is not an object: %#v", stored["recipient"]) + } + if got, want := recipient["firstName"], "Success"; got != want { + t.Fatalf("stored recipient.firstName mismatch: got=%#v want=%q", got, want) + } + if got, want := recipient["lastName"], "Success"; got != want { + t.Fatalf("stored recipient.lastName mismatch: got=%#v want=%q", got, want) + } + if _, ok := recipient["first_name"]; ok { + t.Fatalf("stored recipient must not use JSON first_name: %#v", recipient) + } + if _, ok := recipient["last_name"]; ok { + t.Fatalf("stored recipient must not use JSON last_name: %#v", recipient) + } +} + +func bsonDocumentMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case bson.M: + return typed, true + case bson.D: + out := make(map[string]any, len(typed)) + for _, element := range typed { + out[element.Key] = element.Value + } + return out, true + default: + return nil, false + } +} diff --git a/backend/pkg/model/permission.go b/backend/pkg/model/permission.go new file mode 100644 index 0000000..b796f23 --- /dev/null +++ b/backend/pkg/model/permission.go @@ -0,0 +1,33 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type PermissionBoundStorable interface { + storable.Storable + OrganizationBound + Archivable + GetPermissionRef() bson.ObjectID + SetPermissionRef(permissionRef bson.ObjectID) +} + +type PermissionBound struct { + storable.Base `bson:",inline" json:",inline"` + ArchivableBase `bson:",inline" json:",inline"` + OrganizationBoundBase `bson:",inline" json:",inline"` + PermissionRef bson.ObjectID `bson:"permissionRef" json:"permissionRef"` +} + +func (b *PermissionBound) GetPermissionRef() bson.ObjectID { + return b.PermissionRef +} + +func (b *PermissionBound) GetOrganizationRef() bson.ObjectID { + return b.OrganizationRef +} + +func (b *PermissionBound) SetPermissionRef(permissionRef bson.ObjectID) { + b.PermissionRef = permissionRef +} diff --git a/backend/pkg/model/rba.go b/backend/pkg/model/rba.go new file mode 100644 index 0000000..6ec3ef9 --- /dev/null +++ b/backend/pkg/model/rba.go @@ -0,0 +1,52 @@ +package model + +import ( + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/person" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type RussianBankAccountPaymentData struct { + Recipient person.Name `bson:"recipient" json:"recipient"` + Inn string `bson:"inn" json:"inn"` + Kpp string `bson:"kpp" json:"kpp"` + BankName string `bson:"bankName" json:"bankName"` + Bik string `bson:"bik" json:"bik"` + AccountNumber string `bson:"accountNumber" json:"accountNumber"` + CorrespondentAccount string `bson:"correspondentAccount" json:"correspondentAccount"` +} + +// UnmarshalBSON accepts both stored recipient names and the API names in the +// intermediate document built by a payment-method PATCH. +func (d *RussianBankAccountPaymentData) UnmarshalBSON(data []byte) error { + type stored RussianBankAccountPaymentData + var decoded stored + if err := bson.Unmarshal(data, &decoded); err != nil { + return err + } + var fields struct { + Recipient map[string]any `bson:"recipient"` + } + if err := bson.Unmarshal(data, &fields); err != nil { + return err + } + decoded.Recipient.FirstName = firstPaymentDataString(fields.Recipient, "firstName", "first_name") + decoded.Recipient.LastName = firstPaymentDataString(fields.Recipient, "lastName", "last_name") + *d = RussianBankAccountPaymentData(decoded) + return nil +} + +func (m *PaymentMethod) AsRussianBankAccount() (*RussianBankAccountPaymentData, error) { + if m.Type != PaymentTypeBankAccount { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not bankAccount", m.Type), "type") + } + + var d RussianBankAccountPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + + return &d, nil +} diff --git a/backend/pkg/model/recipient.go b/backend/pkg/model/recipient.go new file mode 100644 index 0000000..10f8a18 --- /dev/null +++ b/backend/pkg/model/recipient.go @@ -0,0 +1,128 @@ +package model + +import ( + "encoding/json" + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type RecipientStatus int + +const ( + RecipientStatusReady RecipientStatus = iota + RecipientStatusRegistered + RecipientStatusNotRegistered +) + +var recipientStatusToString = map[RecipientStatus]string{ + RecipientStatusReady: "ready", + RecipientStatusRegistered: "registered", + RecipientStatusNotRegistered: "notRegistered", +} + +var recipientStatusFromString = map[string]RecipientStatus{ + "ready": RecipientStatusReady, + "registered": RecipientStatusRegistered, + "notRegistered": RecipientStatusNotRegistered, +} + +func (s RecipientStatus) String() string { + if v, ok := recipientStatusToString[s]; ok { + return v + } + return "ready" // дефолт, можно поменять +} + +// JSON: храним как строку ("ready" / "registered" / "notRegistered") +func (s RecipientStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func (s *RecipientStatus) UnmarshalJSON(data []byte) error { + var val string + if err := json.Unmarshal(data, &val); err != nil { + return err + } + v, ok := recipientStatusFromString[val] + if !ok { + return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientStatus: %q", val)) + } + *s = v + return nil +} + +// RecipientType { internal, external } + +type RecipientType int + +const ( + RecipientTypeInternal RecipientType = iota + RecipientTypeExternal +) + +var recipientTypeToString = map[RecipientType]string{ + RecipientTypeInternal: "internal", + RecipientTypeExternal: "external", +} + +var recipientTypeFromString = map[string]RecipientType{ + "internal": RecipientTypeInternal, + "external": RecipientTypeExternal, +} + +func (t RecipientType) String() string { + if v, ok := recipientTypeToString[t]; ok { + return v + } + return "internal" +} + +func (t RecipientType) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +func (t *RecipientType) UnmarshalJSON(data []byte) error { + var val string + if err := json.Unmarshal(data, &val); err != nil { + return err + } + v, ok := recipientTypeFromString[val] + if !ok { + return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientType: %q", val)) + } + *t = v + return nil +} + +// Owner is the ownership token stored under the "owner" BSON/JSON key. +// storable.Ref must stay embedded: a named field would keep the bson ",inline" +// but encoding/json has no inline option and would nest the ref under "Ref", +// while embedding promotes it, so both wire paths are owner.ref and owner.kind. +type Owner struct { + storable.Ref `bson:",inline" json:",inline"` + Kind OwnerKind `bson:"kind" json:"kind"` +} + +type Recipient struct { + PermissionBound `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + Email string `bson:"email" json:"email"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` + + Status RecipientStatus `bson:"status" json:"status"` + Type RecipientType `bson:"type" json:"type"` + Owner Owner `bson:"owner" json:"owner"` + + // INN is the recipient's taxpayer identifier. It is the canonical source of + // the INN for payouts to this recipient (flows into customer.tax_identifier), + // replacing the per-payment-method inn field for recipient-owned methods. + INN string `bson:"inn,omitempty" json:"inn,omitempty"` +} + +func (r *Recipient) GetOwnerKind() OwnerKind { return r.Owner.Kind } +func (r *Recipient) GetOwnerRef() bson.ObjectID { return r.Owner.Ref.Ref } +func (r *Recipient) SetOwnerKind(k OwnerKind) { r.Owner.Kind = k } +func (r *Recipient) SetOwnerRef(ref bson.ObjectID) { r.Owner.Ref.Ref = ref } diff --git a/backend/pkg/model/recipient_json_test.go b/backend/pkg/model/recipient_json_test.go new file mode 100644 index 0000000..563ec39 --- /dev/null +++ b/backend/pkg/model/recipient_json_test.go @@ -0,0 +1,62 @@ +package model + +import ( + "encoding/json" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// TestOwnerJSONFlattensRef locks in the on-wire shape documented on Owner: +// owner.ref and owner.kind must both be top-level keys. Ref must stay +// anonymously embedded — a NAMED field with a `json:",inline"` tag is a +// BSON-only convention and is not flattened by encoding/json, which would +// silently nest it under the Go field name instead (a real regression this +// test caught: {"Ref":{"ref":"..."},"kind":"..."} instead of +// {"ref":"...","kind":"..."}), breaking every JSON consumer of owner.ref. +func TestOwnerJSONFlattensRef(t *testing.T) { + ownerRef := bson.NewObjectID() + owner := Owner{Kind: OwnerKindAccount} + owner.Ref.Ref = ownerRef + + b, err := json.Marshal(owner) + if err != nil { + t.Fatalf("marshal owner: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal owner json: %v", err) + } + if got, want := decoded["ref"], ownerRef.Hex(); got != want { + t.Fatalf("top-level owner.ref = %v, want %q (got JSON: %s)", got, want, b) + } + if got, want := decoded["kind"], string(OwnerKindAccount); got != want { + t.Fatalf("top-level owner.kind = %v, want %q (got JSON: %s)", got, want, b) + } + if _, nested := decoded["Ref"]; nested { + t.Fatalf("owner.ref must not nest under the Go field name \"Ref\", got JSON: %s", b) + } +} + +func TestRecipientJSONRoundTripsOwner(t *testing.T) { + r := &Recipient{} + r.SetOwnerKind(OwnerKindOrganization) + r.SetOwnerRef(bson.NewObjectID()) + + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal recipient: %v", err) + } + + var roundTripped Recipient + if err := json.Unmarshal(b, &roundTripped); err != nil { + t.Fatalf("unmarshal recipient json: %v", err) + } + if roundTripped.GetOwnerKind() != r.GetOwnerKind() { + t.Fatalf("owner kind round-trip = %v, want %v", roundTripped.GetOwnerKind(), r.GetOwnerKind()) + } + if roundTripped.GetOwnerRef() != r.GetOwnerRef() { + t.Fatalf("owner ref round-trip = %v, want %v", roundTripped.GetOwnerRef(), r.GetOwnerRef()) + } +} diff --git a/backend/pkg/model/recipient_wire_test.go b/backend/pkg/model/recipient_wire_test.go new file mode 100644 index 0000000..ebb254b --- /dev/null +++ b/backend/pkg/model/recipient_wire_test.go @@ -0,0 +1,64 @@ +package model + +import ( + "encoding/json" + "testing" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// The frontend parses owner as {"ref": ..., "kind": ...}; a regression to the +// nested {"Ref": {"ref": ...}} shape breaks every recipients screen. +func TestOwnerJSONIsFlat(t *testing.T) { + ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") + if err != nil { + t.Fatal(err) + } + + out, err := json.Marshal(Owner{ + Ref: storable.Ref{Ref: ref}, + Kind: "account", + }) + if err != nil { + t.Fatal(err) + } + + want := `{"ref":"69834f22fff13a7457793b6c","kind":"account"}` + if string(out) != want { + t.Fatalf("owner JSON = %s, want %s", out, want) + } + + var back Owner + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + if back.Ref.Ref != ref || back.Kind != "account" { + t.Fatalf("round-trip lost data: %+v", back) + } +} + +// Mongo queries filter on owner.ref / owner.kind (see auth.ScopedDBImp), so +// the BSON document must keep those exact paths. +func TestOwnerBSONPathsUnchanged(t *testing.T) { + ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") + if err != nil { + t.Fatal(err) + } + + out, err := bson.Marshal(Owner{ + Ref: storable.Ref{Ref: ref}, + Kind: "account", + }) + if err != nil { + t.Fatal(err) + } + + var doc bson.M + if err := bson.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if doc["ref"] != ref || doc["kind"] != "account" { + t.Fatalf("bson doc = %v, want flat ref/kind", doc) + } +} diff --git a/backend/pkg/model/refresh.go b/backend/pkg/model/refresh.go new file mode 100644 index 0000000..bc9c8ee --- /dev/null +++ b/backend/pkg/model/refresh.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ClientRefreshToken struct { + SessionIdentifier `bson:",inline" json:",inline"` + RefreshToken string `bson:"token" json:"token"` +} + +type RefreshToken struct { + storable.Base `bson:",inline" json:",inline"` + ClientRefreshToken `bson:",inline" json:",inline"` + AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"` + ExpiresAt time.Time `bson:"expiresAt"` + IsRevoked bool `bson:"isRevoked"` + LastUsedAt time.Time `bson:"lastUsedAt,omitempty"` + UserAgent string `bson:"userAgent"` + IPAddress string `bson:"ipAddress"` +} + +func (*RefreshToken) Collection() string { + return mservice.RefreshTokens +} diff --git a/backend/pkg/model/scopedstorablemodel.go b/backend/pkg/model/scopedstorablemodel.go new file mode 100644 index 0000000..c96b6dc --- /dev/null +++ b/backend/pkg/model/scopedstorablemodel.go @@ -0,0 +1,19 @@ +package model + +import "go.mongodb.org/mongo-driver/v2/bson" + +// ScopedStorable is the type constraint for resources that follow the +// personal-vs-org dual-ownership model. ScopedDBImp[T] provides generic +// CRUD and bulk operations for any T satisfying this interface without +// the permissionRef==NilObjectID sentinel pattern that ProtectedDBImp requires +// for personal-owned resources. +// +// Resources that embed model.PermissionBound and model.Owner satisfy +// ScopedStorable automatically once SetOwnerKind/SetOwnerRef methods are added. +type ScopedStorable interface { + OwnerBound + SetOwnerKind(OwnerKind) + SetOwnerRef(bson.ObjectID) + GetPermissionRef() bson.ObjectID + SetPermissionRef(bson.ObjectID) +} diff --git a/backend/pkg/model/sessionid.go b/backend/pkg/model/sessionid.go new file mode 100644 index 0000000..251eea9 --- /dev/null +++ b/backend/pkg/model/sessionid.go @@ -0,0 +1,7 @@ +package model + +type SessionIdentifier struct { + ClientID string `bson:"clientId" json:"clientId"` + DeviceID string `bson:"deviceId" json:"deviceId"` + SessionVersion string `bson:"sessionVersion,omitempty" json:"sessionVersion,omitempty"` +} diff --git a/backend/pkg/model/signup_provisioning.go b/backend/pkg/model/signup_provisioning.go new file mode 100644 index 0000000..20eb96d --- /dev/null +++ b/backend/pkg/model/signup_provisioning.go @@ -0,0 +1,150 @@ +package model + +import ( + "strings" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type SignupProvisioningState string + +const ( + SignupProvisioningStateStarted SignupProvisioningState = "started" + SignupProvisioningStateProvisioning SignupProvisioningState = "provisioning" + SignupProvisioningStateCompensating SignupProvisioningState = "compensating" + SignupProvisioningStateFailedPartialClean SignupProvisioningState = "failed_partial_cleanup" + SignupProvisioningStateFailedClean SignupProvisioningState = "failed_clean" + SignupProvisioningStateCompleted SignupProvisioningState = "completed" +) + +type SignupProvisioningCleanupStatus string + +const ( + SignupProvisioningCleanupStatusNotNeeded SignupProvisioningCleanupStatus = "not_needed" + SignupProvisioningCleanupStatusPending SignupProvisioningCleanupStatus = "pending" + SignupProvisioningCleanupStatusRunning SignupProvisioningCleanupStatus = "running" + SignupProvisioningCleanupStatusComplete SignupProvisioningCleanupStatus = "complete" + SignupProvisioningCleanupStatusPartial SignupProvisioningCleanupStatus = "partial" +) + +type SignupProvisioningTarget struct { + Chain ChainNetwork `bson:"chain" json:"chain"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` + ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` +} + +func (t *SignupProvisioningTarget) Normalize() { + if t == nil { + return + } + t.Chain = ChainNetwork(strings.TrimSpace(t.Chain.String())) + t.TokenSymbol = strings.ToUpper(strings.TrimSpace(t.TokenSymbol)) + t.ContractAddress = strings.TrimSpace(t.ContractAddress) +} + +type SignupProvisionedWallet struct { + Chain ChainNetwork `bson:"chain" json:"chain"` + WalletRef string `bson:"walletRef" json:"walletRef"` +} + +func (w *SignupProvisionedWallet) Normalize() { + if w == nil { + return + } + w.Chain = ChainNetwork(strings.TrimSpace(w.Chain.String())) + w.WalletRef = strings.TrimSpace(w.WalletRef) +} + +type SignupProvisionedRoute struct { + WalletRef string `bson:"walletRef" json:"walletRef"` + RouteRef string `bson:"routeRef" json:"routeRef"` +} + +func (r *SignupProvisionedRoute) Normalize() { + if r == nil { + return + } + r.WalletRef = strings.TrimSpace(r.WalletRef) + r.RouteRef = strings.TrimSpace(r.RouteRef) +} + +type SignupProvisioningRecord struct { + storable.Base `bson:",inline" json:",inline"` + + NormalizedLogin string `bson:"normalizedLogin" json:"normalizedLogin"` + OrganizationRef string `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` + AttemptID string `bson:"attemptId" json:"attemptId"` + State SignupProvisioningState `bson:"state" json:"state"` + CleanupStatus SignupProvisioningCleanupStatus `bson:"cleanupStatus" json:"cleanupStatus"` + LeaseOwner string `bson:"leaseOwner" json:"leaseOwner"` + LeaseExpiresAt time.Time `bson:"leaseExpiresAt" json:"leaseExpiresAt"` + HeartbeatAt time.Time `bson:"heartbeatAt" json:"heartbeatAt"` + SelectedTargets []SignupProvisioningTarget `bson:"selectedTargets,omitempty" json:"selectedTargets,omitempty"` + Wallets []SignupProvisionedWallet `bson:"wallets,omitempty" json:"wallets,omitempty"` + Routes []SignupProvisionedRoute `bson:"routes,omitempty" json:"routes,omitempty"` + StepStatus map[string]string `bson:"stepStatus,omitempty" json:"stepStatus,omitempty"` + LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` + LastCleanupError string `bson:"lastCleanupError,omitempty" json:"lastCleanupError,omitempty"` + ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` +} + +func (*SignupProvisioningRecord) Collection() string { + return mservice.SignupProvisioning +} + +func (r *SignupProvisioningRecord) Normalize() { + if r == nil { + return + } + r.NormalizedLogin = strings.ToLower(strings.TrimSpace(r.NormalizedLogin)) + r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) + r.AttemptID = strings.TrimSpace(r.AttemptID) + r.LeaseOwner = strings.TrimSpace(r.LeaseOwner) + r.LastError = strings.TrimSpace(r.LastError) + r.LastCleanupError = strings.TrimSpace(r.LastCleanupError) + + for i := range r.SelectedTargets { + r.SelectedTargets[i].Normalize() + } + for i := range r.Wallets { + r.Wallets[i].Normalize() + } + for i := range r.Routes { + r.Routes[i].Normalize() + } + if r.StepStatus == nil { + r.StepStatus = map[string]string{} + } +} + +func (r *SignupProvisioningRecord) Active() bool { + if r == nil { + return false + } + switch r.State { + case SignupProvisioningStateStarted, SignupProvisioningStateProvisioning, SignupProvisioningStateCompensating: + return true + default: + return false + } +} + +func (r *SignupProvisioningRecord) Terminal() bool { + if r == nil { + return false + } + switch r.State { + case SignupProvisioningStateFailedPartialClean, SignupProvisioningStateFailedClean, SignupProvisioningStateCompleted: + return true + default: + return false + } +} + +func (r *SignupProvisioningRecord) OrganizationObjectID() bson.ObjectID { + ref, _ := bson.ObjectIDFromHex(strings.TrimSpace(r.OrganizationRef)) + return ref +} diff --git a/backend/pkg/model/userdata.go b/backend/pkg/model/userdata.go new file mode 100644 index 0000000..8a56bd1 --- /dev/null +++ b/backend/pkg/model/userdata.go @@ -0,0 +1,30 @@ +package model + +type UserDataBase struct { + Login string `bson:"login" json:"login"` + Locale string `bson:"locale" json:"locale"` +} + +type LoginData struct { + UserDataBase `bson:",inline" json:",inline"` + Password string ` bson:"-" json:"password"` +} + +type AccountData struct { + LoginData `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + LastName string `bson:"lastName" json:"lastName"` +} + +func (ad *AccountData) ToAccount() *Account { + return &Account{ + AccountPublic: AccountPublic{ + AccountBase: AccountBase{ + Describable: ad.Describable, + LastName: ad.LastName, + }, + UserDataBase: ad.UserDataBase, + }, + Password: ad.Password, + } +} diff --git a/backend/pkg/model/verificaton.go b/backend/pkg/model/verificaton.go new file mode 100644 index 0000000..698e977 --- /dev/null +++ b/backend/pkg/model/verificaton.go @@ -0,0 +1,60 @@ +package model + +import ( + "fmt" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type VerificationPurpose string + +const ( + PurposeAccountActivation VerificationPurpose = "account_activation" + PurposeEmailChange VerificationPurpose = "email_change" + PurposePasswordReset VerificationPurpose = "password_reset" + PurposeMagicLogin VerificationPurpose = "magic_login" + PurposeLogin VerificationPurpose = "login" + PurposePayout VerificationPurpose = "payout" +) + +func VPToString(code VerificationPurpose) string { + return string(code) +} + +func VPFromString(s string) (VerificationPurpose, error) { + switch s { + case string(PurposeAccountActivation): + return PurposeAccountActivation, nil + case string(PurposeEmailChange): + return PurposeEmailChange, nil + case string(PurposePasswordReset): + return PurposePasswordReset, nil + case string(PurposeMagicLogin): + return PurposeMagicLogin, nil + case string(PurposeLogin): + return PurposeLogin, nil + case string(PurposePayout): + return PurposePayout, nil + default: + return "", merrors.InvalidArgument(fmt.Sprintf("invalid verification purpose: %s", s)) + } +} + +type VerificationToken struct { + storable.Base `bson:",inline" json:",inline"` + + Target string `bson:"target,omitempty" json:"target"` + ContextRef string `bson:"contextRef,omitempty" json:"contextRef,omitempty"` + AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` + Purpose VerificationPurpose `bson:"purpose" json:"purpose"` + IdempotencyKey *string `bson:"idempotencyKey,omitempty" json:"-"` + VerifyTokenHash string `bson:"verifyTokenHash" json:"-"` + Salt *string `bson:"salt,omitempty" json:"-"` + UsedAt *time.Time `bson:"usedAt,omitempty" json:"-"` + ExpiresAt time.Time `bson:"expiresAt" json:"-"` + MaxRetries *int `bson:"maxRetries,omitempty" json:"-"` + Attempts int `bson:"attempts" json:"-"` +} diff --git a/backend/pkg/model/viewcursor.go b/backend/pkg/model/viewcursor.go new file mode 100644 index 0000000..0143c3f --- /dev/null +++ b/backend/pkg/model/viewcursor.go @@ -0,0 +1,8 @@ +package model + +// ViewCursor aggregates pagination and archival filters for list queries +type ViewCursor struct { + Limit *int64 + Offset *int64 + IsArchived *bool +} diff --git a/backend/pkg/model/wallet.go b/backend/pkg/model/wallet.go new file mode 100644 index 0000000..aae91b6 --- /dev/null +++ b/backend/pkg/model/wallet.go @@ -0,0 +1,25 @@ +package model + +import ( + "fmt" + + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type WalletPaymentData struct { + WalletID string `bson:"walletId" json:"walletId"` +} + +func (m *PaymentMethod) AsWallet() (*WalletPaymentData, error) { + if m.Type != PaymentTypeWallet { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not wallet", m.Type), "type") + } + + var d WalletPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + + return &d, nil +} diff --git a/backend/pkg/model/walletprovisioning.go b/backend/pkg/model/walletprovisioning.go new file mode 100644 index 0000000..616b1bd --- /dev/null +++ b/backend/pkg/model/walletprovisioning.go @@ -0,0 +1,105 @@ +package model + +import ( + "strings" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +) + +type WalletProvisioningState string + +const ( + WalletProvisioningStateProvisioning WalletProvisioningState = "provisioning" + WalletProvisioningStateCompleted WalletProvisioningState = "completed" + WalletProvisioningStateFailed WalletProvisioningState = "failed" +) + +type WalletProvisioningTargetState string + +const ( + WalletProvisioningTargetStatePending WalletProvisioningTargetState = "pending" + WalletProvisioningTargetStateWalletReady WalletProvisioningTargetState = "wallet_ready" + WalletProvisioningTargetStateSuspended WalletProvisioningTargetState = "suspended" + WalletProvisioningTargetStateRouted WalletProvisioningTargetState = "routed" +) + +type WalletProvisioningTarget struct { + TargetID string `bson:"targetId" json:"targetId"` + Chain ChainNetwork `bson:"chain" json:"chain"` + Currency string `bson:"currency" json:"currency"` + ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` + GatewayURI string `bson:"gatewayUri,omitempty" json:"gatewayUri,omitempty"` + WalletIdempotencyKey string `bson:"walletIdempotencyKey,omitempty" json:"walletIdempotencyKey,omitempty"` + State WalletProvisioningTargetState `bson:"state" json:"state"` + WalletRef string `bson:"walletRef,omitempty" json:"walletRef,omitempty"` + Address string `bson:"address,omitempty" json:"address,omitempty"` + RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` + LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` +} + +func (t *WalletProvisioningTarget) Normalize() { + if t == nil { + return + } + t.TargetID = strings.TrimSpace(t.TargetID) + t.Currency = strings.ToUpper(strings.TrimSpace(t.Currency)) + t.ContractAddress = strings.TrimSpace(t.ContractAddress) + if strings.HasPrefix(strings.ToLower(t.ContractAddress), "0x") { + t.ContractAddress = strings.ToLower(t.ContractAddress) + } + t.GatewayID = strings.TrimSpace(t.GatewayID) + t.GatewayURI = strings.TrimSpace(t.GatewayURI) + t.WalletIdempotencyKey = strings.TrimSpace(t.WalletIdempotencyKey) + t.WalletRef = strings.TrimSpace(t.WalletRef) + t.Address = strings.TrimSpace(t.Address) + t.RouteRef = strings.TrimSpace(t.RouteRef) + t.LastError = strings.TrimSpace(t.LastError) + if t.State == "" { + t.State = WalletProvisioningTargetStatePending + } +} + +type WalletProvisioningRecord struct { + storable.Base `bson:",inline" json:",inline"` + + ProvisioningRef string `bson:"provisioningRef" json:"provisioningRef"` + OrganizationRef string `bson:"organizationRef" json:"organizationRef"` + OwnerRef string `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` + LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"` + ActorRef string `bson:"actorRef,omitempty" json:"actorRef,omitempty"` + Reason string `bson:"reason" json:"reason"` + Currency string `bson:"currency" json:"currency"` + State WalletProvisioningState `bson:"state" json:"state"` + Targets []WalletProvisioningTarget `bson:"targets" json:"targets"` + LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` + // Version fences concurrent journal writers: Save replaces only the + // revision this attempt read, so a stale attempt whose lease was taken + // over cannot roll back the checkpoints of the attempt that owns it now. + Version int64 `bson:"version" json:"-"` +} + +func (*WalletProvisioningRecord) Collection() string { + return mservice.WalletProvisioning +} + +func (r *WalletProvisioningRecord) Normalize() { + if r == nil { + return + } + r.ProvisioningRef = strings.TrimSpace(r.ProvisioningRef) + r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) + r.OwnerRef = strings.TrimSpace(r.OwnerRef) + r.LedgerAccountRef = strings.TrimSpace(r.LedgerAccountRef) + r.ActorRef = strings.TrimSpace(r.ActorRef) + r.Reason = strings.TrimSpace(r.Reason) + r.Currency = strings.ToUpper(strings.TrimSpace(r.Currency)) + r.LastError = strings.TrimSpace(r.LastError) + for index := range r.Targets { + r.Targets[index].Normalize() + } + if r.State == "" { + r.State = WalletProvisioningStateProvisioning + } +} diff --git a/backend/pkg/moneybase/fx_rounding.go b/backend/pkg/moneybase/fx_rounding.go new file mode 100644 index 0000000..ec98363 --- /dev/null +++ b/backend/pkg/moneybase/fx_rounding.go @@ -0,0 +1,50 @@ +package moneybase + +import ( + "math/big" + "strings" + + decimalbase "git.sendico.io/tech/sxchnge/bff/pkg/decimal" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" +) + +// SourceRoundedConversionMatches reports whether sourceAmount is the minimum +// source-scale amount that covers targetAmount at rate. +func SourceRoundedConversionMatches(sourceAmount, targetAmount, rate string, sourceScale uint32) (bool, error) { + if err := ValidateScale("source scale", sourceScale); err != nil { + return false, err + } + source, err := positiveRat(sourceAmount, "source amount") + if err != nil { + return false, err + } + target, err := positiveRat(targetAmount, "target amount") + if err != nil { + return false, err + } + rateRat, err := positiveRat(rate, "conversion rate") + if err != nil { + return false, err + } + exactSource := new(big.Rat).Quo(target, rateRat) + roundedSource := ceilDecimalToScale(exactSource, sourceScale) + return roundedSource.Cmp(source) == 0 && source.Cmp(exactSource) >= 0, nil +} + +func positiveRat(value string, field string) (*big.Rat, error) { + parsed, err := decimalbase.RatFromString(strings.TrimSpace(value)) + if err != nil || parsed.Sign() <= 0 { + return nil, merrors.InvalidArgument(field + " must be a positive decimal") + } + return parsed, nil +} + +func ceilDecimalToScale(value *big.Rat, scale uint32) *big.Rat { + factor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) + scaled := new(big.Rat).Mul(value, new(big.Rat).SetInt(factor)) + q, r := new(big.Int).QuoRem(scaled.Num(), scaled.Denom(), new(big.Int)) + if r.Sign() > 0 { + q.Add(q, big.NewInt(1)) + } + return new(big.Rat).SetFrac(q, factor) +} diff --git a/backend/pkg/moneybase/fx_rounding_test.go b/backend/pkg/moneybase/fx_rounding_test.go new file mode 100644 index 0000000..424051f --- /dev/null +++ b/backend/pkg/moneybase/fx_rounding_test.go @@ -0,0 +1,29 @@ +package moneybase + +import ( + "testing" +) + +func TestSourceRoundedConversionMatches(t *testing.T) { + ok, err := SourceRoundedConversionMatches("0.06", "3.72", "73.1275", 2) + if err != nil { + t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) + } + if !ok { + t.Fatal("expected rounded source conversion to match") + } + ok, err = SourceRoundedConversionMatches("0.07", "3.72", "73.1275", 2) + if err != nil { + t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) + } + if ok { + t.Fatal("expected oversized source amount to fail") + } + ok, err = SourceRoundedConversionMatches("0.05", "3.72", "73.1275", 2) + if err != nil { + t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) + } + if ok { + t.Fatal("expected undersized source amount to fail") + } +} diff --git a/backend/pkg/moneybase/scale.go b/backend/pkg/moneybase/scale.go new file mode 100644 index 0000000..8e33fb2 --- /dev/null +++ b/backend/pkg/moneybase/scale.go @@ -0,0 +1,12 @@ +package moneybase + +import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + +const MaxScale uint32 = 36 + +func ValidateScale(field string, scale uint32) error { + if scale > MaxScale { + return merrors.InvalidArgumentf("%s exceeds supported money scale %d", field, MaxScale) + } + return nil +} diff --git a/backend/pkg/mservice/mservice.go b/backend/pkg/mservice/mservice.go new file mode 100644 index 0000000..87e24f6 --- /dev/null +++ b/backend/pkg/mservice/mservice.go @@ -0,0 +1,10 @@ +package mservice + +import ( + "context" +) + +type MicroService interface { + Name() Type + Finish(ctx context.Context) error +} diff --git a/backend/pkg/mservice/services.go b/backend/pkg/mservice/services.go new file mode 100644 index 0000000..c279715 --- /dev/null +++ b/backend/pkg/mservice/services.go @@ -0,0 +1,108 @@ +package mservice + +import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + +type Type = string + +const ( + Accounts Type = "accounts" // Represents user accounts in the system + Verification Type = "verification" // Represents verification code flows + Amplitude Type = "amplitude" // Represents analytics integration with Amplitude + Discovery Type = "discovery" // Represents service discovery registry + Site Type = "site" // Represents public site endpoints + Changes Type = "changes" // Tracks changes made to resources + Clients Type = "clients" // Represents client information + ChainGateway Type = "chain_gateway" // Represents chain gateway microservice + MntxGateway Type = "mcards_gateway" // Represents Monetix gateway microservice + AuroraGateway Type = "aurora_gateway" // Represents Aurora card gateway microservice + AsterBankGateway Type = "asterbank_gateway" // Represents AsterBank local/dev bank payout emulator + MBankGateway Type = "mbank_gateway" // Represents mBank real-money bank payout gateway + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice + FXOracle Type = "fx_oracle" // Represents FX oracle microservice + FXIngestor Type = "fx_ingestor" // Represents FX ingestor microservice + Treasury Type = "treasury" // Represents Treasury microservice + OrgMoneyFlowStats Type = "org_money_flow_stats" // Represents organization money flow statistics + FeePlans Type = "fee_plans" // Represents fee plans microservice + BillingDocuments Type = "billing_documents" // Represents billing documents microservice + BillingFees Type = "billing_fees" // Represents billing fees microservice + AMLSources Type = "aml_sources" // Represents Backoffice AML data-source management + CardBlacklist Type = "card_blacklist" // Represents backoffice card-blacklist management + ComplianceAML Type = "compliance_aml" // Represents AML screening microservice + ComplianceKYC Type = "compliance_kyc" // Represents KYC verification microservice + CompliancePolicy Type = "compliance_policy" // Represents policy enforcement microservice + ComplianceAssessment Type = "compliance_assessment" // Represents durable compliance assessment coordination + CardBlacklistIngestor Type = "card_blacklist_ingestor" // Represents automated card-blacklist ingestion + Limits Type = "limits" // Represents limit reservation and usage enforcement microservice + FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources + Invitations Type = "invitations" // Represents invitations sent to users + Invoices Type = "invoices" // Represents invoices + Logo Type = "logo" // Represents logos for organizations or projects + Ledger Type = "ledger" // Represents ledger microservice + LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice + // LedgerBalances and LedgerEntries gate the resources derived from an + // organization-owned ledger account. The edge Ledger API authorizes them in + // two steps: the account must be visible to the caller (see edge/product + // internal/ledgervisibility), and reading its balance or journal entries + // then requires the matching permission below. Owners of a personal account + // are exempt from the second step. + LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice + LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice + LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice + LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice + LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice + PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice + PaymentQuotation Type = "payment_quotation" // Represents payment quotation microservice + InboundDepositProcessor Type = "crypto_deposits" // Represents inbound crypto deposit processing microservice + ReceivingRoutes Type = "receiving_routes" // Represents inbound receiving route resolution + ChainAssets Type = "chain_assets" // Represents managed chain assets + ChainWallets Type = "chain_wallets" // Represents managed chain wallets + ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances + ChainTransfers Type = "chain_transfers" // Represents chain transfers + ChainDeposits Type = "chain_deposits" // Represents chain deposits + Callbacks Type = "callbacks" // Represents webhook callback subscriptions + ChSettle Type = "chsettle_gateway" // Represents chimera settlement gateway + Notifications Type = "notifications" // Represents notifications sent to users + Organizations Type = "organizations" // Represents organizations in the system + Payments Type = "payments" // Represents payments service + Transactions Type = "transactions" // Represents transaction reporting and investigations + PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + PaymentMethods Type = "payment_methods" // Represents payment methods service + Permissions Type = "permissions" // Represents permissiosns service + Policies Type = "policies" // Represents access control policies + PolicyAssignements Type = "policy_assignments" // Represents policy assignments database + Recipients Type = "recipients" // Represents payment recipients + OrgRecipients Type = "org_recipients" // Represents organization-scoped shared recipients (policy resource type) + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + Roles Type = "roles" // Represents roles in access control + SignupProvisioning Type = "signup_provisioning" // Represents in-flight signup provisioning records + Storage Type = "storage" // Represents statuses of tasks or projects + TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway + TronGateway Type = "tron_gateway" // Represents tron gateway microservice + Tenants Type = "tenants" // Represents tenants managed in the system + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system + Wallets Type = "wallets" // Represents workflows for tasks or projects + WalletProvisioning Type = "wallet_provisioning" // Represents durable wallet and receiving-route provisioning state + WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing + RotatedWallets Type = "rotated_wallets" // Represents backoffice rotated-wallet review and disposition access + Workflows Type = "workflows" // Represents workflows for tasks or projects +) + +const ( + DocumentsReport Type = "documents_report" // Represents employee closing reports + DocumentsPayout Type = "documents_payout" // Represents report-bound RUB payout rows + DocumentsPayoutAttempt Type = "documents_payout_attempt" // Represents immutable payout execution attempts + DocumentsPayoutBatch Type = "documents_payout_batch" // Represents imported payout batches +) + +func StringToSType(s string) (Type, error) { + switch s { + case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletProvisioning, WalletRoutes, ChainWalletBalances, + ChainTransfers, ChainDeposits, Callbacks, MntxGateway, AuroraGateway, AsterBankGateway, MBankGateway, PaymentGateway, FXOracle, FXIngestor, Treasury, OrgMoneyFlowStats, FeePlans, BillingDocuments, DocumentsReport, DocumentsPayout, DocumentsPayoutAttempt, DocumentsPayoutBatch, BillingFees, AMLSources, CardBlacklist, ComplianceAML, ComplianceKYC, CompliancePolicy, ComplianceAssessment, CardBlacklistIngestor, Limits, FilterProjects, Invitations, Invoices, Logo, Ledger, + LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, + Organizations, Payments, Transactions, PaymentRoutes, PaymentOrchestrator, InboundDepositProcessor, ReceivingRoutes, PaymentMethods, Permissions, Policies, PolicyAssignements, + Recipients, RefreshTokens, Roles, SignupProvisioning, Storage, Tenants, Workflows, Discovery, ChSettle, TgSettle, TronGateway, RotatedWallets: + return s, nil + default: + return "", merrors.InvalidArgument("invalid service type", s) + } +} diff --git a/backend/pkg/mservice/services_test.go b/backend/pkg/mservice/services_test.go new file mode 100644 index 0000000..546cbf5 --- /dev/null +++ b/backend/pkg/mservice/services_test.go @@ -0,0 +1,40 @@ +package mservice + +import "testing" + +func TestAMLSourcesResourceTypeIsRecognized(t *testing.T) { + got, err := StringToSType(AMLSources) + if err != nil { + t.Fatalf("AMLSources resource type was rejected: %v", err) + } + if got != AMLSources { + t.Fatalf("AMLSources resource type mismatch: got=%q want=%q", got, AMLSources) + } +} + +func TestCardBlacklistIngestorServiceTypeIsRecognized(t *testing.T) { + got, err := StringToSType(CardBlacklistIngestor) + if err != nil { + t.Fatalf("card blacklist ingestor service type was rejected: %v", err) + } + if got != CardBlacklistIngestor { + t.Fatalf("card blacklist ingestor service type mismatch: got=%q want=%q", got, CardBlacklistIngestor) + } +} + +func TestDocumentsCRUDResourceTypesAreRecognized(t *testing.T) { + for _, resource := range []Type{ + DocumentsReport, + DocumentsPayout, + DocumentsPayoutAttempt, + DocumentsPayoutBatch, + } { + got, err := StringToSType(resource) + if err != nil { + t.Fatalf("documents resource type %q was rejected: %v", resource, err) + } + if got != resource { + t.Fatalf("documents resource type mismatch: got=%q want=%q", got, resource) + } + } +} diff --git a/backend/pkg/mutil/config/param.go b/backend/pkg/mutil/config/param.go new file mode 100644 index 0000000..abb2b10 --- /dev/null +++ b/backend/pkg/mutil/config/param.go @@ -0,0 +1,74 @@ +package mutil + +import ( + "os" + "time" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.uber.org/zap" +) + +func GetConfigValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + return os.Getenv(*envValue) + } + + if value != nil { + return *value + } + + return "" +} + +func GetConfigIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + envStr := os.Getenv(*envValue) + if envStr != "" { + if parsed, err := time.ParseDuration(envStr + "s"); err == nil { + return int(parsed.Seconds()) + } + logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr)) + } + } + + if value != nil { + return *value + } + + return 30 // Default timeout in seconds +} + +func GetConfigBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool { + if value != nil && envValue != nil { + logger.Warn("Both variable and environment variable are set, using environment variable value", + zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue))) + } + + if envValue != nil { + envStr := os.Getenv(*envValue) + switch envStr { + case "true", "1": + return true + case "false", "0": + return false + default: + logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr)) + } + } + + if value != nil { + return *value + } + + return false // Default for boolean +} diff --git a/backend/pkg/mutil/db/archive.go b/backend/pkg/mutil/db/archive.go new file mode 100644 index 0000000..daf901f --- /dev/null +++ b/backend/pkg/mutil/db/archive.go @@ -0,0 +1,37 @@ +package mutil + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func SetArchived[T storable.Storable](ctx context.Context, logger mlogger.Logger, newArchived bool, objectRef bson.ObjectID, repo repository.Repository) error { + objs, err := GetObjects[T](ctx, logger, repository.IDFilter(objectRef), nil, repo) + if err != nil { + logger.Warn("Failed to fetch object", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) + return err + } + + if len(objs) == 0 { + logger.Warn("No objects found to archive", mzap.ObjRef("object_ref", objectRef)) + return nil + } + + // Archive the first object found + obj := objs[0] + if archivable, ok := any(obj).(interface{ SetArchived(bool) }); ok { + archivable.SetArchived(newArchived) + if err := repo.Update(ctx, obj); err != nil { + logger.Warn("Failed to update object archived status", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) + return err + } + } + + return nil +} diff --git a/backend/pkg/mutil/db/array.go b/backend/pkg/mutil/db/array.go new file mode 100644 index 0000000..7d63abc --- /dev/null +++ b/backend/pkg/mutil/db/array.go @@ -0,0 +1,26 @@ +package mutil + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +func GetObjects[T any](ctx context.Context, logger mlogger.Logger, filter builder.Query, cursor *model.ViewCursor, repo repository.Repository) ([]T, error) { + entities := make([]T, 0) + decoder := func(cur *mongo.Cursor) error { + var next T + if e := cur.Decode(&next); e != nil { + logger.Warn("Failed to decode entity", zap.Error(e)) + return e + } + entities = append(entities, next) + return nil + } + return entities, repo.FindManyByFilter(ctx, repository.ApplyCursor(filter, cursor), decoder) +} diff --git a/backend/pkg/mutil/db/auth/accountbound.go b/backend/pkg/mutil/db/auth/accountbound.go new file mode 100644 index 0000000..c550623 --- /dev/null +++ b/backend/pkg/mutil/db/auth/accountbound.go @@ -0,0 +1,89 @@ +// Package mutil provides utility functions for working with account-bound objects +// with permission enforcement. +// +// Example usage: +// +// // Using the low-level repository approach +// objects, err := mutil.GetAccountBoundObjects[model.ProjectFilter]( +// ctx, logger, accountRef, orgRef, model.ActionRead, +// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, +// enforcer, repo) +// +// // Using the AccountBoundDB interface approach +// objects, err := mutil.GetAccountBoundObjectsFromDB[model.ProjectFilter]( +// ctx, logger, accountRef, orgRef, +// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, +// accountBoundDB) +package mutil + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +// GetAccountBoundObjects retrieves account-bound objects with permission enforcement. +// This function handles the complex logic of: +// 1. Finding objects where accountRef matches OR is null/absent +// 2. Checking organization-level permissions for each object +// 3. Filtering to only objects the account has permission to read +func GetAccountBoundObjects[T any]( + ctx context.Context, + logger mlogger.Logger, + accountRef, organizationRef bson.ObjectID, + filter builder.Query, + cursor *model.ViewCursor, + enforcer auth.Enforcer, + repo repository.Repository, +) ([]T, error) { + // Build query to find objects where accountRef matches OR is null/absent + accountQuery := repository.WithOrg(accountRef, organizationRef) + + // Get all account-bound objects that match the criteria + allObjects, err := repo.ListAccountBound(ctx, repository.ApplyCursor(accountQuery, cursor)) + if err != nil { + if !errors.Is(err, merrors.ErrNoData) { + logger.Warn("Failed to fetch account bound objects", zap.Error(err), + mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), + ) + } else { + logger.Debug("No matching account bound objects found", zap.Error(err), + mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), + ) + } + return nil, err + } + + if len(allObjects) == 0 { + return nil, merrors.NoData("no_account_bound_objects_found") + } + + allowed := make([]bson.ObjectID, 0, len(allObjects)) + for _, ref := range allObjects { + allowed = append(allowed, *ref.GetID()) + } + if len(allowed) == 0 { + return nil, merrors.NoData("no_data_found_or_allowed") + } + + logger.Debug("Successfully retrieved account bound objects", + zap.Int("total_count", len(allObjects)), + mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), + zap.Any("objs", allObjects), + ) + + return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) +} diff --git a/backend/pkg/mutil/db/auth/protected.go b/backend/pkg/mutil/db/auth/protected.go new file mode 100644 index 0000000..352ef9c --- /dev/null +++ b/backend/pkg/mutil/db/auth/protected.go @@ -0,0 +1,58 @@ +package mutil + +import ( + "context" + "errors" + + "git.sendico.io/tech/sxchnge/bff/pkg/auth" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func GetProtectedObjects[T any]( + ctx context.Context, + logger mlogger.Logger, + accountRef, organizationRef bson.ObjectID, + action model.Action, + filter builder.Query, + cursor *model.ViewCursor, + enforcer auth.Enforcer, + repo repository.Repository, +) ([]T, error) { + refs, err := repo.ListPermissionBound(ctx, repository.ApplyCursor(filter, cursor)) + if err != nil { + if !errors.Is(err, merrors.ErrNoData) { + logger.Warn("Failed to fetch object IDs", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("action", string(action))) + } else { + logger.Debug("No matching IDs found", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("action", string(action))) + } + return nil, err + } + res, err := enforcer.EnforceBatch(ctx, refs, accountRef, action) + if err != nil { + logger.Warn("Failed to enforce object IDs", zap.Error(err), mzap.AccRef(accountRef), + mzap.OrgRef(organizationRef), zap.String("action", string(action))) + return nil, err + } + + allowed := make([]bson.ObjectID, 0, len(res)) + for _, ref := range refs { + if ok := res[*ref.GetID()]; ok { + allowed = append(allowed, *ref.GetID()) + } + } + if len(allowed) == 0 { + return nil, merrors.NoData("no_data_found_or_allowed") + } + + return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) +} diff --git a/backend/pkg/mutil/db/db.go b/backend/pkg/mutil/db/db.go new file mode 100644 index 0000000..646313b --- /dev/null +++ b/backend/pkg/mutil/db/db.go @@ -0,0 +1,20 @@ +package mutil + +import ( + "context" + + "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func GetObjectByID(ctx context.Context, logger mlogger.Logger, id string, val storable.Storable, repo repository.Repository) error { + p, err := bson.ObjectIDFromHex(id) + if err != nil { + logger.Warn("Failed to decode object reference", zap.String("reference", id), zap.String("collection", val.Collection())) + return err + } + return repo.Get(ctx, p, val) +} diff --git a/backend/pkg/mutil/duration/duration.go b/backend/pkg/mutil/duration/duration.go new file mode 100644 index 0000000..c27a304 --- /dev/null +++ b/backend/pkg/mutil/duration/duration.go @@ -0,0 +1,7 @@ +package mduration + +import "time" + +func Param2Duration(param int, d time.Duration) time.Duration { + return time.Duration(param) * d +} diff --git a/backend/pkg/mutil/mask/mask.go b/backend/pkg/mutil/mask/mask.go new file mode 100644 index 0000000..9d3ea79 --- /dev/null +++ b/backend/pkg/mutil/mask/mask.go @@ -0,0 +1,29 @@ +package mask + +import "strings" + +func Email(email string) string { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return email + } + local := parts[0] + if len(local) > 2 { + local = local[:1] + "***" + local[len(local)-1:] + } else { + local = local[:1] + "***" + } + return local + "@" + parts[1] +} + +func Emails(emails []string) []string { + if emails == nil { + return nil + } + + masked := make([]string, len(emails)) + for i, email := range emails { + masked[i] = Email(email) + } + return masked +} diff --git a/backend/pkg/mutil/mask/mask_test.go b/backend/pkg/mutil/mask/mask_test.go new file mode 100644 index 0000000..6c723a9 --- /dev/null +++ b/backend/pkg/mutil/mask/mask_test.go @@ -0,0 +1,21 @@ +package mask + +import ( + "reflect" + "testing" +) + +func TestEmailsMasksEachEmail(t *testing.T) { + got := Emails([]string{"alice@example.com", "bo@example.org", "invalid"}) + want := []string{"a***e@example.com", "b***@example.org", "invalid"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Emails() = %#v, want %#v", got, want) + } +} + +func TestEmailsPreservesNil(t *testing.T) { + if got := Emails(nil); got != nil { + t.Fatalf("Emails(nil) = %#v, want nil", got) + } +} diff --git a/backend/pkg/mutil/mzap/account.go b/backend/pkg/mutil/mzap/account.go new file mode 100644 index 0000000..036179a --- /dev/null +++ b/backend/pkg/mutil/mzap/account.go @@ -0,0 +1,28 @@ +package mzap + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func AccRef(accountRef bson.ObjectID) zap.Field { + return ObjRef("account_ref", accountRef) +} + +func Email(email string) zap.Field { + return MaskEmail("email", email) +} + +func MaskEmail(field, email string) zap.Field { + return zap.String(field, mask.Email(email)) +} + +func MaskEmails(field string, emails []string) zap.Field { + return zap.Strings(field, mask.Emails(emails)) +} + +func Login(account *model.Account) zap.Field { + return Email(account.Login) +} diff --git a/backend/pkg/mutil/mzap/error.go b/backend/pkg/mutil/mzap/error.go new file mode 100644 index 0000000..e2c47cb --- /dev/null +++ b/backend/pkg/mutil/mzap/error.go @@ -0,0 +1,14 @@ +package mzap + +import ( + "reflect" + + "go.uber.org/zap" +) + +func ErrorType(err error) zap.Field { + if err == nil { + return zap.String("error_type", "") + } + return zap.String("error_type", reflect.TypeOf(err).String()) +} diff --git a/backend/pkg/mutil/mzap/object.go b/backend/pkg/mutil/mzap/object.go new file mode 100644 index 0000000..e0fdd5c --- /dev/null +++ b/backend/pkg/mutil/mzap/object.go @@ -0,0 +1,28 @@ +package mzap + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func ObjRef(name string, objRef bson.ObjectID) zap.Field { + return zap.String(name, objRef.Hex()) +} + +func StorableRef(obj storable.Storable) zap.Field { + return ObjRef(obj.Collection()+"_ref", *obj.GetID()) +} + +func OrgRef(orgRef bson.ObjectID) zap.Field { + return ObjRef("organization_ref", orgRef) +} + +func OrganizationRef(obj model.OrganizationBound) zap.Field { + return OrgRef(obj.GetOrganizationRef()) +} + +func PermissionRef(permissionRef bson.ObjectID) zap.Field { + return ObjRef("permission_ref", permissionRef) +} diff --git a/backend/pkg/person/name.go b/backend/pkg/person/name.go new file mode 100644 index 0000000..862fbd7 --- /dev/null +++ b/backend/pkg/person/name.go @@ -0,0 +1,38 @@ +package person + +import "strings" + +// Name represents a person's first and last name as a reusable value object. +type Name struct { + FirstName string `bson:"firstName,omitempty" json:"first_name,omitempty"` + LastName string `bson:"lastName,omitempty" json:"last_name,omitempty"` +} + +func (n Name) Trim() Name { + return Name{ + FirstName: strings.TrimSpace(n.FirstName), + LastName: strings.TrimSpace(n.LastName), + } +} + +func (n Name) WithFallback(fallback Name) Name { + n = n.Trim() + fallback = fallback.Trim() + if n.FirstName == "" { + n.FirstName = fallback.FirstName + } + if n.LastName == "" { + n.LastName = fallback.LastName + } + return n +} + +func (n Name) FullName() string { + n = n.Trim() + return strings.TrimSpace(n.FirstName + " " + n.LastName) +} + +func (n Name) IsZero() bool { + n = n.Trim() + return n.FirstName == "" && n.LastName == "" +} diff --git a/backend/pkg/person/name_test.go b/backend/pkg/person/name_test.go new file mode 100644 index 0000000..828b4f7 --- /dev/null +++ b/backend/pkg/person/name_test.go @@ -0,0 +1,14 @@ +package person + +import "testing" + +func TestNameWithFallbackUsesFallbackPerField(t *testing.T) { + name := Name{FirstName: " Ivan "} + fallback := Name{FirstName: "FallbackFirst", LastName: " Ivanov "} + + got := name.WithFallback(fallback) + want := Name{FirstName: "Ivan", LastName: "Ivanov"} + if got != want { + t.Fatalf("name mismatch: got=%+v want=%+v", got, want) + } +} diff --git a/backend/pkg/server/factory.go b/backend/pkg/server/factory.go new file mode 100644 index 0000000..fe572db --- /dev/null +++ b/backend/pkg/server/factory.go @@ -0,0 +1,5 @@ +package server + +import "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + +type ServerFactoryT = func(logger mlogger.Logger, file string, debug bool) (Application, error) diff --git a/backend/pkg/server/internal/instance.go b/backend/pkg/server/internal/instance.go new file mode 100644 index 0000000..318dc52 --- /dev/null +++ b/backend/pkg/server/internal/instance.go @@ -0,0 +1,40 @@ +package serverimp + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/bff/pkg/server" + "go.uber.org/zap" +) + +type Instance struct { + srv server.Application + logger mlogger.Logger + file string + debug bool + factory server.ServerFactoryT +} + +func (i *Instance) Start() error { + var err error + if i.srv, err = i.factory(i.logger, i.file, i.debug); err != nil { + i.logger.Warn("Failed to create server instance", zap.Error(err)) + return err + } + return i.srv.Start() +} + +func (i *Instance) Shutdown() { + if i.srv != nil { + i.srv.Shutdown() + } +} + +func NewInstance(factory server.ServerFactoryT, logger mlogger.Logger, file string, debug bool) *Instance { + return &Instance{ + srv: nil, + logger: logger, + file: file, + debug: debug, + factory: factory, + } +} diff --git a/backend/pkg/server/internal/server.go b/backend/pkg/server/internal/server.go new file mode 100644 index 0000000..a190f56 --- /dev/null +++ b/backend/pkg/server/internal/server.go @@ -0,0 +1,66 @@ +package serverimp + +import ( + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + lf "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" + "git.sendico.io/tech/sxchnge/bff/pkg/server" + "git.sendico.io/tech/sxchnge/bff/pkg/version" + "github.com/google/uuid" + "go.uber.org/zap" +) + +var ( + configFileFlag = flag.String("config.file", "config.yml", "Path to the configuration file.") + versionFlag = flag.Bool("version", false, "Show version information.") + debugFlag = flag.Bool("debug", false, "Show debug information.") +) + +func prepareLogger() mlogger.Logger { + flag.Parse() + + return lf.NewLogger(*debugFlag) +} + +func RunServer(rootLoggerName string, av version.Printer, factory server.ServerFactoryT) error { + logger := mlogger.Named(prepareLogger(), rootLoggerName) + logger = logger.With(zap.String("instance_id", uuid.NewString())) + defer func() { + _ = logger.Sync() + }() + + // Show version information + if *versionFlag { + if _, err := fmt.Fprintln(os.Stdout, av.Print()); err != nil { + logger.Warn("Failed to print version", zap.Error(err)) + } + return nil + } + + // Create server instance + instance := NewInstance(factory, logger, *configFileFlag, *debugFlag) + + // Interrupt handler + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + sig := <-c + logger.Info("Received sigint/segterm signal, shutting down", zap.String("signal", sig.String())) + instance.Shutdown() + }() + + // Start server + logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info())) + logger.Info("Build context", zap.String("context", av.Context())) + if err := instance.Start(); err != nil { + logger.Error("Failed to start service", zap.Error(err)) + return err + } + logger.Info("Server stopped") + return nil +} diff --git a/backend/pkg/server/main/run.go b/backend/pkg/server/main/run.go new file mode 100644 index 0000000..483a7da --- /dev/null +++ b/backend/pkg/server/main/run.go @@ -0,0 +1,11 @@ +package server + +import ( + sd "git.sendico.io/tech/sxchnge/bff/pkg/server" + serverimp "git.sendico.io/tech/sxchnge/bff/pkg/server/internal" + "git.sendico.io/tech/sxchnge/bff/pkg/version" +) + +func RunServer(rootLoggerName string, av version.Printer, factory sd.ServerFactoryT) error { + return serverimp.RunServer(rootLoggerName, av, factory) +} diff --git a/backend/pkg/server/server.go b/backend/pkg/server/server.go new file mode 100644 index 0000000..1272190 --- /dev/null +++ b/backend/pkg/server/server.go @@ -0,0 +1,7 @@ +package server + +// Typical service interface +type Application interface { + Shutdown() + Start() error +} diff --git a/backend/pkg/version/factory/factory.go b/backend/pkg/version/factory/factory.go new file mode 100644 index 0000000..d805562 --- /dev/null +++ b/backend/pkg/version/factory/factory.go @@ -0,0 +1,10 @@ +package version + +import ( + "git.sendico.io/tech/sxchnge/bff/pkg/version" + versionimp "git.sendico.io/tech/sxchnge/bff/pkg/version/internal" +) + +func Create(info *version.Info) version.Printer { + return versionimp.Create(info) +} diff --git a/backend/pkg/version/info.go b/backend/pkg/version/info.go new file mode 100644 index 0000000..c9a9ce5 --- /dev/null +++ b/backend/pkg/version/info.go @@ -0,0 +1,9 @@ +package version + +type Printer interface { + Print() string + Short() string + Info() string + Context() string + Program() string +} diff --git a/backend/pkg/version/internal/version.go b/backend/pkg/version/internal/version.go new file mode 100644 index 0000000..dd159d6 --- /dev/null +++ b/backend/pkg/version/internal/version.go @@ -0,0 +1,130 @@ +package versionimp + +import ( + "bytes" + "fmt" + "regexp" + "runtime" + "strings" + "text/template" + + "git.sendico.io/tech/sxchnge/bff/pkg/version" +) + +// versionInfoTmpl contains the template used by Info. +var versionInfoTmpl = ` +{{.program}}, version {{.version}} (branch: {{.branch}}, revision: {{.revision}}) + build user: {{.buildUser}} + build date: {{.buildDate}} + go version: {{.goVersion}} + go OS: {{.goOS}} + go Architecture: {{.goArch}} +` + +func parseVersion(input, revision string) (string, string) { + var version string + + // Regular expression to match different version formats + re := regexp.MustCompile(`^v?(\d+\.\d+\.\d+)(?:-\d+)?(?:-([0-9a-zA-Z]+))?$`) + matches := re.FindStringSubmatch(input) + + if len(matches) > 0 { + version = matches[1] // Capture the version number + if len(matches) >= 3 && matches[2] != "" { + revision = matches[2] // Update the revision if present + } + // If no new revision part is found, the original revision remains unchanged + } else { + // Handle the case where the input does not match the expected format + version = input // Default to the input as is + } + + return version, revision +} + +type Imp struct { + info version.Info + GoVersion string + GoOS string + GoArch string +} + +func (i *Imp) prepareVersion() { + i.info.Version, i.info.Revision = parseVersion(i.info.Version, i.info.Revision) +} + +// Print returns version information. +func (i *Imp) Print() string { + i.prepareVersion() + m := map[string]string{ + "program": i.info.Program, + "version": i.info.Version, + "revision": i.info.Revision, + "branch": i.info.Branch, + "buildUser": i.info.BuildUser, + "buildDate": i.info.BuildDate, + "goVersion": i.GoVersion, + "goOS": i.GoOS, + "goArch": i.GoArch, + } + t, err := template.New("version").Parse(versionInfoTmpl) + if err != nil { + return renderVersionFallback(m) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, m); err != nil { + return renderVersionFallback(m) + } + return strings.TrimSpace(buf.String()) +} + +func renderVersionFallback(values map[string]string) string { + return strings.TrimSpace(fmt.Sprintf(` +%s, version %s (branch: %s, revision: %s) + build user: %s + build date: %s + go version: %s + go OS: %s + go Architecture: %s +`, + values["program"], + values["version"], + values["branch"], + values["revision"], + values["buildUser"], + values["buildDate"], + values["goVersion"], + values["goOS"], + values["goArch"], + )) +} + +func (i *Imp) Short() string { + if len(i.info.Revision) == 0 { + return i.info.Version + } + return fmt.Sprintf("%s-%s", i.info.Version, i.info.Revision) +} + +func (i *Imp) Info() string { + i.prepareVersion() + return fmt.Sprintf("(version=%s, branch=%s, revision=%s)", i.info.Version, i.info.Branch, i.info.Revision) +} + +func (i *Imp) Context() string { + return fmt.Sprintf("(go=%s, OS=%s, arch=%s user=%s, date=%s)", i.GoVersion, i.GoOS, i.GoArch, i.info.BuildUser, i.info.BuildDate) +} + +func (i *Imp) Program() string { + return i.info.Program +} + +func Create(info *version.Info) *Imp { + return &Imp{ + info: *info, + GoVersion: runtime.Version(), + GoOS: runtime.GOOS, + GoArch: runtime.GOARCH, + } +} diff --git a/backend/pkg/version/version.go b/backend/pkg/version/version.go new file mode 100644 index 0000000..7ab617f --- /dev/null +++ b/backend/pkg/version/version.go @@ -0,0 +1,10 @@ +package version + +type Info struct { + Program string + Version string + Revision string + Branch string + BuildUser string + BuildDate string +} diff --git a/backend/scripts/seed-policies.js b/backend/scripts/seed-policies.js new file mode 100644 index 0000000..16bc9f0 --- /dev/null +++ b/backend/scripts/seed-policies.js @@ -0,0 +1,88 @@ +// Adapted from Sendico's canonical Product policy seed; see PROVENANCE.md. +// Run only against the SXCHNGE-owned database. This seeds descriptions only: +// no users, organization, role grants or provider resources are created. +const databaseName = process.env.SXCHNGE_MONGO_DATABASE || 'sxchnge'; +const targetDb = db.getSiblingDB(databaseName); +const now = new Date(); + +const canonicalPolicies = [ + { + _id: ObjectId('677a61569cf14638b34423e0'), + name: 'Organization Settings', + description: 'Access to organization settings', + resourceTypes: ['organizations'], + }, + { + _id: ObjectId('67bb6ec0da91191ffeb47012'), + name: 'Accounts', + description: 'Access to accounts settings', + resourceTypes: ['accounts'], + }, + { + _id: ObjectId('67b3d7bd452192e77a26b22d'), + name: 'Roles', + description: 'Access to roles management', + resourceTypes: ['roles', 'policies'], + }, +]; + +upsertCanonicalPolicies(); + +function builtInFilter(extra) { + return Object.assign( + { + $or: [ + { organizationRef: { $exists: false } }, + { organizationRef: null }, + ], + }, + extra, + ); +} + +function upsertCanonicalPolicies() { + const result = []; + for (const entry of canonicalPolicies) { + let existing = targetDb.policies.findOne({ _id: entry._id }); + if (!existing) { + // Migration-created policies carry environment-specific ids. + existing = targetDb.policies.findOne( + builtInFilter({ resourceTypes: { $all: entry.resourceTypes } }), + ); + } + if (existing) { + const patch = {}; + if (existing.name !== entry.name) patch.name = entry.name; + if (existing.description !== entry.description) { + patch.description = entry.description; + } + if ( + JSON.stringify(existing.resourceTypes || []) !== + JSON.stringify(entry.resourceTypes) + ) { + patch.resourceTypes = entry.resourceTypes; + } + if (Object.keys(patch).length > 0) { + patch.updatedAt = now; + targetDb.policies.updateOne({ _id: existing._id }, { $set: patch }); + print(`Patched built-in policy "${entry.name}" (${existing._id.toHexString()})`); + } else { + print(`Built-in policy "${entry.name}" is up to date`); + } + result.push({ entry, id: existing._id, inserted: false }); + continue; + } + + targetDb.policies.insertOne({ + _id: entry._id, + createdAt: now, + updatedAt: now, + name: entry.name, + description: entry.description, + resourceTypes: entry.resourceTypes, + }); + print(`Seeded built-in policy "${entry.name}" (${entry._id.toHexString()})`); + result.push({ entry, id: entry._id, inserted: true }); + } + return result; +} diff --git a/docs/architecture/bff.md b/docs/architecture/bff.md index 989402a..9151fa9 100644 --- a/docs/architecture/bff.md +++ b/docs/architecture/bff.md @@ -1,300 +1,125 @@ -# Independent SXCHNGE BFF — task #34 +# SXCHNGE BFF and Sendico public API boundary -## Decision and delivery scope +SXCHNGE is one organization in Sendico, just like any other organization. +SXCHNGE owns its customers, registration, credentials, sessions, profile settings +and application UX. Sendico is the financial platform. All provider access goes +through its public HTTP API using SXCHNGE's organization and an integration +principal. A customer is not a Sendico organization or the integration principal. -Build a small, independently deployed Go BFF in `backend/`. Adapt selected Sendico -code into SXCHNGE ownership; treat Sendico as an external financial provider through -a versioned partner contract. Use one BFF process initially, with a worker entrypoint -from the same module when durable asynchronous work is introduced. Do not reproduce -Sendico's service topology inside SXCHNGE. +This replaces the earlier proposal for a new BFF shell and delegated Sendico +customer provisioning. The starting point is copied Sendico BFF code, maintained +and allowed to diverge within this repository. -This contribution delivers the architecture/reuse audit and a tested operational -starter. It does **not** implement account persistence, real authentication, email, -KYC uploads, money operations or a live Sendico adapter. The starter has health -and explicitly unconfigured readiness endpoints. Selected browser-session, -opaque-credential and evidence-key components are adapted now; the larger flows -below are planned extractions, not claims of completed integration. +## Copied starting point -Audit basis: local `S_I/sendico` checkout, branch `compliance-ingestor-2167`, commit -`8bb20dcd622dd8eb92d289b6b34411e7eccab083`, inspected 2026-09-12. The checkout has -unrelated dependency-manifest edits, which were not copied or modified. Findings -establish source capabilities at that revision, not deployment availability, -external API support, or production readiness. See [source references](#source-references) -and [reuse provenance](../../backend/PROVENANCE.md). +Source: `S_I/sendico`, commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. +The source checkout's unrelated dependency edits were not copied. -## Ownership and separation - -```mermaid -flowchart LR - UI[Flutter web / mobile] --> BFF[SXCHNGE BFF\nconsumer API and authorization] - BFF --> DB[(SXCHNGE database\nsessions, mappings, commands)] - BFF --> MAIL[Email delivery adapter] - BFF --> ADAPTER[SXCHNGE Sendico adapter\nDTOs and mapping] - ADAPTER --> EDGE[Sendico partner API\nauthenticated customer scope] - EDGE --> FIN[Sendico ledger, quotes, payments\nlimits, compliance, inbound] - EDGE --> EVENTS[Signed callback delivery] - EVENTS --> BFF -``` - -| Owner | Responsibilities | +| Local component | Reused implementation | | --- | --- | -| SXCHNGE | Consumer accounts/passwords, email confirmation, sessions, language, MFA policy/enrollment, local customer mapping, user authorization, operation intents, idempotency/outbox/inbox, product DTOs and safe status explanations | -| Sendico | Canonical financial accounts and posted money, spendability rules, currency/rail catalog, quotes/rates/fees, execution/settlement, authoritative limits and reservations, KYC evidence/results, compliance decisions and review workflows | -| Provider boundary | Authenticated partner principal, organization and customer isolation, stable identifiers, versioned transport, error/status semantics, replay guarantees, signed events and recovery reads | +| `backend/edge/shared` | Chi HTTP runtime, middleware, JWT login/pending sessions, refresh/rotation/logout, CSRF/cookies, rate limiting, Mongo verification and refresh-token stores | +| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, creation, local membership/roles, activation tokens, password reset and email-change token issuance, deletion | +| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, authorization, errors, structured logging, process bootstrap and health | +| `backend/internal/profile` | Small SXCHNGE composition layer selecting the copied local account service | +| `backend/contracts/sendico` | Separate snapshot of Sendico's public OpenAPI contract and its referenced schemas | -Sendico integration credentials stay exclusively in the BFF's secret configuration. -SXCHNGE users do not receive Sendico credentials, internal gRPC addresses, database -access, or membership/admin privileges on its integration organization. The BFF -never repairs balances by posting arbitrary ledger entries and never independently -reserves limits already owned by Sendico's payment execution. +Source provenance and the bounded adaptations are in +[PROVENANCE.md](../../backend/PROVENANCE.md). There is no sibling source import, +private Sendico module requirement, gRPC client, discovery subscription or broker +connection. Platform engines and Product's platform-provisioning bootstrap were +not copied. The original handwritten shell and tiny replacement helpers were removed. -### Three identities must stay distinct +The existing local account service retains local organization membership and role +semantics. Use one SXCHNGE local tenant when wiring registration; this does not +create an organization in Sendico for each customer. Local database collections, +JWT keys and registered application clients belong exclusively to SXCHNGE. -Persist a server-owned mapping from `sxchnge_user_id` to a Sendico provider scope: -`organization_ref`, `customer_ref`, `compliance_subject_ref`, and, where Sendico's -ownership contracts require it, `ledger_owner_account_ref`. These fields are not -assumed equal. The partner integration principal is a fifth, separate identity. -Use unique indexes for local user/provider and provider organization/customer. -Provisioning is idempotent using a stable external customer reference. +## Identity and ownership -Do not accept organization, customer, initiator or owner refs from browser payloads. -Resolve local account/quote/operation IDs under the authenticated user first; send -only mapped refs. Sendico must enforce the same scope independently. List filtering -is mandatory before results are returned; direct reads must return the same safe -not-found outcome for missing and inaccessible objects. +Keep three distinct identifiers: -**Blocking contract gap:** the inspected protocols have customer-related fields, -but no customer provisioning RPC was found. Current ledger/payment ownership is -expressed with Sendico account refs; current personal-compliance HTTP routes resolve -organizations owned by the logged-in account. Neither is automatically a delegated -customer API for many SXCHNGE users under one organization. Establish a supported -mapping/provisioning facade on Sendico before connecting customer money or KYC. -Do not create a new Sendico organization per consumer to work around this. +1. The configured SXCHNGE Sendico organization reference. +2. SXCHNGE's backend integration principal, with the public API permissions it needs. +3. The authenticated local customer and its persisted provider resource references. -## Reuse decisions +The browser supplies neither the authoritative organization nor customer owner. +Resolve both from server configuration and the authenticated local customer. +Validate ownership before returning balances/history or submitting operations. +Never give a customer organization-wide data because the integration principal +can see it. Do not pass SXCHNGE customer passwords or session tokens to Sendico. -| Sendico starting point | Decision | SXCHNGE adaptation | -| --- | --- | --- | -| `edge/shared/auth/browser/browser.go` | Adapted now | Own `browser` package; fixed `__Host-sxchnge_session`, explicit HTTPS origins, server-selected cookie routes, no client-header CSRF bypass, same-site Lax cookie for return navigation; no Sendico error/config imports | -| `edge/shared/storage/verification/hash.go` | Selected functions adapted now | 32-byte random opaque credentials and SHA-256 storage digests; constant-time matching; no deterministic magic links or short-OTP hashing copied | -| `edge/shared/storage/s3key/key.go` | Adapted now | Own evidence object-key confinement and errors, additional encoded/backslash/control-character rejection; not an ownership check or local-filesystem sanitizer | -| `edge/shared/storage/verification/{create,consume,consume_update,db}.go` | Adapt in authentication phase | Account + purpose + operation binding, atomic consumption, expiry checked during reads, idempotent issuance, cooldown/attempt indexes. Replace model/repository/logging/notification dependencies with SXCHNGE contracts | -| `edge/shared/storage/refreshtokens/{filters,crud,revoke}.go` | Adapt model and persistence patterns in session phase | Session families/versioning, account binding, rotation and revocation; own database and credential format. Do not copy account-bound API-client login wholesale | -| `edge/shared/.../router/public/{login,refresh,logout}.go` | Adapt flow and tests | Pending login before session issuance; uniform credential failures; confirmed logout and refresh recovery. Preserve password input verbatim, unlike the inspected decoder's trim behavior | -| `edge/product/internal/server/paymentapiimp/{personal_source,visibility,quote,payquote}.go` | Adapt orchestration/authorization rules | Mandatory customer ownership even when partner has organization-wide rights; request-scoped deadlines, quote-bound execution, safe not-found projection. No BSON/permissions/discovery types in SXCHNGE models | -| `edge/product/internal/compliance/{read_service,upload_service,submit_service}.go` | Adapt orchestration pattern | Separate status/limits slices, bounded evidence ingestion, ownership, submission idempotency. Replace “account owns organization” assumption with provider customer mapping | -| `edge/callbacks/internal/signing/service.go` | Implement matching receiver after contract confirmation | Use approved wire algorithm and exact raw-body verification, bounded clock skew, event-ID inbox deduplication, reconciliation reads. Do not copy Vault-backed outbound sender into receiver | -| Ledger, limits, FX, AML/policy, gateways, treasury, KYC engines | External services | Call the partner facade; no cloned financial/compliance state machines | -| Sendico `pkg`, generic Mongo repositories, NATS/discovery bootstrapping, BFF router/DI graph | Do not import wholesale | Small local packages and explicit construction; no sibling Go replacements, shared database, submodule, automatic upstream sync or Sendico runtime dependencies | +Sendico's `/accounts/login/api` is an existing password/client-secret grant with +client/account binding and IP policy, not a generic OAuth client-credentials flow. +Its tokens are server-side platform credentials, separate from customer sessions. +Confirm the deployed credential policy before configuring a provider client. -Reuse includes meaningful tests and behavioral invariants, not just filenames. -Each extraction records its source commit and intentional deviations. Future -Sendico fixes are assessed and ported selectively through SXCHNGE PRs. Contract -fixtures/version checks protect the adapter as the products diverge. +## Contract and service structure -## BFF structure +The copied OpenAPI files are a pinned upstream wire contract, not SXCHNGE's app +API or domain model. Preserve them as an isolated snapshot. Changes require an +explicit contract update; do not follow Sendico source updates automatically. -Use a single Go module and explicit constructor injection. The following is the -**target layout**; only the shell and three selected foundations exist today. -Create additional directories when their first use case is implemented. +When wiring a feature, keep the existing architecture: -```text -backend/ - cmd/bff/ HTTP process and graceful shutdown - cmd/worker/ future outbox / provider reconciliation worker - internal/httpapi/ routes, HTTP DTOs, mappers, stable errors - internal/identity/ users, registration, email confirmation - internal/session/ authenticated principal, restore/rotate/revoke - internal/verification/ challenges, factors, operation approvals - internal/profile/ account preferences - internal/wallet/ accounts and spendable-balance projection - internal/operations/ quotes, commands, status, history - internal/compliance/ KYC and visible limits use cases - internal/provider/ narrow customer/financial provider ports - internal/sendico/ external DTOs, HTTP client, mappers, contract tests - internal/notification/ email delivery port and adapter - internal/storage/ SXCHNGE repositories and migrations/indexes - internal/jobs/ durable outbox, event inbox, reconciliation - internal/browser/ browser cookie and CSRF policy (exists) - internal/credential/ opaque credentials and digests (exists) - internal/evidence/ object key confinement (exists) -``` +`app DTO → mapper → service → data model → provider → UI` -A handler decodes an HTTP DTO, validates it, maps to a use-case input, and supplies -the authenticated principal. Use cases own application models and repository/provider -ports. The Sendico adapter alone converts its external DTOs into these models. -The Flutter client retains DTO → mapper → service → model → provider → UI; -provider DTOs do not leak through either boundary. Avoid one interface per function -or an all-purpose “Sendico service” spanning unrelated capabilities. +On the backend, local HTTP request/response DTOs map into account/application +services. A Sendico adapter maps those local models to/from the public HTTP wire +contract. Only the adapter handles provider URLs, credentials, response envelopes +and provider-specific errors. Do not expose provider wire types in Flutter models +or replace local account services with Sendico login/customer calls. -Proposed persistence: a separate SXCHNGE MongoDB database/credentials, using native -driver repositories and replica-set transactions for critical local transitions. -This preserves useful Sendico persistence experience without importing its generic -repository module. Collections: users, sessions, challenges/factors, provider -mappings, quotes, operation commands, outbox and callback inbox. Unique indexes -scope email, token digest, issuance/idempotency keys and provider events. TTL indexes -clean up expired data; application checks enforce expiry immediately. Financial -balances and KYC decisions are not authoritative local records. +The next adapter will use a configured public HTTPS base URL, a fixed organization +reference, bounded request timeouts and an injected HTTP transport. It must reject +cross-origin credential redirects. Reuse the existing public contract and response +conventions; do not invent a parallel provider facade or call internal services. -Start with the existing deployment's secret facility where available. Do not make -NATS, Redis, Sendico discovery or direct Vault imports prerequisites for the BFF. -Email has an independent adapter and durable delivery jobs; adopting an existing -mail provider does not require adopting Sendico notification/event envelopes. +## Required public Sendico capabilities -## Required Sendico services and partner capabilities +The snapshot is API version 4.2.37. Source routes and schemas establish available +shapes; they do not prove that a deployment enables every feature. -The BFF should call a versioned partner HTTP API. The names below are **existing -internal services**, useful to implement that facade; they are not instructions to -expose unauthenticated gRPC or for the browser to call them. If a supported partner -API is already deployed, validate it against this checklist instead of creating a -second one. No deployed endpoint has been verified in this task. +| Need | Existing public surface / work required | +| --- | --- | +| Integration session | `/accounts/login/api`, `/accounts/refresh`, `/accounts/rotate`; provision SXCHNGE integration credentials and permissions | +| Organization context | `/organizations` and organization lookup; configure the existing SXCHNGE organization, no customer organization provisioning | +| Currency accounts and history | Organization-scoped ledger account listing, balance and operation routes; confirm supported external-customer ownership and available-balance semantics | +| Top-up instructions | Wallets, assets and ledger inbound-route HTTP handlers; confirm customer allocation, expiry, observed versus credited states, fees and settlement callbacks | +| Exchange and payout | Public quote/multiquote, by-quote execution, payment lookup/history, payment methods and recipients; verify external-customer resource scope and idempotent retry/reconciliation behavior | +| KYC and limits usage | Product has `/accounts/compliance`, `/accounts/compliance/evidence`, `/accounts/compliance/submit`; they use the authenticated Sendico account and organization ownership. A supported public external-customer contract is needed for SXCHNGE customers | +| Manual review | Agree on public submission/status/reason contracts for passport plus passport-selfie review, resubmission and denial; do not invoke internal case/KYC services directly | +| Status updates | Public callback subscription and signing contracts exist. Confirm supported events, replay, deduplication and reconciliation before relying on them | -| Capability needed by SXCHNGE | Existing source capability | Required partner behavior / gap | -| --- | --- | --- | -| Partner authentication and customer provisioning | Product account/API-client login and organization provisioning; customer fields in payment/KYC contracts | Confirm server-to-server grant/rotation/scopes. Existing `/login/api` is password + client-secret based, not evidence of OAuth client credentials. Add/confirm idempotent customer registration and canonical owner/subject mapping | -| Customer currency accounts | `LedgerService.ListAccounts`, `GetBalance`; treasury currency catalog | Enforce mapped customer scope; provision accounts through approved workflow. Return server-defined **available to spend**, currency precision, timestamp/version. `BalanceResponse.balance` alone does not define holds, pending debits or spendability | -| Rates, fees and quotes | `QuotationService.QuotePayment`, `QuotePayments`, `ListFXDirections` | Bind initiator and required source owner; return immutable fee/debit/credit/expiry quote snapshot, supported directions and exact money representation. An advisory quote is not execution authorization | -| Exchange and payout | `PaymentOrchestratorService.ExecutePayment`, `GetPayment`, `ListPayments` | Mandatory mapped organization/initiator/source-owner constraints; execute by accepted quote ref; scoped idempotency conflict/replay and lookup by client operation ref. Confirm FX ledger-to-ledger and payout rail availability | -| Top-up | `inbound.routes.RouteService`, payment methods and gateway capabilities; `inbound.crypto_deposits` for crypto | Return customer-bound funding instructions or hosted payment action. Confirm bank/card rails and incoming attribution. A top-up cannot immediately credit funds merely because the browser submitted a form | -| Limits and upgrade benefits | `LimitsService.ListVisibleLimits`, `CheckLimit`; reservation lifecycle used by payments | Customer-visible policy including used, remaining, period/reset and verified-tier projection. Policy chooses tier from authoritative KYC. Payments owns execution reservation/commit/release; BFF never double-reserves | -| KYC evidence and status | `KYCService.SubmitVerification`, `GetVerification`, `Reverify`, `ListVerifications`; existing edge compliance upload/submit | Delegated customer access, passport/selfie evidence roles, retention/access rules and immutable evidence IDs. Confirm manual review/correction/denial transitions and their public reason codes; public KYC proto alone does not expose a manual-review decision RPC | -| Backoffice review and eligibility | KYC plus `CaseManagementService`, Assessment and Policy | Confirm review routing and authoritative decision application; resolving a workflow case is not itself a KYC approval. Ensure KYC/tier changes feed payment eligibility, rather than assuming the separate KYC service already gates every payment | -| Status changes and reconciliation | Orchestrator reads, ledger statements, outbound `edge/callbacks` | Signed customer-scoped payment/KYC events, stable event IDs, at-least-once semantics, replay/ordering/version rules, retries and replayable status reads. Polling is a valid initial fallback; missing callbacks cannot leave funds permanently ambiguous | +Required platform capabilities stay inside Sendico: ledger, quotation/execution, +receiving routes, payment methods/rails, KYC decisions, policy and limit enforcement. +If the public API lacks a needed capability, extend that public API in a separate +Sendico task. No private transport fallback is allowed. -The current limits README says reservations are in process, but source now requires -`RuntimeRepository` and applies durable mutations with conflict/retry handling. -Use implementation and contract tests as evidence; confirm deployed durability and -restart/multi-instance behavior before money rollout rather than repeating the -README claim. +## Next wiring sequence -## Client-facing contracts to implement +1. Wire local signup, verification delivery/completion and profile endpoints onto + the copied account and verification services. Create SXCHNGE-owned tenant, + client and role records; keep signup independent of Sendico availability. +2. Implement the public HTTP adapter and confirm organization/resource scoping + with the integration principal. Agree on external-customer contracts where the + existing public API assumes a Sendico account. +3. Wire read-only accounts, available funds, operation history and usage/limits. + Return local models to the existing shared Flutter services/providers. +4. Wire top-up, quote/review, exchange/payout execution and reconciliation with + local two-factor approval bound to the exact operation and idempotency key. +5. Wire KYC uploads/status/review and callback processing through public APIs. -Paths here are proposed **SXCHNGE API**, not existing Sendico endpoints. Keep them -versioned under `/api/v1`. HTTP bodies use explicit DTOs; product error responses -carry a stable code, request ID and, where useful, `retry_after` or field codes. -No raw upstream exceptions or internal compliance evidence are returned. +Before enabling money operations, test customer isolation within the same +organization, expired credentials/quotes, denied KYC, exhausted limits, duplicate +submission, timeout-after-acceptance and delayed settlement. An uncertain response +must remain pending until reconciled. UI balances show available funds, while +operation details explain what has happened and what remains pending. -| Client journey | Proposed BFF operations | Important contract | -| --- | --- | --- | -| Registration/email | `POST /registrations`, `/email-verifications`, `/email-verifications/confirm` | Idempotent registration/delivery; explicit unverified state; single-use expiring links; no session from merely creating an account | -| Login/session | `POST /sessions`, `POST /sessions/complete`, `GET /session`, `DELETE /session` | Credentials return pending challenge or confirmed session; only server can complete it. Restore, expiry, revocation, logout and frontend route guards | -| Recovery | `POST /password-resets`, `/password-resets/confirm` | Non-enumerating delivery, bounded retries and session revocation policy; do not reuse email-confirmation token purposes | -| 2FA | `GET /security`, `POST /security/challenges`, `POST /security/challenges/{id}/verify`, `/resend`, `PATCH /security` | User/session/purpose/operation-bound challenges and server-selected methods. Verified enable/disable; future TOTP setup is pending until a valid code confirms the secret | -| Preferences | `GET /profile`, `PATCH /profile` | Server-owned account language and validated mutable fields | -| Accounts/limits | `GET /accounts`, `/accounts/{id}`, `/limits` | Available balance only in primary UI; current usage and upgrade benefits from service; distinguish unavailable/stale from zero | -| Money | `POST /quotes`, `POST /operations`, `GET /operations`, `/operations/{id}`, `/operations/by-key/{key}` | Exact immutable quote, server confirmation proof, scoped idempotency and non-final acceptance. Funding instructions/hosted-action state where required | -| KYC | `GET /identity`, `POST /identity/uploads`, `POST /identity/submissions` | Bounded content validation and customer ownership; two evidence roles; idempotent submission; status, correction requests and denials | +## Current delivery boundary -Define money on the wire as decimal-string minor units plus currency and scale; -map into checked application types. Never use floating-point amounts or unchecked -JavaScript-sized integers. Existing Flutter models use integer minor units; their -mappers must reject unsupported precision/overflow. Never invent balances, fees, -limits or rates on provider timeout. - -Current Flutter stubs need targeted extensions during integration: a session model/ -provider and HTTP transport; typed funding methods/recipients instead of the current -free-text recipient; `requiresAction`/funding instructions and settlement-aware funds -status. Keep the three broad operation states if useful visually, but represent -awaiting funding, review, execution, cancellation/reversal and returned funds in -models so “failed” cannot incorrectly promise that money has already returned. -The current `GuardedLoginService` is a local stand-in: replace it with the BFF's -pending-login contract, not a production client that mints its own pending session. -There is no public endpoint that simply trusts `authorize(purpose, operation)`; -the BFF consumes proof as part of the protected server-side action. - -## Critical transaction and identity flows - -### Authentication and verification - -Registration owns local credentials, confirms email, then idempotently provisions -or resumes the provider mapping. An incomplete provider mapping can yield an -explicit “account setup pending” state without losing the local registration. -No money access until mapping is confirmed; the optional unverified KYC state -still receives whatever basic policy Sendico returns. - -Adapt Sendico's pending-login and durable consumption patterns into SXCHNGE-owned -models. Browser routes use an HttpOnly Secure host-only opaque session cookie; -unsafe requests require an exact allowed Origin plus the CSRF header, independently -of client transport hints. SameSite Lax permits safe return navigation from email; -state-changing confirmation still requires POST validation. Production sits behind -HTTPS. Native mobile uses a separate server-selected token transport with secure -platform storage and rotation/revocation, not browser-origin bypass flags. - -Scope challenges by user, session/pending-login, purpose, operation ID and method. -Use independent purposes for login, email, password reset and setting changes. -Use durable attempt/cooldown state across instances; a resend must not reset the -attempt budget. High-entropy token digests can reuse the extracted helper; numeric -codes need a separately keyed verifier and strict attempt controls, never this -helper as a password/OTP hash. Future TOTP secrets require encryption, confirmed -enrollment, bounded clock drift/replay controls and recovery before availability. - -### Money execution without duplicate payment - -1. Resolve the authenticated customer and source/destination ownership server-side. -2. Obtain a Sendico quote carrying the mapped initiator and mandatory source-owner - constraint. Persist its local ID, user, provider ref, expiry and exact snapshot. -3. Bind the user's verification to that quote and action. A changed amount, - recipient, account or expired quote requires fresh review and authorization. -4. In one **local database transaction**, validate/consume approval and insert the - immutable operation command, scoped idempotency key, request digest and outbox - job. Reusing the key with another payload is a conflict. Replaying the same key - returns the same command before checking an already-consumed approval. -5. A worker submits the stored command using the same provider idempotency key. - Sendico rechecks ownership, quote validity, eligibility, spendability and limits. - A BFF approval does not override provider policy. -6. If a response is lost, reconcile by the provider/client operation reference; - retry only under Sendico's documented idempotency contract. Never create a new - key merely to escape uncertainty. A crash after approval consumption is recovered - by the persisted outbox, not by asking for and spending another authorization. -7. Persist observed status/version and correlate events; update balances/limits from - authoritative reads. Pending/manual review is not completion, and failed/reversed - status must describe whether funds actually returned. Callback duplicates and - out-of-order events must not regress terminal state; ambiguous order triggers a read. - -There is no cross-database atomic transaction with Sendico. Durable commands, -idempotent provider execution and reconciliation are the explicit bridge. This -behavior must be proven with an “accepted upstream, response lost” contract test. - -### KYC and operational observability - -Use customer-bound private evidence references; validate size/type/content and -read access before forwarding to the provider. Do not pass arbitrary public URLs, -local file paths or untrusted object keys. If uploads stage in SXCHNGE, lifecycle, -retention, orphan cleanup and delete responsibility must be explicit; prefer -provider-issued scoped upload intents when supported. Passport and selfie are -separate evidence roles in one idempotent submission. Backoffice decisions remain -in Sendico; project only safe actionable reasons and the resulting limits. - -Correlate requests, local commands and provider IDs on the server with structured -logs. Record provider latency/errors, outbox lag, reconciliation age, callback -retries and final outcomes. Exclude passwords, codes, cookies, documents, recipient -payloads and secrets. Existing dummy analytics remains a client sink; backend -settlement evidence supplies real outcomes, not browser “submitted” events. - -## Implementation sequence and acceptance gates - -| Phase | Deliverable | Exit evidence | -| --- | --- | --- | -| 0 — this task | Audit, reuse choices, independent shell and primitives | Unit/race tests, Go formatting/vet, no Sendico imports/replacements, source provenance | -| 1 — provider contract agreement | Customer provisioning/mapping, scoped access, spendability, quote/payment replay, KYC review and callback contract | Sandbox fixtures/API schema and two-customer isolation tests; determine whether existing partner edge can be extended | -| 2 — own authentication | Mongo repositories, email outbox, signup/email/login/session/2FA/recovery plus Flutter adapters/guards | Restart-safe consumption and delivery, session isolation/revocation, no session before required factor, web/mobile transport tests | -| 3 — read-only provider integration | Customer mapping, currency accounts, profile, limits, KYC status | Available-money semantics, no cross-customer list/direct reads, safe unknown/error handling, contract version validation | -| 4 — first money slice | One supported top-up rail followed by exchange and payout, recipient/funding UI extensions | Provider-confirmed credit/debit, fees and expiry; idempotent concurrency and lost-response recovery; funds explanations match actual statuses | -| 5 — evidence/review integration | Passport/selfie uploads, backoffice review, correction/deny, policy propagation | Customer isolation, evidence lifecycle, review-driven limits change and execution policy enforcement | -| 6 — rollout | Deployment, migrations, secret rotation, health/readiness, reconciliation operations and event delivery | Sandbox end-to-end runs, restart/recovery drills, ownership tests on every public resource and explicit supported-rail configuration | - -Authentication work can proceed while the provider contract is agreed. Do not block -it on cloning financial services. Conversely, no live money rollout until the scope, -spendability, idempotency and settlement contracts are verified. Keep each phase in -its own task/PR; Sendico-side facade work is a separate provider contribution. - -## Source references - -All paths below are in the audited Sendico revision; links pin that revision. - -- [Browser transport](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go), [verification hashing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go), [evidence keys](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go). -- [Pending/API login](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/internal/transport/http/router/public/login.go), [session binding](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/refreshtokens/filters.go), [verification indexes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/db.go). -- [Ledger contract](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/ledger/v1/ledger.proto), [payment visibility](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/visibility.go), [personal source constraints](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/personal_source.go). -- [Quotation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/quotation/v2/quotation.proto), [orchestration](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/orchestration/v2/orchestration.proto), [inbound routes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/inbound/routes/v1/routes.proto). -- [Limits projection](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/limits/v1/limits.proto), [durable mutation implementation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/reservation_mutation_persistence.go), [required runtime storage](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/service.go). -- [Personal-compliance ownership](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/accountapiimp/compliance.go), [KYC](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/compliance/kyc/v1/kyc.proto), [case workflow](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/workflow/case/v1/case.proto), [callback signing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/callbacks/internal/signing/service.go). +This task supplies the copied starting point and isolated public contracts. +The local runtime installs the existing authentication transport and account +service. Local signup/profile HTTP endpoints, verification delivery/completion, +initial tenant/client provisioning, Flutter HTTP adapters and live Sendico calls +are not connected yet. The frontend continues using its existing dummy services. +No new provider organization, customer or payment has been created. diff --git a/docs/implementation.md b/docs/implementation.md index d43e05f..548f76f 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -363,40 +363,44 @@ Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`; committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend label, tech owner and SXCHNGE project. No stacked dependencies. -## Independent BFF plan and Sendico reuse — task #34 +## Copied BFF foundation and public Sendico contracts — task #34 -Audited `S_I/sendico` at `8bb20dcd622dd8eb92d289b6b34411e7eccab083` without -modifying its unrelated working-tree dependency edits. The -[BFF plan](architecture/bff.md) records source-backed reuse choices, independent -ownership, target structure, proposed Mongo persistence, client/API mapping, -required provider services and phased acceptance criteria. Source availability -is distinguished from supported/deployed external APIs. +Bootstrapped SXCHNGE from Sendico revision +`8bb20dcd622dd8eb92d289b6b34411e7eccab083`: shared HTTP runtime, authentication +and session stack, verification and refresh storage, account service, Mongo +repositories, authorization, canonical models/errors and structured logging. +The original handwritten shell and replacement helpers were removed. File-level +[provenance](../backend/PROVENANCE.json) records 416 copied Go files and 112 public +OpenAPI files. The source checkout and its unrelated dependency edits were untouched. -The main provider gaps are delegated customer provisioning/owner mapping, a -spendable-balance projection, customer-scoped compliance access/manual review, -and verified payment/callback replay contracts. Existing quotation/orchestration -owner constraints and durable limits mutation code can underpin the provider -facade. Sendico's personal-compliance ownership assumptions and stale limits -README are called out explicitly. Sendico remains authoritative for financial -execution, limits and compliance; the BFF does not clone those engines. +The corrected [architecture](architecture/bff.md) reflects the product boundary: +SXCHNGE owns customers/authentication; SXCHNGE itself is one Sendico organization; +all Sendico access uses the public HTTP API. Internal gRPC, discovery, messaging, +platform service factories and organization provisioning were excluded. Public +OpenAPI is isolated from local models and tested for self-contained references. +No live provider call, organization, customer or payment was created. -Created a separate Go module and operational shell under `backend/`, plus adapted -browser cookie/CSRF policy, opaque credential generation/digests and evidence-key -confinement. [Provenance](../backend/PROVENANCE.md) pins source blobs and deviations. -No Sendico imports, sibling replacements, external Go modules, shared database -access or live provider calls. The starter exposes health and deliberately 503 -readiness; authentication, persistence and financial product routes remain planned. -Root/backend README and backend guidelines describe the current scope. +Restored full backend guidelines and the backend-review skill, correcting scoped +paths. Copied error constructors follow `merrors`; local adaptations also validate +startup settings, preserve listen failures/nonzero exit, close Mongo after failed +runtime creation and avoid logging database settings. Architecture checks enforce +the project error model and public-only provider boundary. -Validation: `make test-backend` (race-enabled tests across four packages), -`make lint-backend` (gofmt/vet), `go build` and `git diff --check` passed. -CLI smoke verified health 200, readiness 503, unimplemented session 404 and SIGTERM -exit 0. Module/dependency inspection confirms standard-library-only dependencies. -Pinned source paths, source blobs and local documentation links were checked. -Reviewed all new backend files and the task diff. No Flutter code changed; -frontend tests and security scans were not run for this backend starter/plan task. +The copied runtime installs local authentication transport and initializes the +account service. Signup/profile endpoints, verification delivery/completion, +initial local tenant/client provisioning, Flutter HTTP adapters and live Sendico +calls remain future wiring. Public external-customer compliance/ownership gaps +are documented; internal APIs are not an allowed workaround. -Delivery: [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), `bff-34` into -`main`, committed/pushed and unmerged. Issue #34 and PR metadata verified: backend -label, tech owner, SXCHNGE project. Larger authentication/provider work remains -separate from this completed audit and starter scope. +Validation: `make test` (race-enabled backend suite with real MongoDB containers, +plus all frontend package/app tests), `make lint-backend` (architecture, gofmt, +vet), `go build` and `git diff --check` passed. Local smoke used a disposable +SXCHNGE Mongo replica set: health 200, invalid login 400, SIGTERM exit 0 and missing +configuration exit 1. Earlier extraction failures were fixed: unused import, +wrong example-config test path, an out-of-scope recipient migration test +copied without its uncopied migration, and missing built-in policy records on +fresh startup. The copied policy seed was run twice and retained three records. No security scan was requested or run. + +Delivery: [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), branch `bff-34` +into `main`; task #34 retains backend label, tech owner and SXCHNGE project. +This entry supersedes the earlier new-shell/delegated-provisioning proposal. -- 2.54.0 From 981e84a4b402b720e10e4d5790d816caf6e0dc58 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 14:00:14 +0200 Subject: [PATCH 04/11] Remove platform tenancy from SXCHNGE customer BFF (#34) --- README.md | 1 + backend/AGENTS.md | 6 +- backend/PROVENANCE.json | 1180 +---------------- backend/PROVENANCE.md | 102 +- backend/README.md | 15 +- backend/config.example.yml | 2 - .../accountservice/internal/customer_test.go | 130 ++ .../internal/join_organization_test.go | 119 -- .../accountservice/internal/service.go | 334 +---- .../internal/service_deletion_test.go | 156 --- .../product/interface/accountservice/types.go | 52 +- backend/edge/shared/interface/api/api.go | 2 - backend/edge/shared/interface/api/config.go | 8 +- backend/edge/shared/interface/api/register.go | 2 - backend/edge/shared/interface/api/runtime.go | 11 +- .../interface/api/srequest/changepolicies.go | 12 - .../interface/api/srequest/changerole.go | 8 - .../shared/interface/api/sresponse/account.go | 40 +- .../shared/interface/api/sresponse/login.go | 2 +- .../interface/api/sresponse/login_pending.go | 2 +- .../interface/api/sresponse/login_test.go | 12 + .../interface/api/sresponse/organization.go | 35 - .../interface/api/sresponse/permissions.go | 45 - .../edge/shared/interface/api/ws/config.go | 9 - .../edge/shared/interface/api/ws/handler.go | 12 - .../edge/shared/interface/api/ws/message.go | 9 - .../shared/interface/middleware/middleware.go | 6 - .../edge/shared/interface/storage/config.go | 25 - backend/edge/shared/internal/runtime/api.go | 5 - .../edge/shared/internal/runtime/bootstrap.go | 2 - .../shared/internal/runtime/bootstrap_test.go | 63 + .../internal/transport/http/middleware.go | 18 - .../http/router/authorized/customer_test.go | 74 ++ .../http/router/authorized/handler.go | 7 + .../http/router/authorized/router.go | 3 +- .../transport/http/router/dispatcher.go | 4 +- .../transport/http/router/public/login.go | 2 +- .../router/public/login_test_support_test.go | 4 - .../http/router/public/refresh_test.go | 14 + .../transport/http/router/public/validate.go | 7 + .../transport/http/websocket/dispimp.go | 68 - .../transport/http/websocket/router.go | 15 - .../edge/shared/storage/mongo/dependencies.go | 18 +- .../shared/storage/organization/archived.go | 32 - .../shared/storage/organization/cascade.go | 23 - .../shared/storage/organization/create.go | 19 - .../edge/shared/storage/organization/db.go | 113 -- .../edge/shared/storage/organization/get.go | 12 - .../shared/storage/organization/index_test.go | 54 - .../edge/shared/storage/organization/list.go | 16 - .../shared/storage/organization/member.go | 49 - .../storage/organization/member_test.go | 153 --- .../memory_repository_queries_test.go | 378 ------ .../organization/memory_repository_test.go | 149 --- .../storage/organization/organization.go | 19 - .../edge/shared/storage/organization/owned.go | 14 - .../shared/storage/organization/provider.go | 14 - .../storage/organization/provisioning.go | 166 --- .../storage/organization/provisioning_test.go | 180 --- .../storage/organization/setarchived_test.go | 51 - .../storage/organization/test_helpers_test.go | 58 - .../edge/shared/storage/refreshtokens/crud.go | 2 +- backend/edge/shared/storage/s3key/key.go | 35 - backend/edge/shared/storage/s3key/key_test.go | 42 - .../memory_token_repository_test.go | 8 - backend/go.mod | 5 - backend/go.sum | 8 - .../internal/architecture/boundary_test.go | 2 +- .../internal/architecture/customer_test.go | 48 + backend/internal/profile/runtime.go | 3 +- backend/pkg/api/http/response/response.go | 3 - .../pkg/api/http/response/response_test.go | 15 - backend/pkg/auth/anyobject/anyobject.go | 3 - backend/pkg/auth/archivable.go | 35 - backend/pkg/auth/archivableimp.go | 107 -- backend/pkg/auth/authorizer.go | 87 -- backend/pkg/auth/authorizer_test.go | 147 -- backend/pkg/auth/config.go | 12 - backend/pkg/auth/db.go | 38 - backend/pkg/auth/dbab.go | 51 - backend/pkg/auth/dbimp.go | 319 ----- backend/pkg/auth/dbimpab.go | 394 ------ backend/pkg/auth/dbimpab_test.go | 80 -- backend/pkg/auth/dbimpscoped.go | 142 -- backend/pkg/auth/dbimpscoped_test.go | 848 ------------ backend/pkg/auth/dbimpscopedcrud.go | 262 ---- backend/pkg/auth/dbimpscopedpatch.go | 129 -- backend/pkg/auth/enforcer.go | 32 - backend/pkg/auth/factory.go | 58 - backend/pkg/auth/helper.go | 61 - backend/pkg/auth/indexable.go | 29 - backend/pkg/auth/indexableimp.go | 182 --- .../pkg/auth/internal/casbin/config/config.go | 127 -- backend/pkg/auth/internal/casbin/enforcer.go | 206 --- backend/pkg/auth/internal/casbin/factory.go | 34 - backend/pkg/auth/internal/casbin/logger.go | 61 - backend/pkg/auth/internal/casbin/manager.go | 61 - .../internal/casbin/mongoadapter/adapter.go | 357 ----- .../casbin/mongoadapter/adapter_test.go | 68 - .../pkg/auth/internal/casbin/permissions.go | 167 --- backend/pkg/auth/internal/casbin/role.go | 209 --- .../casbin/serialization/internal/policy.go | 81 -- .../casbin/serialization/internal/role.go | 57 - .../internal/casbin/serialization/policy.go | 12 - .../internal/casbin/serialization/role.go | 12 - .../casbin/serialization/serializer.go | 10 - .../pkg/auth/internal/native/db/policies.go | 151 --- backend/pkg/auth/internal/native/db/roles.go | 99 -- .../pkg/auth/internal/native/dbpolicies.go | 27 - backend/pkg/auth/internal/native/dbroles.go | 24 - backend/pkg/auth/internal/native/enforcer.go | 282 ---- .../pkg/auth/internal/native/enforcer_test.go | 746 ----------- backend/pkg/auth/internal/native/manager.go | 57 - .../internal/native/nstructures/policies.go | 17 - .../auth/internal/native/nstructures/role.go | 15 - .../auth/internal/native/object_scope_test.go | 175 --- .../pkg/auth/internal/native/permission.go | 101 -- backend/pkg/auth/internal/native/role.go | 142 -- backend/pkg/auth/management/permission.go | 27 - backend/pkg/auth/management/role.go | 41 - backend/pkg/auth/manager.go | 15 - backend/pkg/auth/patchguard.go | 30 - backend/pkg/auth/provider.go | 14 - backend/pkg/auth/scoped.go | 71 - backend/pkg/auth/scoped_test.go | 130 -- backend/pkg/auth/scopeddb.go | 129 -- backend/pkg/auth/scopeddb_test.go | 256 ---- backend/pkg/db/account/account.go | 2 - backend/pkg/db/config.go | 90 -- backend/pkg/db/factory.go | 23 - .../pkg/db/internal/mongo/accountdb/user.go | 8 - backend/pkg/db/internal/mongo/db.go | 130 +- .../db/internal/mongo/invitationdb/accept.go | 12 - .../internal/mongo/invitationdb/archived.go | 49 - .../db/internal/mongo/invitationdb/cascade.go | 24 - .../pkg/db/internal/mongo/invitationdb/db.go | 48 - .../mongo/invitationdb/db_integration_test.go | 108 -- .../db/internal/mongo/invitationdb/db_test.go | 81 -- .../db/internal/mongo/invitationdb/decline.go | 12 - .../internal/mongo/invitationdb/getpublic.go | 121 -- .../db/internal/mongo/invitationdb/indexes.go | 30 - .../db/internal/mongo/invitationdb/list.go | 28 - .../mongo/invitationdb/updatestatus.go | 26 - .../pkg/db/internal/mongo/policiesdb/all.go | 20 - .../db/internal/mongo/policiesdb/builtin.go | 13 - .../pkg/db/internal/mongo/policiesdb/db.go | 21 - .../db/internal/mongo/policiesdb/db_test.go | 355 ----- .../db/internal/mongo/policiesdb/policies.go | 18 - .../mongo/repositoryimp/repository.go | 52 - .../repository_comprehensive_test.go | 66 - backend/pkg/db/internal/mongo/rolesdb/db.go | 21 - backend/pkg/db/internal/mongo/rolesdb/list.go | 15 - .../pkg/db/internal/mongo/rolesdb/roles.go | 15 - .../mongo/transactionimp/transaction.go | 4 + backend/pkg/db/invitation/invitation.go | 19 - backend/pkg/db/permissions_config_test.go | 129 -- backend/pkg/db/policy/policy.go | 17 - backend/pkg/db/repository/abfilter.go | 78 -- backend/pkg/db/repository/builders.go | 16 - .../pkg/db/repository/filter_factory_test.go | 93 -- backend/pkg/db/repository/repository.go | 3 - backend/pkg/db/role/role.go | 15 - backend/pkg/db/storable/id.go | 8 +- backend/pkg/db/template/template.go | 5 - backend/pkg/db/template/tseries.go | 14 - backend/pkg/db/tseries/point/interface.go | 14 - backend/pkg/decimal/money.go | 129 -- backend/pkg/decimal/rational.go | 161 --- backend/pkg/decimal/rounding.go | 29 - backend/pkg/merrors/accounting.go | 25 - backend/pkg/merrors/aml.go | 20 - backend/pkg/merrors/aml_test.go | 21 - backend/pkg/merrors/chain_execution.go | 21 - backend/pkg/merrors/chain_execution_test.go | 17 - backend/pkg/merrors/documents.go | 44 - backend/pkg/merrors/documents_test.go | 37 - backend/pkg/merrors/errors.go | 6 - backend/pkg/merrors/ledger.go | 65 - backend/pkg/merrors/ledger_test.go | 48 - backend/pkg/merrors/limits.go | 19 - backend/pkg/merrors/organization.go | 66 - backend/pkg/merrors/paymaster_control.go | 9 - backend/pkg/merrors/payment_execution.go | 47 - backend/pkg/merrors/payment_execution_test.go | 26 - backend/pkg/merrors/quotation.go | 32 - backend/pkg/merrors/quotation_test.go | 44 - backend/pkg/merrors/reason.go | 88 +- backend/pkg/merrors/routes.go | 127 -- backend/pkg/merrors/routes_test.go | 52 - backend/pkg/merrors/signup_email_policy.go | 78 -- .../pkg/merrors/signup_email_policy_test.go | 52 - backend/pkg/merrors/treasury.go | 15 - backend/pkg/model/account.go | 47 +- backend/pkg/model/account_metadata.go | 45 - .../pkg/model/account_role/account_role.go | 57 - backend/pkg/model/account_test.go | 51 - backend/pkg/model/auth.go | 84 -- backend/pkg/model/card.go | 169 --- backend/pkg/model/card_test.go | 68 - backend/pkg/model/chains.go | 97 -- backend/pkg/model/chains_test.go | 33 - backend/pkg/model/chainwalletroute.go | 29 - backend/pkg/model/cryptoaddress.go | 127 -- backend/pkg/model/cryptoaddress_test.go | 84 -- backend/pkg/model/ctoken.go | 47 - backend/pkg/model/ctoken_test.go | 30 - backend/pkg/model/currency.go | 77 -- backend/pkg/model/currency_test.go | 41 - backend/pkg/model/iban.go | 26 - backend/pkg/model/indexable.go | 12 - backend/pkg/model/invitation.go | 72 - backend/pkg/model/invitation_status.go | 18 - backend/pkg/model/invitation_test.go | 62 - backend/pkg/model/invitation_validation.go | 23 - backend/pkg/model/ledger.go | 132 -- backend/pkg/model/object.go | 20 - backend/pkg/model/organization.go | 111 -- backend/pkg/model/ownerbound.go | 21 - backend/pkg/model/ownerkind.go | 24 - backend/pkg/model/payment.go | 276 ---- backend/pkg/model/payment_ledger.go | 77 -- backend/pkg/model/payment_method_json_test.go | 122 -- backend/pkg/model/permission.go | 33 - backend/pkg/model/rba.go | 52 - backend/pkg/model/recipient.go | 128 -- backend/pkg/model/recipient_json_test.go | 62 - backend/pkg/model/recipient_wire_test.go | 64 - backend/pkg/model/scopedstorablemodel.go | 19 - backend/pkg/model/signup_provisioning.go | 150 --- backend/pkg/model/wallet.go | 25 - backend/pkg/model/walletprovisioning.go | 105 -- backend/pkg/moneybase/fx_rounding.go | 50 - backend/pkg/moneybase/fx_rounding_test.go | 29 - backend/pkg/moneybase/scale.go | 12 - backend/pkg/mservice/services.go | 107 +- backend/pkg/mservice/services_test.go | 40 - backend/pkg/mutil/db/auth/accountbound.go | 89 -- backend/pkg/mutil/db/auth/protected.go | 58 - backend/pkg/mutil/mzap/object.go | 13 - backend/pkg/person/name.go | 38 - backend/pkg/person/name_test.go | 14 - backend/scripts/seed-policies.js | 88 -- docs/architecture/bff.md | 21 +- docs/implementation.md | 82 +- 244 files changed, 651 insertions(+), 17130 deletions(-) create mode 100644 backend/edge/product/interface/accountservice/internal/customer_test.go delete mode 100644 backend/edge/product/interface/accountservice/internal/join_organization_test.go delete mode 100644 backend/edge/product/interface/accountservice/internal/service_deletion_test.go delete mode 100644 backend/edge/shared/interface/api/srequest/changepolicies.go delete mode 100644 backend/edge/shared/interface/api/srequest/changerole.go delete mode 100644 backend/edge/shared/interface/api/sresponse/organization.go delete mode 100644 backend/edge/shared/interface/api/sresponse/permissions.go delete mode 100644 backend/edge/shared/interface/api/ws/config.go delete mode 100644 backend/edge/shared/interface/api/ws/handler.go delete mode 100644 backend/edge/shared/interface/api/ws/message.go delete mode 100644 backend/edge/shared/interface/storage/config.go create mode 100644 backend/edge/shared/internal/runtime/bootstrap_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/authorized/customer_test.go delete mode 100644 backend/edge/shared/internal/transport/http/websocket/dispimp.go delete mode 100644 backend/edge/shared/internal/transport/http/websocket/router.go delete mode 100644 backend/edge/shared/storage/organization/archived.go delete mode 100644 backend/edge/shared/storage/organization/cascade.go delete mode 100644 backend/edge/shared/storage/organization/create.go delete mode 100644 backend/edge/shared/storage/organization/db.go delete mode 100644 backend/edge/shared/storage/organization/get.go delete mode 100644 backend/edge/shared/storage/organization/index_test.go delete mode 100644 backend/edge/shared/storage/organization/list.go delete mode 100644 backend/edge/shared/storage/organization/member.go delete mode 100644 backend/edge/shared/storage/organization/member_test.go delete mode 100644 backend/edge/shared/storage/organization/memory_repository_queries_test.go delete mode 100644 backend/edge/shared/storage/organization/memory_repository_test.go delete mode 100644 backend/edge/shared/storage/organization/organization.go delete mode 100644 backend/edge/shared/storage/organization/owned.go delete mode 100644 backend/edge/shared/storage/organization/provider.go delete mode 100644 backend/edge/shared/storage/organization/provisioning.go delete mode 100644 backend/edge/shared/storage/organization/provisioning_test.go delete mode 100644 backend/edge/shared/storage/organization/setarchived_test.go delete mode 100644 backend/edge/shared/storage/organization/test_helpers_test.go delete mode 100644 backend/edge/shared/storage/s3key/key.go delete mode 100644 backend/edge/shared/storage/s3key/key_test.go create mode 100644 backend/internal/architecture/customer_test.go delete mode 100644 backend/pkg/auth/anyobject/anyobject.go delete mode 100644 backend/pkg/auth/archivable.go delete mode 100644 backend/pkg/auth/archivableimp.go delete mode 100644 backend/pkg/auth/authorizer.go delete mode 100644 backend/pkg/auth/authorizer_test.go delete mode 100644 backend/pkg/auth/config.go delete mode 100644 backend/pkg/auth/db.go delete mode 100644 backend/pkg/auth/dbab.go delete mode 100644 backend/pkg/auth/dbimp.go delete mode 100644 backend/pkg/auth/dbimpab.go delete mode 100644 backend/pkg/auth/dbimpab_test.go delete mode 100644 backend/pkg/auth/dbimpscoped.go delete mode 100644 backend/pkg/auth/dbimpscoped_test.go delete mode 100644 backend/pkg/auth/dbimpscopedcrud.go delete mode 100644 backend/pkg/auth/dbimpscopedpatch.go delete mode 100644 backend/pkg/auth/enforcer.go delete mode 100644 backend/pkg/auth/factory.go delete mode 100644 backend/pkg/auth/helper.go delete mode 100644 backend/pkg/auth/indexable.go delete mode 100644 backend/pkg/auth/indexableimp.go delete mode 100644 backend/pkg/auth/internal/casbin/config/config.go delete mode 100644 backend/pkg/auth/internal/casbin/enforcer.go delete mode 100644 backend/pkg/auth/internal/casbin/factory.go delete mode 100644 backend/pkg/auth/internal/casbin/logger.go delete mode 100644 backend/pkg/auth/internal/casbin/manager.go delete mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter.go delete mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go delete mode 100644 backend/pkg/auth/internal/casbin/permissions.go delete mode 100644 backend/pkg/auth/internal/casbin/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/policy.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/policy.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/serializer.go delete mode 100644 backend/pkg/auth/internal/native/db/policies.go delete mode 100644 backend/pkg/auth/internal/native/db/roles.go delete mode 100644 backend/pkg/auth/internal/native/dbpolicies.go delete mode 100644 backend/pkg/auth/internal/native/dbroles.go delete mode 100644 backend/pkg/auth/internal/native/enforcer.go delete mode 100644 backend/pkg/auth/internal/native/enforcer_test.go delete mode 100644 backend/pkg/auth/internal/native/manager.go delete mode 100644 backend/pkg/auth/internal/native/nstructures/policies.go delete mode 100644 backend/pkg/auth/internal/native/nstructures/role.go delete mode 100644 backend/pkg/auth/internal/native/object_scope_test.go delete mode 100644 backend/pkg/auth/internal/native/permission.go delete mode 100644 backend/pkg/auth/internal/native/role.go delete mode 100644 backend/pkg/auth/management/permission.go delete mode 100644 backend/pkg/auth/management/role.go delete mode 100644 backend/pkg/auth/manager.go delete mode 100644 backend/pkg/auth/patchguard.go delete mode 100644 backend/pkg/auth/provider.go delete mode 100644 backend/pkg/auth/scoped.go delete mode 100644 backend/pkg/auth/scoped_test.go delete mode 100644 backend/pkg/auth/scopeddb.go delete mode 100644 backend/pkg/auth/scopeddb_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/accept.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/archived.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/cascade.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/decline.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/getpublic.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/indexes.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/list.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/updatestatus.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/all.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/builtin.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/db_test.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/policies.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/list.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/roles.go delete mode 100644 backend/pkg/db/invitation/invitation.go delete mode 100644 backend/pkg/db/permissions_config_test.go delete mode 100644 backend/pkg/db/policy/policy.go delete mode 100644 backend/pkg/db/repository/abfilter.go delete mode 100644 backend/pkg/db/repository/filter_factory_test.go delete mode 100644 backend/pkg/db/role/role.go delete mode 100644 backend/pkg/db/template/tseries.go delete mode 100644 backend/pkg/db/tseries/point/interface.go delete mode 100644 backend/pkg/decimal/money.go delete mode 100644 backend/pkg/decimal/rational.go delete mode 100644 backend/pkg/decimal/rounding.go delete mode 100644 backend/pkg/merrors/accounting.go delete mode 100644 backend/pkg/merrors/aml.go delete mode 100644 backend/pkg/merrors/aml_test.go delete mode 100644 backend/pkg/merrors/chain_execution.go delete mode 100644 backend/pkg/merrors/chain_execution_test.go delete mode 100644 backend/pkg/merrors/documents.go delete mode 100644 backend/pkg/merrors/documents_test.go delete mode 100644 backend/pkg/merrors/ledger.go delete mode 100644 backend/pkg/merrors/ledger_test.go delete mode 100644 backend/pkg/merrors/limits.go delete mode 100644 backend/pkg/merrors/organization.go delete mode 100644 backend/pkg/merrors/paymaster_control.go delete mode 100644 backend/pkg/merrors/payment_execution.go delete mode 100644 backend/pkg/merrors/payment_execution_test.go delete mode 100644 backend/pkg/merrors/quotation.go delete mode 100644 backend/pkg/merrors/quotation_test.go delete mode 100644 backend/pkg/merrors/routes.go delete mode 100644 backend/pkg/merrors/routes_test.go delete mode 100644 backend/pkg/merrors/signup_email_policy.go delete mode 100644 backend/pkg/merrors/signup_email_policy_test.go delete mode 100644 backend/pkg/merrors/treasury.go delete mode 100644 backend/pkg/model/account_metadata.go delete mode 100644 backend/pkg/model/account_role/account_role.go delete mode 100644 backend/pkg/model/account_test.go delete mode 100644 backend/pkg/model/auth.go delete mode 100644 backend/pkg/model/card.go delete mode 100644 backend/pkg/model/card_test.go delete mode 100644 backend/pkg/model/chains.go delete mode 100644 backend/pkg/model/chains_test.go delete mode 100644 backend/pkg/model/chainwalletroute.go delete mode 100644 backend/pkg/model/cryptoaddress.go delete mode 100644 backend/pkg/model/cryptoaddress_test.go delete mode 100644 backend/pkg/model/ctoken.go delete mode 100644 backend/pkg/model/ctoken_test.go delete mode 100644 backend/pkg/model/currency.go delete mode 100644 backend/pkg/model/currency_test.go delete mode 100644 backend/pkg/model/iban.go delete mode 100644 backend/pkg/model/indexable.go delete mode 100644 backend/pkg/model/invitation.go delete mode 100644 backend/pkg/model/invitation_status.go delete mode 100644 backend/pkg/model/invitation_test.go delete mode 100644 backend/pkg/model/invitation_validation.go delete mode 100644 backend/pkg/model/ledger.go delete mode 100644 backend/pkg/model/object.go delete mode 100644 backend/pkg/model/organization.go delete mode 100644 backend/pkg/model/ownerbound.go delete mode 100644 backend/pkg/model/ownerkind.go delete mode 100644 backend/pkg/model/payment.go delete mode 100644 backend/pkg/model/payment_ledger.go delete mode 100644 backend/pkg/model/payment_method_json_test.go delete mode 100644 backend/pkg/model/permission.go delete mode 100644 backend/pkg/model/rba.go delete mode 100644 backend/pkg/model/recipient.go delete mode 100644 backend/pkg/model/recipient_json_test.go delete mode 100644 backend/pkg/model/recipient_wire_test.go delete mode 100644 backend/pkg/model/scopedstorablemodel.go delete mode 100644 backend/pkg/model/signup_provisioning.go delete mode 100644 backend/pkg/model/wallet.go delete mode 100644 backend/pkg/model/walletprovisioning.go delete mode 100644 backend/pkg/moneybase/fx_rounding.go delete mode 100644 backend/pkg/moneybase/fx_rounding_test.go delete mode 100644 backend/pkg/moneybase/scale.go delete mode 100644 backend/pkg/mservice/services_test.go delete mode 100644 backend/pkg/mutil/db/auth/accountbound.go delete mode 100644 backend/pkg/mutil/db/auth/protected.go delete mode 100644 backend/pkg/person/name.go delete mode 100644 backend/pkg/person/name_test.go delete mode 100644 backend/scripts/seed-policies.js diff --git a/README.md b/README.md index 82629d9..de8f711 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ usability, accessibility and journey measurement under the repository's UX rules The integration is: **consumer app → sxchnge BFF → Sendico public HTTP API**. SXCHNGE is one organization in Sendico; its customers are owned locally. +Local accounts have no organization tenancy, membership, roles or policy engine. - sxchnge will own consumer registration/sessions, onboarding and exchange UX, and mappings from local customers to organization-scoped platform resources. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b17e35b..748d279 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -86,7 +86,11 @@ skill to perform this completion workflow. ## SXCHNGE provider boundary SXCHNGE owns auth, sessions and customer management. SXCHNGE is one organization -inside Sendico. Copied BFF components are locally owned and may diverge. +inside Sendico. This organization reference belongs only in the public provider +adapter configuration. Local customers have no organizations, tenant IDs, +membership, roles or policy engine. Do not copy platform scaffolding to satisfy +unused dependencies; remove those dependencies from the selected components. +Copied BFF components are locally owned and may diverge. Use only Sendico's public HTTP API for platform operations. Never import sibling Sendico modules or access its database, internal gRPC APIs, discovery or message bus. Keep the pinned public wire contract separate from local DTOs and models. diff --git a/backend/PROVENANCE.json b/backend/PROVENANCE.json index 5823010..c79f6f4 100644 --- a/backend/PROVENANCE.json +++ b/backend/PROVENANCE.json @@ -2,71 +2,17 @@ "repository": "S_I/sendico", "revision": "8bb20dcd622dd8eb92d289b6b34411e7eccab083", "files": [ - { - "source": "api/edge/shared/storage/s3key/key.go", - "target": "edge/shared/storage/s3key/key.go", - "blob": "e3cca16304d2c4cb9f256f510d5a1a30a21e22a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/s3key/key_test.go", - "target": "edge/shared/storage/s3key/key_test.go", - "blob": "e016ff8b05c0975c72015d952e408dba3ba85f6f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/accounting.go", - "target": "pkg/merrors/accounting.go", - "blob": "e5f34a8c17af718e16c624ee72abf45c913b250c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/aml.go", - "target": "pkg/merrors/aml.go", - "blob": "c544dfcad1c701fbbfb89afa2e35235cbec9ad39", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/aml_test.go", - "target": "pkg/merrors/aml_test.go", - "blob": "bf03151338f835e5275ca03a26531cd0a8186325", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/chain_execution.go", - "target": "pkg/merrors/chain_execution.go", - "blob": "6dd96e073549f15f91ed25ea3191103ddc645097", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/chain_execution_test.go", - "target": "pkg/merrors/chain_execution_test.go", - "blob": "85831a603568cca18e0db4e0ffb8359d043f0838", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/classified.go", "target": "pkg/merrors/classified.go", "blob": "569049273d437caa7d7d62e8f804f2d8b5ba632f", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/documents.go", - "target": "pkg/merrors/documents.go", - "blob": "f94d13e56de3e57fd6faaf975d7f44acda984fce", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/documents_test.go", - "target": "pkg/merrors/documents_test.go", - "blob": "6b048c6066bf23c2821e960d6dc494269c92a4a0", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/errors.go", "target": "pkg/merrors/errors.go", "blob": "dceb39670721ed0b4bc59e8da9a96a0428c87684", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/errors_test.go", @@ -80,89 +26,11 @@ "blob": "954402a713c11f113377a08cdb27ef87006e40c3", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/ledger.go", - "target": "pkg/merrors/ledger.go", - "blob": "bfaa6f9bd8683d5830ad24f9353047ae9764b0e9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/ledger_test.go", - "target": "pkg/merrors/ledger_test.go", - "blob": "05287d741d2d97230be202e91736952c54e16161", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/limits.go", - "target": "pkg/merrors/limits.go", - "blob": "8e16a8fc4e13c34cdc0712bd4ead4c7e405522aa", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/organization.go", - "target": "pkg/merrors/organization.go", - "blob": "591ada734f2a3d9b4ed590de577cb432ac2a0cbf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/paymaster_control.go", - "target": "pkg/merrors/paymaster_control.go", - "blob": "db12becdc40206d4cbc344a628776f26c703d7e0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/payment_execution.go", - "target": "pkg/merrors/payment_execution.go", - "blob": "86b0b6aa3395fa392c2c952775881da8cc8ad013", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/payment_execution_test.go", - "target": "pkg/merrors/payment_execution_test.go", - "blob": "82c5ae93c047eb0d2902e990e53b2ded983e5e0b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/quotation.go", - "target": "pkg/merrors/quotation.go", - "blob": "b65c15845a6e0c51513a06af57ae4ea18a26b260", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/quotation_test.go", - "target": "pkg/merrors/quotation_test.go", - "blob": "584af1ef24af82fa63f4c41ebb02489edbce309d", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/reason.go", "target": "pkg/merrors/reason.go", "blob": "9205bac7ece87eaf56e410ff96ec68a0f0f69e51", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/routes.go", - "target": "pkg/merrors/routes.go", - "blob": "546018c398d8417975c0986184daec643e1acbf3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/routes_test.go", - "target": "pkg/merrors/routes_test.go", - "blob": "40f2fbdf82fc4d701835243ffc844ffd233afdc0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/signup_email_policy.go", - "target": "pkg/merrors/signup_email_policy.go", - "blob": "7fdc705e8d71641b95c6fef42dedbc471515d8bf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/signup_email_policy_test.go", - "target": "pkg/merrors/signup_email_policy_test.go", - "blob": "40b44ba9833e8e870b2c185b6535e89cb107c34c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/throttle.go", @@ -176,12 +44,6 @@ "blob": "10014a308444208cc67fbefc61c0bb815697dafe", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/treasury.go", - "target": "pkg/merrors/treasury.go", - "blob": "d4c4d5347cb775012a5a1232e3b3bf50c319da1b", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/appversion/version.go", "target": "edge/shared/appversion/version.go", @@ -276,31 +138,13 @@ "source": "api/pkg/mservice/services.go", "target": "pkg/mservice/services.go", "blob": "d02e6f5b5af4ba7dac1fdd1cf3f6331ecd1800a1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mservice/services_test.go", - "target": "pkg/mservice/services_test.go", - "blob": "546cbf5ad65032e0cd594a98cd0c4e08cf99c3ec", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/model/account.go", "target": "pkg/model/account.go", "blob": "552c87f19dedb4927b3758d7ca86f091be8e2f34", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_metadata.go", - "target": "pkg/model/account_metadata.go", - "blob": "5f0e82c1abf347df42db7410bfb91b40a1eef7f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_test.go", - "target": "pkg/model/account_test.go", - "blob": "0aff70c89c5a52a92f943d6e3f8da513bf89d4ba", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/model/archivable.go", @@ -308,84 +152,12 @@ "blob": "9b51c661f5c09825a6ea8dfc46aed09f94166654", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/auth.go", - "target": "pkg/model/auth.go", - "blob": "2c6bda159fbf0ee5fb64e8de8faf59bb3ceee166", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/card.go", - "target": "pkg/model/card.go", - "blob": "426c22e21bace53eef91b6ddcd82365944f7c902", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/card_test.go", - "target": "pkg/model/card_test.go", - "blob": "2ca8c0a88376dc19ca2372ea372cfc255e0b3c9d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chains.go", - "target": "pkg/model/chains.go", - "blob": "b0e8b27da0f2c551fb4861fe704d1f262d3cdf2d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chains_test.go", - "target": "pkg/model/chains_test.go", - "blob": "84c4a9b8f7edd95146c83e463ce2cb34932daf5b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chainwalletroute.go", - "target": "pkg/model/chainwalletroute.go", - "blob": "d5f16f43ede5b0750fdd84f1c447dc6fc36009a7", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/client.go", "target": "pkg/model/client.go", "blob": "d61c63a55985b8b3bf05bb417f1079ab98fbf495", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/cryptoaddress.go", - "target": "pkg/model/cryptoaddress.go", - "blob": "73a63eef658f91e722a0c8849414a2936eb7da84", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/cryptoaddress_test.go", - "target": "pkg/model/cryptoaddress_test.go", - "blob": "71601d1782e1d9a16b35639b9f979126b23d0a0d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ctoken.go", - "target": "pkg/model/ctoken.go", - "blob": "0b9f239d63d83ce16203e2c76d24546aa2082019", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ctoken_test.go", - "target": "pkg/model/ctoken_test.go", - "blob": "51f9066c8e532613c75c7d7c7fa71fd61afcbeb9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/currency.go", - "target": "pkg/model/currency.go", - "blob": "8938cecae14171a0b0c1756d279fb6d648868cbb", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/currency_test.go", - "target": "pkg/model/currency_test.go", - "blob": "c490497b254f00d90cd32d9e685d541f6839a9bb", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/describable.go", "target": "pkg/model/describable.go", @@ -398,144 +170,18 @@ "blob": "8806c3b3c30f17033a8b1a0228b445c437f7b50d", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/iban.go", - "target": "pkg/model/iban.go", - "blob": "6a2550b382fa369765838cfe01f04f8c8635a3d3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/indexable.go", - "target": "pkg/model/indexable.go", - "blob": "7cba1fb5e1b457dfeabae5da7972ae21adcf7dba", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation.go", - "target": "pkg/model/invitation.go", - "blob": "e300325868e4bbf8662352f3c1652e5870e3c429", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_status.go", - "target": "pkg/model/invitation_status.go", - "blob": "b5799c21c0d5a0801eb3c2dbdb44dc83537af13e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_test.go", - "target": "pkg/model/invitation_test.go", - "blob": "fd80f2b7094cd6578e6ead214fff22264a8bc6e3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_validation.go", - "target": "pkg/model/invitation_validation.go", - "blob": "e7b594d602c95c942c207dbedcacdb616a7c69b4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ledger.go", - "target": "pkg/model/ledger.go", - "blob": "7738efe09c48393ee363f38c3ba7f02bd8f56c1c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/object.go", - "target": "pkg/model/object.go", - "blob": "32b43c8549e342bad8304a44b01de45932c9e805", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/organization.go", - "target": "pkg/model/organization.go", - "blob": "514fd23e2ce101f89dab7f78a24eb6a27a63611f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ownerbound.go", - "target": "pkg/model/ownerbound.go", - "blob": "34fdc46f738cd9f89c2588cb66cce4fc4bc4ce4f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ownerkind.go", - "target": "pkg/model/ownerkind.go", - "blob": "47313d9ec4cf7b1937dafddc598c6b4c71a9073f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment.go", - "target": "pkg/model/payment.go", - "blob": "05acab59443febad981d02e258e9fd1b481c12e7", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment_ledger.go", - "target": "pkg/model/payment_ledger.go", - "blob": "232af9035327944b723321d304a6cd8ffd9ea20c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment_method_json_test.go", - "target": "pkg/model/payment_method_json_test.go", - "blob": "828d23294a2d24fc9fa69bfc48e18e9bd438109e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/permission.go", - "target": "pkg/model/permission.go", - "blob": "2b80f1bb4fec3b0e2e9956ce57f9812cadedde82", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/rba.go", - "target": "pkg/model/rba.go", - "blob": "fca3808c1ae663dd15a824424f9c73405518e250", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient.go", - "target": "pkg/model/recipient.go", - "blob": "99157a525e0fa511f38af31d9c93842422224cf6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient_json_test.go", - "target": "pkg/model/recipient_json_test.go", - "blob": "563ec39a91b23c12a1a72a25f7e01b9b78ca0b00", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient_wire_test.go", - "target": "pkg/model/recipient_wire_test.go", - "blob": "bbcd1355d222d47b4b68e317633a13660e278c66", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/refresh.go", "target": "pkg/model/refresh.go", "blob": "43e956be1babedf7857f7697dd3478eb9d05b006", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/scopedstorablemodel.go", - "target": "pkg/model/scopedstorablemodel.go", - "blob": "c96b6dcec5324ce9b1e723c3a212a01d0eb366ff", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/sessionid.go", "target": "pkg/model/sessionid.go", "blob": "251eea9f0bb56d67d89d32851e004313d17aae52", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/signup_provisioning.go", - "target": "pkg/model/signup_provisioning.go", - "blob": "6d0c3e0ba4f0f51a31308f8f180f77562bfa850b", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/userdata.go", "target": "pkg/model/userdata.go", @@ -554,23 +200,11 @@ "blob": "0143c3f99f3131f778fba713c33f796fb4fb9b6e", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/wallet.go", - "target": "pkg/model/wallet.go", - "blob": "b8ab22548c62c21ddc70eb5839a6d2ec159160a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/walletprovisioning.go", - "target": "pkg/model/walletprovisioning.go", - "blob": "dcd5f369090822d038da7c9ccdde96c3314d5a75", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/storable/id.go", "target": "pkg/db/storable/id.go", "blob": "58d06374158e2e606aa98fc21860a5077b26b934", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/storable/ref.go", @@ -584,60 +218,6 @@ "blob": "e048582bbc669013eaf02ea57076072a3d4d16e6", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/person/name.go", - "target": "pkg/person/name.go", - "blob": "862fbd765dbc5f8b0f21b3c5800b0c33bb94cfa9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/person/name_test.go", - "target": "pkg/person/name_test.go", - "blob": "828b4f7a30312620c5d77641edb1771dfe29334c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_role/account_role.go", - "target": "pkg/model/account_role/account_role.go", - "blob": "2a6e35e9ef99a90ecf1f74ccadcc049faf41b19e", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/moneybase/fx_rounding.go", - "target": "pkg/moneybase/fx_rounding.go", - "blob": "cf24e19179f7f5a6fb8382ee92064a5294105e29", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/moneybase/fx_rounding_test.go", - "target": "pkg/moneybase/fx_rounding_test.go", - "blob": "424051f72310b48bf77f7848482bc03291f3c0d9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/moneybase/scale.go", - "target": "pkg/moneybase/scale.go", - "blob": "60c218d246fb64f22e5a13cfaf0af336d80285a1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/money.go", - "target": "pkg/decimal/money.go", - "blob": "4d781719fa10f743ebe9b2c22afaae6843ada245", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/rational.go", - "target": "pkg/decimal/rational.go", - "blob": "ebbc45d72f58227702343815fbb7b6a6d50f8510", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/rounding.go", - "target": "pkg/decimal/rounding.go", - "blob": "65b345d20df1d961a5c37eb4a12b6c060d4dfbcd", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/mutil/mzap/account.go", "target": "pkg/mutil/mzap/account.go", @@ -654,7 +234,7 @@ "source": "api/pkg/mutil/mzap/object.go", "target": "pkg/mutil/mzap/object.go", "blob": "663574556a14cd592fcaa3be615bed3644f7dea1", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/mask/mask.go", @@ -672,13 +252,13 @@ "source": "api/edge/product/interface/accountservice/types.go", "target": "edge/product/interface/accountservice/types.go", "blob": "848519b9000cda122be8fa652544b33ed2f7fd52", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/config.go", "target": "pkg/db/config.go", "blob": "86d46f9f0c78e76e782f134222028fe91d955f53", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/connection.go", @@ -690,13 +270,7 @@ "source": "api/pkg/db/factory.go", "target": "pkg/db/factory.go", "blob": "b9ea3bb4da4fae375d9fabe7c816c7a6b65847bf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/permissions_config_test.go", - "target": "pkg/db/permissions_config_test.go", - "blob": "00b0dfafa2a1951052e8a41c45a154cdaa8a9b99", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/transaction/context.go", @@ -722,12 +296,6 @@ "blob": "859977f653db2e0dac676cd12f161acc2d91b72b", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/role/role.go", - "target": "pkg/db/role/role.go", - "blob": "30a4e4e1f82ff2601cce8f592ee10b33a1e11804", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/template/interface.go", "target": "pkg/db/template/interface.go", @@ -738,19 +306,7 @@ "source": "api/pkg/db/template/template.go", "target": "pkg/db/template/template.go", "blob": "abe24d42447e1759aa373a5ecadd06ccb879d672", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/template/tseries.go", - "target": "pkg/db/template/tseries.go", - "blob": "6a1504731ad18ae2cc4b3487dc9fec4acecea8cd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/tseries/point/interface.go", - "target": "pkg/db/tseries/point/interface.go", - "blob": "efb9483928a38f8b5599254222d88ccb3c594282", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/builder/accumulator.go", @@ -830,17 +386,11 @@ "blob": "45b011c84f129e508231c314b4935c0f33bf08ad", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/repository/abfilter.go", - "target": "pkg/db/repository/abfilter.go", - "blob": "8e09216ecdfb07aeaae364ba1e6976a3fe48e686", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/repository/builders.go", "target": "pkg/db/repository/builders.go", "blob": "ba4190e467397693c5dfce8c991bfbbd2fcf7cd5", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/cursor.go", @@ -848,17 +398,11 @@ "blob": "bfcbb351acc317539655053a5ff7861d1c987fdf", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/repository/filter_factory_test.go", - "target": "pkg/db/repository/filter_factory_test.go", - "blob": "9ba32e80407fd44319c08784cc626ea5ff81eb31", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/repository/repository.go", "target": "pkg/db/repository/repository.go", "blob": "14c4c10705cc641fe788dd0b10d9e1344c699599", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/index/index.go", @@ -900,13 +444,13 @@ "source": "api/pkg/db/internal/mongo/repositoryimp/repository.go", "target": "pkg/db/internal/mongo/repositoryimp/repository.go", "blob": "28d3683fc0b9ad9ce2ba8dc04eebad0247c04465", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", "target": "pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", "blob": "1395cc67768367047dcee5b39ac60725f58e546c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_filter.go", @@ -1028,258 +572,6 @@ "blob": "10de4fa645cc76a6e6fdb369177d63bf5f723974", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/policy/policy.go", - "target": "pkg/db/policy/policy.go", - "blob": "30b0eeaaa8cae80408acc81c0d0ff1e499b56766", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/invitation/invitation.go", - "target": "pkg/db/invitation/invitation.go", - "blob": "3097375dd85fdd1a88fa752f07c81e35ddeea3bd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/archivable.go", - "target": "pkg/auth/archivable.go", - "blob": "e770edc4751021fd49dcd9c80a5f1c15a3cf9324", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/archivableimp.go", - "target": "pkg/auth/archivableimp.go", - "blob": "256da6fe5b7916761f19c586971169f6aed375f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/authorizer.go", - "target": "pkg/auth/authorizer.go", - "blob": "3594b5aaa3986f89dfe3d38037b12cf7dfa0f2bb", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/authorizer_test.go", - "target": "pkg/auth/authorizer_test.go", - "blob": "66caca0a2e0dfdec3b3bc352e25cb9ba6461bff5", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/config.go", - "target": "pkg/auth/config.go", - "blob": "4ec922ec8517f5b9a28a2837416943d23d89500d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/db.go", - "target": "pkg/auth/db.go", - "blob": "9ccd3fd3da0f52a30796fa0032c209c434f8efd8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbab.go", - "target": "pkg/auth/dbab.go", - "blob": "b8fa72ab976d768c3195495db52e9a6b0357b772", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimp.go", - "target": "pkg/auth/dbimp.go", - "blob": "83cb6cc2b864882e58a2e39d55b60e06cf406dce", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpab.go", - "target": "pkg/auth/dbimpab.go", - "blob": "612db1719bcc37f8e55d6fd75e9ddffd08121748", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpab_test.go", - "target": "pkg/auth/dbimpab_test.go", - "blob": "008b09a1cb83267c3d1df2738daaf51e542ea97e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscoped.go", - "target": "pkg/auth/dbimpscoped.go", - "blob": "43886943b4e5f56029f43d17e5a52c39897213f3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscoped_test.go", - "target": "pkg/auth/dbimpscoped_test.go", - "blob": "c87042f5020fe466d721442a9b42c910ba6263f6", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/dbimpscopedcrud.go", - "target": "pkg/auth/dbimpscopedcrud.go", - "blob": "ce4e9bddac3108697064b03ab8387be3374c697f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscopedpatch.go", - "target": "pkg/auth/dbimpscopedpatch.go", - "blob": "239cf5b6e6c848fd975ade685900d5bde0140e91", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/enforcer.go", - "target": "pkg/auth/enforcer.go", - "blob": "487c3b22396fe8589a09f9e7e069d1da095ea0d8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/factory.go", - "target": "pkg/auth/factory.go", - "blob": "3e7fb12d677a80ac11a42dbb023905ae3ea072f1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/helper.go", - "target": "pkg/auth/helper.go", - "blob": "dbdfabb90bf5dd3dea991ca2ff00faa06a7333ba", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/indexable.go", - "target": "pkg/auth/indexable.go", - "blob": "4ba65ec9dddc6d0ccb15a1a8ce8ff8e56cc4d66d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/indexableimp.go", - "target": "pkg/auth/indexableimp.go", - "blob": "eb47986d9c8623933b365b6396144ca3d54b112e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/manager.go", - "target": "pkg/auth/manager.go", - "blob": "0184a198b9bc6a6522851aed5ca742b365c873dc", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/patchguard.go", - "target": "pkg/auth/patchguard.go", - "blob": "5762afa0ed126d4b446f476bb054e4db548008c3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/provider.go", - "target": "pkg/auth/provider.go", - "blob": "21c3bb5e1d7cb9174d245bbe52097ec15d810343", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scoped.go", - "target": "pkg/auth/scoped.go", - "blob": "83fe6d0adfaadb4e14e73ec16a76503f3030b6f4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scoped_test.go", - "target": "pkg/auth/scoped_test.go", - "blob": "d6b11145b87fbddb987d3df7360a9beebb54d901", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/scopeddb.go", - "target": "pkg/auth/scopeddb.go", - "blob": "ffbd1ea3cc91deabba2a36d9c7a0fe6a1f8aae8c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scopeddb_test.go", - "target": "pkg/auth/scopeddb_test.go", - "blob": "70e58db28ba4cd1ab1aa17b81d54f2c317613b20", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/management/permission.go", - "target": "pkg/auth/management/permission.go", - "blob": "35ea4729da67a39797772f0604a70ded9ea48b5c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/management/role.go", - "target": "pkg/auth/management/role.go", - "blob": "ea3b902effbbc2aa2c2381453c1d833807645821", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/dbpolicies.go", - "target": "pkg/auth/internal/native/dbpolicies.go", - "blob": "35958c0c109df2bd8d8ab716ca5a84ebcb1ddee5", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/dbroles.go", - "target": "pkg/auth/internal/native/dbroles.go", - "blob": "72b98ab1e2874980a5e169c5a489f79a66818639", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/enforcer.go", - "target": "pkg/auth/internal/native/enforcer.go", - "blob": "b1382b5448c4c5202b17c059f955a1399f567ab4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/enforcer_test.go", - "target": "pkg/auth/internal/native/enforcer_test.go", - "blob": "d31e40531d1877459a6e3337339c65055e698407", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/manager.go", - "target": "pkg/auth/internal/native/manager.go", - "blob": "58b021c774a72b68d078800986fb54afae5b7c2e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/object_scope_test.go", - "target": "pkg/auth/internal/native/object_scope_test.go", - "blob": "740f09f825ebfc8d9ea1b640e5226f570d248815", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/permission.go", - "target": "pkg/auth/internal/native/permission.go", - "blob": "4d2f04b620a5654fe544953ec0ac856ddf766866", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/role.go", - "target": "pkg/auth/internal/native/role.go", - "blob": "6c155b540c7e5d6617faf1a38373a78f591a5c5f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/nstructures/policies.go", - "target": "pkg/auth/internal/native/nstructures/policies.go", - "blob": "a6791ab0d9e27ffb4dccb8e60793fd63513fcf3e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/nstructures/role.go", - "target": "pkg/auth/internal/native/nstructures/role.go", - "blob": "af3c6c1b86eea99b99247ca97b0bb77c2904851e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/db/policies.go", - "target": "pkg/auth/internal/native/db/policies.go", - "blob": "51f51ec24da544737c8b5078538efa3e0c0eee40", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/db/roles.go", - "target": "pkg/auth/internal/native/db/roles.go", - "blob": "b57868046c36e82849b568926445e68bb5770e57", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/mutil/db/archive.go", "target": "pkg/mutil/db/archive.go", @@ -1298,96 +590,6 @@ "blob": "d29855c92fe4df1abf5e342d5f025c4e6e7434ab", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/auth/internal/casbin/enforcer.go", - "target": "pkg/auth/internal/casbin/enforcer.go", - "blob": "b5614b07360e37e580ad5539e8abface9eb0c0ee", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/factory.go", - "target": "pkg/auth/internal/casbin/factory.go", - "blob": "8a1a2da2965c5796d02da5a31e7baa384fd324f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/logger.go", - "target": "pkg/auth/internal/casbin/logger.go", - "blob": "1a172bdf25514f970e4a3f187d942c193ca73b55", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/manager.go", - "target": "pkg/auth/internal/casbin/manager.go", - "blob": "1499471a58548d3147e385e3e28b16b3e199234b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/permissions.go", - "target": "pkg/auth/internal/casbin/permissions.go", - "blob": "554e702620b99ae2fc90bc67058e0a404703e450", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/role.go", - "target": "pkg/auth/internal/casbin/role.go", - "blob": "359f4ff2b746a90b51dab9aa7972f3b0e805e757", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/policy.go", - "target": "pkg/auth/internal/casbin/serialization/policy.go", - "blob": "0e1fa29b7307a83ea4eef7888cd375f63de7eebc", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/role.go", - "target": "pkg/auth/internal/casbin/serialization/role.go", - "blob": "59ea1dc241d99dce612ff9150c54b5530527fc36", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/serializer.go", - "target": "pkg/auth/internal/casbin/serialization/serializer.go", - "blob": "36d11db558d4ea903f400b0754cfc453cfc2fa7d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/internal/policy.go", - "target": "pkg/auth/internal/casbin/serialization/internal/policy.go", - "blob": "e119f97631a87bce2230bb45cc3b5f7c15fee762", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/internal/role.go", - "target": "pkg/auth/internal/casbin/serialization/internal/role.go", - "blob": "796914fdbcdf789c416a06deb9c259ae6d06fa56", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/anyobject/anyobject.go", - "target": "pkg/auth/anyobject/anyobject.go", - "blob": "124da0fed3bc57ed35deae1382ef6364f686b1ca", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter.go", - "target": "pkg/auth/internal/casbin/mongoadapter/adapter.go", - "blob": "0d64409a9a8cbf9470ad917dc805c7717fd5c483", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter_test.go", - "target": "pkg/auth/internal/casbin/mongoadapter/adapter_test.go", - "blob": "36ea81dd413ce6c84370b0f65cce32e9f76062a5", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/config/config.go", - "target": "pkg/auth/internal/casbin/config/config.go", - "blob": "ce4b097622d26c0f6c4a43e526276c0d61bda153", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/internal/mongo/db.go", "target": "pkg/db/internal/mongo/db.go", @@ -1416,133 +618,7 @@ "source": "api/pkg/db/internal/mongo/transactionimp/transaction.go", "target": "pkg/db/internal/mongo/transactionimp/transaction.go", "blob": "266feed66a033d86f8c75785718d424ac10f8db8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/db.go", - "target": "pkg/db/internal/mongo/rolesdb/db.go", - "blob": "2f5f3848c31add6ff42c6d32c3ce6ec50501c164", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/list.go", - "target": "pkg/db/internal/mongo/rolesdb/list.go", - "blob": "54a993dad72cea6ea3d1b9e81fe017db83e16801", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/roles.go", - "target": "pkg/db/internal/mongo/rolesdb/roles.go", - "blob": "4d070ce586a21251312749bbe5f48337dc9c014f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/all.go", - "target": "pkg/db/internal/mongo/policiesdb/all.go", - "blob": "d944be2159240a7df7055162adb71364024bf18f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/builtin.go", - "target": "pkg/db/internal/mongo/policiesdb/builtin.go", - "blob": "758d423bf3b4ecd330143c6ee18557aaf984877f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/db.go", - "target": "pkg/db/internal/mongo/policiesdb/db.go", - "blob": "b4c0e9733c80fced9c3403a61d11ca6c8d7f36a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/db_test.go", - "target": "pkg/db/internal/mongo/policiesdb/db_test.go", - "blob": "5089eec6afd4fa32fbe4e4c7e66bcede8d54d6b2", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/policies.go", - "target": "pkg/db/internal/mongo/policiesdb/policies.go", - "blob": "b802cb5d99749e06945e592103808d66e0d9f5fe", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/accept.go", - "target": "pkg/db/internal/mongo/invitationdb/accept.go", - "blob": "8b22999c234e9019c21b4c70512907af2701d747", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/archived.go", - "target": "pkg/db/internal/mongo/invitationdb/archived.go", - "blob": "c899d1aed20cdcfa4421d8ec8093cd267aefe681", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/cascade.go", - "target": "pkg/db/internal/mongo/invitationdb/cascade.go", - "blob": "6ec580bf26da2d725ef544c52e8619277cde90ea", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db.go", - "target": "pkg/db/internal/mongo/invitationdb/db.go", - "blob": "7f1f4f1735880bba21dd4b6ce35184ec2a6463fe", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db_integration_test.go", - "target": "pkg/db/internal/mongo/invitationdb/db_integration_test.go", - "blob": "05482982cd2b2f7e66ef145f8791689dc451d1b0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db_test.go", - "target": "pkg/db/internal/mongo/invitationdb/db_test.go", - "blob": "c0c2d973796d2873af3366dbeaa0d1cd023b8dcd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/decline.go", - "target": "pkg/db/internal/mongo/invitationdb/decline.go", - "blob": "0cd0b9896f3bbdc6dbf16cd4500e2d810cfe2381", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/getpublic.go", - "target": "pkg/db/internal/mongo/invitationdb/getpublic.go", - "blob": "d072f157dfa6d6dc70d5e5192058283d1b88c3b9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/indexes.go", - "target": "pkg/db/internal/mongo/invitationdb/indexes.go", - "blob": "014fcfa60822306d86c05b7439caf5fb50fb69be", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/list.go", - "target": "pkg/db/internal/mongo/invitationdb/list.go", - "blob": "4eb61b8ac8258d10cbcc5a0c26cecd3ed677f3e9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/updatestatus.go", - "target": "pkg/db/internal/mongo/invitationdb/updatestatus.go", - "blob": "a55ba4600ca41695987a99d74a87ae19e0df97d6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mutil/db/auth/accountbound.go", - "target": "pkg/mutil/db/auth/accountbound.go", - "blob": "097eb5a688970b81059a36299562afdb6df44e95", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mutil/db/auth/protected.go", - "target": "pkg/mutil/db/auth/protected.go", - "blob": "d91a8a9a391dd0a8159c4f90fa7e8c27453f9279", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/accountdb/db.go", @@ -1554,13 +630,13 @@ "source": "api/pkg/db/internal/mongo/accountdb/user.go", "target": "pkg/db/internal/mongo/accountdb/user.go", "blob": "22d68e5e07e5ac002c661e07409e3f21ba5c475c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/account/account.go", "target": "pkg/db/account/account.go", "blob": "22f6191c47730d7e1373e96c8aa691940525fcf3", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/middleware/cors_test.go", @@ -1598,23 +674,11 @@ "blob": "1854a6abeadc2a9ce014e79f124c8cb213e17a65", "adapted_beyond_namespace": false }, - { - "source": "api/edge/product/interface/accountservice/internal/join_organization_test.go", - "target": "edge/product/interface/accountservice/internal/join_organization_test.go", - "blob": "1a06c563c9cb0d9d056ed7b4450e34cf199c0639", - "adapted_beyond_namespace": false - }, { "source": "api/edge/product/interface/accountservice/internal/service.go", "target": "edge/product/interface/accountservice/internal/service.go", "blob": "e0a26dd3f3d740ad5103e98a56bfa68f1065b1b6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/product/interface/accountservice/internal/service_deletion_test.go", - "target": "edge/product/interface/accountservice/internal/service_deletion_test.go", - "blob": "0100b9aa1c727b213e4d6e20dc2c7ee874c6be46", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/product/interface/accountservice/internal/service_test.go", @@ -1716,7 +780,7 @@ "source": "api/edge/shared/storage/verification/memory_token_repository_test.go", "target": "edge/shared/storage/verification/memory_token_repository_test.go", "blob": "3a24298b02e90323416f1b47a1012d36b7cd26bc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/request.go", @@ -1766,119 +830,11 @@ "blob": "6d5d118198d62f545f0ef10216466c4cbec9b16d", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/storage/organization/archived.go", - "target": "edge/shared/storage/organization/archived.go", - "blob": "f0a947a6bec2f360ccf7162b5e0d7aab58747499", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/cascade.go", - "target": "edge/shared/storage/organization/cascade.go", - "blob": "f735105520c2bc379c887445dda74516a8d4f29f", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/create.go", - "target": "edge/shared/storage/organization/create.go", - "blob": "9cf661ded9a9e8187471ac85682fb2c5d5800815", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/db.go", - "target": "edge/shared/storage/organization/db.go", - "blob": "841d914c1741c3a71e189e32ad1dfe2ae8f999eb", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/get.go", - "target": "edge/shared/storage/organization/get.go", - "blob": "54df2ef59f9ffddc0f2917d1d748b1bd608a3e80", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/index_test.go", - "target": "edge/shared/storage/organization/index_test.go", - "blob": "4b42ae41dfc351dba8140657077dd7ca7cbb5394", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/list.go", - "target": "edge/shared/storage/organization/list.go", - "blob": "d50ab508ab749ab55d49cb974a363d3a47148e74", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/member.go", - "target": "edge/shared/storage/organization/member.go", - "blob": "073b5f461d9e8caf1a32c0c677f18b33f399b8fa", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/member_test.go", - "target": "edge/shared/storage/organization/member_test.go", - "blob": "4d82611cdbe96285c67c65e49199f569515d4d51", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/memory_repository_queries_test.go", - "target": "edge/shared/storage/organization/memory_repository_queries_test.go", - "blob": "a05138478d0be4e3f4caca500266f5ca3b34861d", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/memory_repository_test.go", - "target": "edge/shared/storage/organization/memory_repository_test.go", - "blob": "3c2578380f193c61cb2647cb7a76d77205bbd3ce", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/organization.go", - "target": "edge/shared/storage/organization/organization.go", - "blob": "5fd085d8dfa08e388cead3fe6d50d87ace2beb83", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/owned.go", - "target": "edge/shared/storage/organization/owned.go", - "blob": "1d342fb4006fe44924f2a3017c19b10233f23495", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provider.go", - "target": "edge/shared/storage/organization/provider.go", - "blob": "03e1dbfdeb2714241bea537b8156539a649aef2a", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provisioning.go", - "target": "edge/shared/storage/organization/provisioning.go", - "blob": "c76533cd227c9f64b96eb46c92f4e4bc3c5728b6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provisioning_test.go", - "target": "edge/shared/storage/organization/provisioning_test.go", - "blob": "ff2dc3a4650da4059785e8abd2ecc43be5d0dc6e", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/setarchived_test.go", - "target": "edge/shared/storage/organization/setarchived_test.go", - "blob": "20933949c004cba73c2b3f4eadf46cfa5cc1458c", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/test_helpers_test.go", - "target": "edge/shared/storage/organization/test_helpers_test.go", - "blob": "d32f33069a11f97bf5d1d782157fb05fc119177d", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/storage/mongo/dependencies.go", "target": "edge/shared/storage/mongo/dependencies.go", "blob": "4aa6a89fb359a305ddc74d6e4e9bdcdb50cadb49", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/server/server.go", @@ -1914,7 +870,7 @@ "source": "api/edge/shared/interface/api/config.go", "target": "edge/shared/interface/api/config.go", "blob": "1ea1e746f6bd9d28494e9b26d9f97bbdda1f000b", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/register.go", @@ -1952,35 +908,17 @@ "blob": "76f0b7a6e861e1519e7ee018564e5bfadf4be4f6", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/interface/api/ws/config.go", - "target": "edge/shared/interface/api/ws/config.go", - "blob": "3f8bafa8d31b4c3542e0e9fc5a7f23ab36eecf9b", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/ws/handler.go", - "target": "edge/shared/interface/api/ws/handler.go", - "blob": "34979bb556fd75d2b83e999bd6c62dbdbb49958e", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/ws/message.go", - "target": "edge/shared/interface/api/ws/message.go", - "blob": "66544fcdce615e704fa3b51c27b17ec970a34bf9", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/api/http/response/response.go", "target": "pkg/api/http/response/response.go", "blob": "8904d881b718a9ec20d3c4644717118760d8d27e", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/response_test.go", "target": "pkg/api/http/response/response_test.go", "blob": "592cbefaeec2bac5bef8d10223e156b6da150a98", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/result.go", @@ -1992,7 +930,7 @@ "source": "api/edge/shared/interface/api/sresponse/account.go", "target": "edge/shared/interface/api/sresponse/account.go", "blob": "0369f802304ab0b19a57349939ac8a73e924322a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/authresp.go", @@ -2010,31 +948,19 @@ "source": "api/edge/shared/interface/api/sresponse/login.go", "target": "edge/shared/interface/api/sresponse/login.go", "blob": "962b9ca0852da034c70b9d4174f497b887267ac8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_pending.go", "target": "edge/shared/interface/api/sresponse/login_pending.go", "blob": "5bcd839cb379c44c4a95d897af4a055b6b9375fc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_test.go", "target": "edge/shared/interface/api/sresponse/login_test.go", "blob": "419995b9a91ebe8db544c7224be6e945de0c5e55", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/sresponse/organization.go", - "target": "edge/shared/interface/api/sresponse/organization.go", - "blob": "1d0fa6782ba1f9ccde8e2e86fd48683a28aa6b84", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/sresponse/permissions.go", - "target": "edge/shared/interface/api/sresponse/permissions.go", - "blob": "5dced1126eeb6c98936ac25bb74b49268afd1ee9", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/response.go", @@ -2090,12 +1016,6 @@ "blob": "d376b2879803f69acd3d52348d259f5ac3629b1b", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/interface/storage/config.go", - "target": "edge/shared/interface/storage/config.go", - "blob": "8d4369b4a69dbc7a146c27ca1b261f1220ad526d", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/domainprovider/domain_provider.go", "target": "pkg/domainprovider/domain_provider.go", @@ -2174,18 +1094,6 @@ "blob": "7359d959b4c29ef95312fb49056351d408fc9957", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/internal/transport/http/websocket/dispimp.go", - "target": "edge/shared/internal/transport/http/websocket/dispimp.go", - "blob": "1ea2001be84217020e61a8fa827e3fe5cd62ab35", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/internal/transport/http/websocket/router.go", - "target": "edge/shared/internal/transport/http/websocket/router.go", - "blob": "49cbc75d4c298e5504943123bb9dee48dc169a16", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/internal/transport/http/router/metrics/handler.go", "target": "edge/shared/internal/transport/http/router/metrics/handler.go", @@ -2202,7 +1110,7 @@ "source": "api/edge/shared/internal/transport/http/router/dispatcher.go", "target": "edge/shared/internal/transport/http/router/dispatcher.go", "blob": "f173a53bab40b61dead720e92fe1cd27f3e5b5a5", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/router.go", @@ -2220,7 +1128,7 @@ "source": "api/edge/shared/storage/refreshtokens/crud.go", "target": "edge/shared/storage/refreshtokens/crud.go", "blob": "5f8044ca8c7cd30d107f2272042e4b7b1cfea291", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/crud_test.go", @@ -2292,7 +1200,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/login.go", "target": "edge/shared/internal/transport/http/router/public/login.go", "blob": "92666648e8df7c93fc7ac28a4d0e413f0a370499", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit.go", @@ -2316,7 +1224,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/login_test_support_test.go", "target": "edge/shared/internal/transport/http/router/public/login_test_support_test.go", "blob": "7578548953e01a7cf33e265e4202e6c862ad6bb6", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/logout.go", @@ -2340,7 +1248,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/refresh_test.go", "target": "edge/shared/internal/transport/http/router/public/refresh_test.go", "blob": "4f7b9f98a1705414fd1e312629d8ea498c80da64", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/respond.go", @@ -2364,19 +1272,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/validate.go", "target": "edge/shared/internal/transport/http/router/public/validate.go", "blob": "5a5ea587e1a62eb194e68996e841d3a20cf97fce", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/srequest/changepolicies.go", - "target": "edge/shared/interface/api/srequest/changepolicies.go", - "blob": "2c6154e801e645134a1aba4b8d742792cba89ac6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/srequest/changerole.go", - "target": "edge/shared/interface/api/srequest/changerole.go", - "blob": "ec2b3517c840a05f5a17f45db19d21dbf8c5222f", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/login.go", @@ -2472,13 +1368,13 @@ "source": "api/edge/shared/internal/transport/http/router/authorized/handler.go", "target": "edge/shared/internal/transport/http/router/authorized/handler.go", "blob": "bb2fcde2e850dd687e78cad5f4dc95f9b9417ac6", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/router.go", "target": "edge/shared/internal/transport/http/router/authorized/router.go", "blob": "4347dd32db99ae4e46ded03bdff2e64a3a049515", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/session_test.go", @@ -3060,12 +1956,6 @@ "target": "cmd/bff/main.go", "blob": "4c9f6dfd4436bf75fac15690decdac0efaa5b9c2", "adapted_beyond_namespace": true - }, - { - "source": "scripts/mongosh/product-permissions-seed.js", - "target": "scripts/seed-policies.js", - "blob": "52597d928f0154ba9eb1cfc6975d6c44cac104a9", - "adaptation": "SXCHNGE database; retain organization/account/role policy definitions and upsert helpers; omit unrelated policies, backfills and pruning." } ], "excluded": [ diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md index 8863d29..5a8e592 100644 --- a/backend/PROVENANCE.md +++ b/backend/PROVENANCE.md @@ -1,64 +1,62 @@ # Copied Sendico source -Source repository: `S_I/sendico` (`git.sendico.io`). Revision: +Source repository: `S_I/sendico` (`git.sendico.io`), revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. -[PROVENANCE.json](PROVENANCE.json) records each copied file, original path and -Git blob, plus whether a Go file changed beyond import namespace and formatting. -It contains 416 Go source/test files, 112 public OpenAPI files and one adapted policy seed. Sources were -read from the pinned commit, not Sendico's dirty working tree. These are owned -SXCHNGE copies, not submodules or automatic synchronization links. +[PROVENANCE.json](PROVENANCE.json) records each retained file, its original path +and Git blob, and whether a Go file changed beyond namespace replacement and +formatting. It currently covers 232 Go source/test files and 112 public OpenAPI +files. Sources came from the pinned commit, not the dirty Sendico checkout. +These are SXCHNGE-owned copies that may diverge independently. -## What was retained +## Retained implementation -- Shared BFF Chi runtime, process/version/logger bootstrap, HTTP middleware, - auth transport and response conventions. -- Password/account service, local Mongo account/role/policy repositories, - transactions and local organization membership. -- Verification issuance, expiry, attempt limits, consumption and idempotency; - refresh-session rotation/revocation and login rate-limit storage. -- Canonical models, `merrors`, structured logging, health and retained tests. -- The public OpenAPI 4.2.37 entry point and recursive local schema references, - copied into `contracts/sendico` without schema changes (trailing blank lines - normalized in three YAML files). +- Chi HTTP runtime, process/version bootstrap, structured logging and health. +- Customer password validation/hashing, account repository and account service. +- Customer login, pending verification, session validation, refresh rotation, + revocation, browser cookies/CSRF and login rate limiting. +- Verification issuance, expiry, attempts, consumption and idempotency. +- Local Mongo connection, transactions and the repository/query implementation + required by account, session and verification storage, with retained tests. +- Public OpenAPI 4.2.37 and its recursive schema references, isolated under + `contracts/sendico`. These wire files are unchanged except for trailing blank + line normalization in three YAML files; they are not local domain models. -## Deliberate adaptations +## Adaptations and exclusions -- Import roots now belong to `git.sendico.io/tech/sxchnge/bff`. Versions are seeded - from committed Sendico manifests and tidied for the selected dependency set. -- Product's composition root selects local account services instead of platform - services/provisioning. Its shared HTTP runtime remains the implementation. -- Removed internal discovery and broker configuration, registration, startup and - teardown. An instance ID uses the existing UUID dependency without discovery. -- Excluded internal gRPC/protobuf clients/converters and unused model files. - Local account-role values remain; private protobuf conversion helpers do not. -- Program identity and the refresh-cookie name use SXCHNGE. -- The policy seed retains the existing upsert helpers and three local - organization/account/role definitions, with an SXCHNGE database default. - Platform policies, automatic role backfills and pruning are excluded. -- Startup configuration now rejects missing database/middleware/HTTP settings - and invalid HTTP timeouts. Runtime-construction failure closes the database. - Listen/start failures preserve the cause and produce a nonzero CLI exit. -- Copied Casbin adapter errors and test sentinel errors use canonical `merrors` - instead of `errors.New`. Removed raw database settings from error logs. -- The recipient-migration-only test was excluded together with its migration: - neither that platform feature nor historical Sendico data migration is part of - this bootstrap. Tests for every retained runtime/component remain included. +Imports belong to `git.sendico.io/tech/sxchnge/bff`. The composition root selects +local customer services. Discovery, message-bus registration, private gRPC and +platform factories are absent. Process identity and the refresh cookie use SXCHNGE. +Startup validates required settings and timeouts, closes failed connections and +preserves process failures. Errors use `merrors`; logs exclude raw DB settings. -No financial engine, internal service client, gateway, platform organization -provisioning, deployment configuration, credential or generated binary was copied. -The local account service's organization is an SXCHNGE authorization tenant; -it does not create Sendico organizations per customer. +The earlier extraction retained too much. Local organizations, membership, +roles, Casbin, policies, invitations, policy seeding and their repositories and +models are removed. There is no replacement tenant or permissive enforcer. +Unneeded financial models/errors, ledger and wallet provisioning metadata, +platform service names, S3 configuration and WebSocket scaffolding are removed. +Tests specific to removed features are removed with those implementations. -## Local additions +The copied account service now creates a local customer from a raw password, +hashes it and commits the customer and activation token atomically. It uses the +same configured collections as login. Deletion revokes that customer's sessions +and deletes its record atomically; it has no sole-member rule. The copied Mongo +transaction helper joins an active outer transaction so verification does not +commit separately from customer creation. Generic repositories no longer expose +organization- or permission-bound operations. Customer responses use explicit DTO fields and a mapper; they have no anonymous +organization-account flag or embedded database record. Refresh and protected +handlers reject inactive customers; refresh checks account-bound application +clients. -`internal/profile` composes the copied account service. Architecture checks enforce -project errors, no private provider imports/transports and self-contained public -contract references. Configuration tests cover the executable example and missing -settings. Guidelines and the backend-review skill are restored from Sendico with -SXCHNGE paths and the public-only integration boundary. +## Local additions and limits -The source includes complete local auth building blocks, not a fully wired -SXCHNGE product API. Remaining endpoint, email, local tenant/client provisioning -and public Sendico integration work is explicit in the -[architecture plan](../docs/architecture/bff.md). +Architecture tests reject private provider imports, policy-engine dependencies +and local tenancy declarations. Fresh Mongo replica-set tests exercise the actual +customer lifecycle, isolation, duplicate login, rollback, deletion/session +revocation and runtime startup without a seed. Existing retained auth/session and +verification tests remain in place. + +This is a bootstrap. Signup/profile HTTP wiring, verification delivery/completion +and the public Sendico HTTP adapter remain explicit work in the +[architecture plan](../docs/architecture/bff.md). The sole Sendico organization +reference belongs to that adapter's configuration, never to local customers. diff --git a/backend/README.md b/backend/README.md index e442c9a..0c4373f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,7 +1,7 @@ # SXCHNGE BFF A locally owned copy of Sendico's reusable BFF runtime, authentication/session -stack and account service. See [provenance](PROVENANCE.md) and the +stack and customer account service. See [provenance](PROVENANCE.md) and the [architecture and public API plan](../docs/architecture/bff.md). SXCHNGE owns its customers and authentication. SXCHNGE itself is one organization @@ -19,21 +19,16 @@ Copy `config.example.yml` to an untracked `config.yml`. Set: - `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment. - `SXCHNGE_API_ENDPOINT`: `/api/v1`. -Seed the local policy descriptions before first startup (requires `mongosh`): - -```sh -mongosh "$SXCHNGE_MONGO_URI" --file backend/scripts/seed-policies.js -``` - -The seed is repeatable and creates only the three local built-in policy -descriptions required by the copied runtime. It grants no role permissions. +The runtime starts against an empty SXCHNGE database and creates its indexes. +No organization, role, policy or seed is required. Run `make run-backend` from the repository root. The copied CLI accepts `-config.file`, `-version` and `-debug`. Configure exact browser origins in YAML; secure refresh cookies require HTTPS for browser session testing. The runtime mounts `/api/v1/health` and the copied account login, API login, refresh, rotation and logout handlers. Customer/client records must exist in the -local database. The account service is initialized locally; signup/profile and +local database. The account service creates customers and activation tokens +atomically, and deletes customers with session revocation. It is initialized locally; signup/profile and verification-delivery/completion routes are the next wiring work. This is a bootstrap, not a finished authentication deployment. No Sendico calls are made. diff --git a/backend/config.example.yml b/backend/config.example.yml index 0417f80..0773abf 100644 --- a/backend/config.example.yml +++ b/backend/config.example.yml @@ -8,8 +8,6 @@ database: settings: uri_env: SXCHNGE_MONGO_URI database: sxchnge - enforcer: - driver: native api: middleware: domain_env: SXCHNGE_DOMAIN diff --git a/backend/edge/product/interface/accountservice/internal/customer_test.go b/backend/edge/product/interface/accountservice/internal/customer_test.go new file mode 100644 index 0000000..b6f0387 --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/customer_test.go @@ -0,0 +1,130 @@ +package accountserviceimp + +import ( + "context" + "net/url" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func customer(login string) *model.Account { + return (&model.AccountData{LoginData: model.LoginData{UserDataBase: model.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: model.Describable{Name: "Customer"}}).ToAccount() +} + +// Uses the actual copied repositories against an empty SXCHNGE database. +// There is deliberately no seed, organization, role, policy or Sendico connection. +func TestCustomerLifecycleWithoutTenancy(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) + uri, err := container.ConnectionString(ctx) + require.NoError(t, err) + endpoint, err := url.Parse(uri) + require.NoError(t, err) + query := endpoint.Query() + query.Set("directConnection", "true") + endpoint.RawQuery = query.Encode() + uri = endpoint.String() + factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_customers"}}) + require.NoError(t, err) + t.Cleanup(factory.CloseConnection) + // Custom collections prove lifecycle and login storage select the same data. + config := &api.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"} + svc, err := NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, config) + require.NoError(t, err) + first, second := customer("first@example.test"), customer("second@example.test") + firstToken, err := svc.CreateAccount(ctx, first) + require.NoError(t, err) + require.NotEmpty(t, firstToken) + require.Equal(t, model.AccountPendingVerification, first.Status) + require.True(t, first.MatchPassword("CustomerPassword1!")) + _, err = svc.CreateAccount(ctx, second) + require.NoError(t, err) + require.NotEqual(t, first.ID, second.ID) + // Activation tokens cannot be used as another customer's token. + _, err = svc.vdb.Consume(ctx, second.ID, model.PurposeAccountActivation, "", firstToken) + require.Error(t, err) + _, err = svc.vdb.Consume(ctx, first.ID, model.PurposeAccountActivation, "", firstToken) + require.NoError(t, err) + _, err = svc.CreateAccount(ctx, customer("first@example.test")) + require.ErrorIs(t, err, merrors.ErrDataConflict) + require.NoError(t, svc.ValidateAccount(customer("validate@example.test"))) + require.ErrorIs(t, svc.ValidateAccount(nil), merrors.ErrInvalidArg) + _, err = svc.CreateAccount(ctx, nil) + require.ErrorIs(t, err, merrors.ErrInvalidArg) + require.ErrorIs(t, svc.DeleteAccount(ctx, bson.NilObjectID), merrors.ErrInvalidArg) + + for _, c := range []*model.Account{first, second} { + require.NoError(t, svc.sessions.Create(ctx, &model.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: model.ClientRefreshToken{SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}})) + } + require.NoError(t, svc.DeleteAccount(ctx, first.ID)) + var stored model.Account + require.ErrorIs(t, svc.accountDB.Get(ctx, first.ID, &stored), merrors.ErrNoData) + require.NoError(t, svc.accountDB.Get(ctx, second.ID, &stored)) + session := &model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"} + _, err = svc.sessions.GetSession(ctx, first.ID, session) + require.Error(t, err) + _, err = svc.sessions.GetSession(ctx, second.ID, session) + require.NoError(t, err) + // The final local customer can be deleted; there is no sole-member restriction. + require.NoError(t, svc.DeleteAccount(ctx, second.ID)) + + deps, err := edgemongo.ResolveDependencies(factory) + require.NoError(t, err) + collections, err := deps.Database.ListCollectionNames(ctx, bson.D{}) + require.NoError(t, err) + for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} { + require.NotContains(t, collections, forbidden) + } + + // A token issuance failure must roll back the preceding customer insert. + realVerification := svc.vdb + failure := merrors.Internal("injected verification failure") + svc.vdb = &failingVerification{DB: realVerification, failure: failure} + candidate := customer("rollback@example.test") + _, err = svc.CreateAccount(ctx, candidate) + require.ErrorIs(t, err, failure) + require.True(t, candidate.ID.IsZero()) + _, err = svc.accountDB.GetByEmail(ctx, candidate.Login) + require.ErrorIs(t, err, merrors.ErrNoData) + svc.vdb = realVerification + + // If an outer workflow fails after token issuance, neither write may commit. + outerFailure := merrors.Internal("injected outer failure") + _, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + _, e := svc.CreateAccount(tx, customer("outer@example.test")) + if e != nil { + return nil, e + } + return nil, outerFailure + }) + require.ErrorIs(t, err, outerFailure) + _, err = svc.accountDB.GetByEmail(ctx, "outer@example.test") + require.ErrorIs(t, err, merrors.ErrNoData) + count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{}) + require.NoError(t, err) + require.EqualValues(t, 2, count) +} + +type failingVerification struct { + verification.DB + failure error +} + +func (f *failingVerification) Create(context.Context, *verification.Request) (string, error) { + return "", f.failure +} diff --git a/backend/edge/product/interface/accountservice/internal/join_organization_test.go b/backend/edge/product/interface/accountservice/internal/join_organization_test.go deleted file mode 100644 index 70728e0..0000000 --- a/backend/edge/product/interface/accountservice/internal/join_organization_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package accountserviceimp - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// fakeMembershipOrgDB answers membership from its own state, standing in for -// the transaction-scoped reads a real repository would serve. -type fakeMembershipOrgDB struct { - organization.DB - member bool - isMemberCalls int - addMemberCalls int - addedMember bson.ObjectID -} - -func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { - db.isMemberCalls++ - return db.member, nil -} - -func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) { - db.addMemberCalls++ - db.addedMember = accountRef - db.member = true - return true, nil -} - -type recordingRoleManager struct { - assigned []*model.Role -} - -func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) { - description := &model.RoleDescription{} - description.SetID(bson.NewObjectID()) - description.OrganizationRef = orgRef - return description, nil -} - -func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil } - -func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error { - m.assigned = append(m.assigned, role) - return nil -} - -func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil } - -func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) { - return nil, nil -} - -func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service { - return &service{ - logger: zap.NewNop(), - orgDB: orgDB, - roleManager: roles, - } -} - -func joinTestOrganization(members ...bson.ObjectID) *model.Organization { - org := &model.Organization{Members: members} - org.SetID(bson.NewObjectID()) - return org -} - -func joinTestAccount() *model.Account { - account := &model.Account{} - account.SetID(bson.NewObjectID()) - return account -} - -// A transaction replay rolls back the database but leaves marks on the shared -// in-memory organization. Membership decisions must come from the database, -// otherwise the replay skips the role assignment and the membership write and -// commits an organization without its owner. -func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) { - account := joinTestAccount() - // The in-memory list already carries the account: exactly the state a - // first, aborted transaction attempt leaves behind. - org := joinTestOrganization(account.ID) - orgDB := &fakeMembershipOrgDB{member: false} - roles := &recordingRoleManager{} - svc := joinTestService(orgDB, roles) - - if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil { - t.Fatalf("join failed: %v", err) - } - if len(roles.assigned) != 1 { - t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned)) - } - if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID { - t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex()) - } - if len(org.Members) != 1 { - t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members) - } -} - -func TestJoinOrganizationSkipsExistingMember(t *testing.T) { - account := joinTestAccount() - org := joinTestOrganization() - orgDB := &fakeMembershipOrgDB{member: true} - roles := &recordingRoleManager{} - svc := joinTestService(orgDB, roles) - - if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil { - t.Fatalf("join failed: %v", err) - } - if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 { - t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls) - } -} diff --git a/backend/edge/product/interface/accountservice/internal/service.go b/backend/edge/product/interface/accountservice/internal/service.go index 588bf49..ef82c0e 100644 --- a/backend/edge/product/interface/accountservice/internal/service.go +++ b/backend/edge/product/interface/accountservice/internal/service.go @@ -4,19 +4,17 @@ import ( "context" "errors" "fmt" - "slices" "time" "unicode" + authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization" + "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" "git.sendico.io/tech/sxchnge/bff/pkg/db" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" + "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/model" @@ -26,18 +24,19 @@ import ( ) type service struct { - logger mlogger.Logger - accountDB account.DB - orgDB organization.DB - enforcer auth.Enforcer - roleManager management.Role - config *middleware.PasswordConfig + logger mlogger.Logger + accountDB account.DB + config *middleware.PasswordConfig - policyDB policy.DB - vdb verification.DB + vdb verification.DB + sessions refreshtokens.DB + transactions transaction.Factory } func validateUserRequest(u *model.Account) error { + if u == nil { + return merrors.InvalidArgument("account is required") + } if u.Name == "" { return merrors.InvalidArgument("Name must not be empty") } @@ -99,17 +98,17 @@ func (s *service) ValidatePassword( func (s *service) ValidateAccount(acct *model.Account) error { if err := validateUserRequest(acct); err != nil { - s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login)) + s.logger.Warn("Invalid signup account received", zap.Error(err)) return err } if err := s.ValidatePassword(acct.Password, nil); err != nil { - s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login)) + s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct)) return err } if err := acct.HashPassword(); err != nil { - s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login)) + s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct)) return err } @@ -118,45 +117,52 @@ func (s *service) ValidateAccount(acct *model.Account) error { func (s *service) CreateAccount( ctx context.Context, - org *model.Organization, acct *model.Account, - roleDescID bson.ObjectID, ) (string, error) { - if org == nil { - return "", merrors.InvalidArgument("Organization must not be nil") + if acct == nil { + return "", merrors.InvalidArgument("account is required") } - if acct == nil || len(acct.Login) == 0 { - return "", merrors.InvalidArgument("Account must have a non-empty login") + if !acct.ID.IsZero() { + return "", merrors.InvalidArgument("new account must not have an ID") } - if roleDescID == bson.NilObjectID { - return "", merrors.InvalidArgument("Role description must be provided") + candidate := acct.Copy() + if err := s.ValidateAccount(candidate); err != nil { + return "", err } - - // 1) Create the account - acct.Status = model.AccountPendingVerification - if err := s.accountDB.Create(ctx, acct); err != nil { + candidate.Status = model.AccountPendingVerification + // The customer and activation token either both commit or neither does. + var saved *model.Account + result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + saved = candidate.Copy() + if err := s.accountDB.Create(tx, saved); err != nil { + return nil, err + } + return s.VerifyAccount(tx, saved) + }) + if err != nil { if errors.Is(err, merrors.ErrDataConflict) { - s.logger.Info("Username is already taken", zap.String("login", acct.Login)) + s.logger.Info("Customer login already exists") } else { - s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login)) + s.logger.Warn("Customer creation failed", zap.Error(err)) } return "", err } - - // 2) Add to organization - if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil { - s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct)) - return "", err + token, ok := result.(string) + if !ok { + return "", merrors.Internal("unexpected account creation result") } - - // 3) Issue verification token - return s.VerifyAccount(ctx, acct) + *acct = *saved + s.logger.Info("Customer created pending verification", mzap.AccRef(acct.ID)) + return token, nil } func (s *service) VerifyAccount( ctx context.Context, acct *model.Account, ) (string, error) { + if acct == nil || acct.ID.IsZero() { + return "", merrors.InvalidArgument("customer reference is required") + } token, err := s.vdb.Create( ctx, verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, ""). @@ -173,62 +179,22 @@ func (s *service) VerifyAccount( func (s *service) DeleteAccount( ctx context.Context, - org *model.Organization, accountRef bson.ObjectID, ) error { - // Check if this is the only member in the organization - if len(org.Members) <= 1 { - s.logger.Warn("Cannot delete account - it's the only member in the organization", - mzap.AccRef(accountRef), mzap.StorableRef(org)) - return merrors.InvalidArgument("Cannot delete the only member of an organization") + if accountRef.IsZero() { + return merrors.InvalidArgument("account reference is required") } - - // 1) Remove from organization - if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil { - s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // 2) Delete the account document - if err := s.accountDB.Delete(ctx, accountRef); err != nil { - s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - return nil -} - -func (s *service) RemoveAccountFromOrganization( - ctx context.Context, - org *model.Organization, - accountRef bson.ObjectID, -) error { - if org == nil { - return merrors.InvalidArgument("Organization must not be nil") - } - roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID) + _, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + if err := s.sessions.RevokeAll(tx, accountRef, ""); err != nil { + return nil, err + } + return nil, s.accountDB.Delete(tx, accountRef) + }) if err != nil { - s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org), - mzap.AccRef(accountRef)) + s.logger.Warn("Customer deletion failed", zap.Error(err), mzap.AccRef(accountRef)) return err } - for _, role := range roles { - if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil { - s.logger.Warn("Failed to revoke account role", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef)) - return err - } - } - for i, member := range org.Members { - if member == accountRef { - // Remove the member by slicing it out - org.Members = append(org.Members[:i], org.Members[i+1:]...) - if err := s.orgDB.Update(ctx, accountRef, org); err != nil { - s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - break - } - } + s.logger.Info("Customer deleted and sessions revoked", mzap.AccRef(accountRef)) return nil } @@ -236,6 +202,9 @@ func (s *service) ResetPassword( ctx context.Context, acct *model.Account, ) (string, error) { + if acct == nil || acct.ID.IsZero() { + return "", merrors.InvalidArgument("customer reference is required") + } return s.vdb.Create( ctx, verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, ""). @@ -248,6 +217,9 @@ func (s *service) UpdateLogin( acct *model.Account, newLogin string, ) (string, error) { + if acct == nil || acct.ID.IsZero() { + return "", merrors.InvalidArgument("customer reference is required") + } return s.vdb.Create( ctx, verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin). @@ -255,196 +227,22 @@ func (s *service) UpdateLogin( ) } -func (s *service) JoinOrganization( - ctx context.Context, - org *model.Organization, - account *model.Account, - roleDescID bson.ObjectID, -) error { - // Membership is decided by the database, not the in-memory copy: this method - // runs inside provisioning transactions whose retries replay the callback, - // and a stale in-memory list would silently skip the role assignment. - member, err := s.orgDB.IsMember(ctx, org.ID, account.ID) - if err != nil { - return err - } - if member { - s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account)) - return nil - } - - role := &model.Role{ - DescriptionRef: roleDescID, - OrganizationRef: org.ID, - AccountRef: account.ID, - } - if err := s.roleManager.Assign(ctx, role); err != nil { - s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account), - mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID)) - return err - } - - // A point patch on purpose: replacing the whole document would clobber - // concurrent writes, most notably the provisioning lease that guards - // finalization against attempts whose lease was taken over. - if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil { - s.logger.Warn("Failed to update organization members list", - zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org)) - return err - } - if !slices.Contains(org.Members, account.ID) { - org.Members = append(org.Members, account.ID) - } - return nil -} - -func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error { - return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) { - roles, err := s.roleManager.List(ctx, orgRef) - if err != nil { - return nil, err - } - roleIDs := make([]bson.ObjectID, 0, len(roles)) - for _, role := range roles { - roleIDs = append(roleIDs, role.ID) - } - return roleIDs, nil - }, s.roleManager.Delete) -} - -func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error { - return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) { - policies, err := s.policyDB.All(ctx, orgRef) - if err != nil { - return nil, err - } - policyIDs := make([]bson.ObjectID, 0, len(policies)) - for _, policy := range policies { - policyIDs = append(policyIDs, policy.ID) - } - return policyIDs, nil - }, s.policyDB.Delete) -} - -func (s *service) deleteOrganizationEntities( - ctx context.Context, - orgRef bson.ObjectID, - entityName string, - entityLabel string, - entityRefField string, - load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error), - deleteFn func(context.Context, bson.ObjectID) error, -) error { - s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef)) - - entityIDs, err := load(ctx, orgRef) - if err != nil && !errors.Is(err, merrors.ErrNoData) { - s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef)) - return err - } - - for _, entityID := range entityIDs { - if err := deleteFn(ctx, entityID); err != nil { - s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID)) - return err - } - } - - s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef)) - return nil -} - -func (s *service) DeleteOrganization( - ctx context.Context, - org *model.Organization, -) error { - s.logger.Info("Starting organization deletion", mzap.StorableRef(org)) - - // Use transaction to ensure atomicity - // 8. Delete all roles and role descriptions in the organization - if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil { - return err - } - - // 9. Delete all policies in the organization - if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil { - return err - } - - // 10. Finally, delete the organization itself - if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil { - s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org)) - return err - } - - s.logger.Info("Organization deleted successfully", mzap.StorableRef(org)) - return nil -} - -func (s *service) DeleteAll( - ctx context.Context, - org *model.Organization, - accountRef bson.ObjectID, -) error { - s.logger.Info("Starting complete deletion (organization + account)", - mzap.StorableRef(org), mzap.AccRef(accountRef)) - - // 1. First delete the organization and all its data - if err := s.DeleteOrganization(ctx, org); err != nil { - return err - } - - // 2. Then delete the account - if err := s.accountDB.Delete(ctx, accountRef); err != nil { - s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef)) - return nil -} - -// NewAccountService wires in your logger plus the three dependencies. func NewAccountService( l mlogger.Logger, dbf db.Factory, - enforcer auth.Enforcer, - ra management.Role, config *middleware.PasswordConfig, + storageConfig *api.AuthStorageConfig, ) (*service, error) { logger := l.Named("account_service") if config == nil { - return nil, merrors.Internal("Invalid account service configuration provides") + return nil, merrors.Internal("password configuration is required") } - res := &service{ - logger: logger, - enforcer: enforcer, - roleManager: ra, - config: config, - } - deps, err := edgestorage.ResolveDependencies(dbf) + stores, err := authstorage.OpenStores(logger, dbf, storageConfig) if err != nil { return nil, err } - if res.accountDB, err = dbf.NewAccountDB(); err != nil { - logger.Error("Failed to create accounts database", zap.Error(err)) - return nil, err - } - if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil { - logger.Error("Failed to create organizations database", zap.Error(err)) - return nil, err - } - - // Initialize database dependencies for cascade deletion - if res.policyDB, err = dbf.NewPoliciesDB(); err != nil { - logger.Error("Failed to create policies database", zap.Error(err)) - return nil, err - } - if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil { - logger.Error("Failed to create verification database", zap.Error(err)) - return nil, err - } - return res, nil + return &service{logger: logger, config: config, accountDB: stores.Accounts, + vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil } diff --git a/backend/edge/product/interface/accountservice/internal/service_deletion_test.go b/backend/edge/product/interface/accountservice/internal/service_deletion_test.go deleted file mode 100644 index 57aec6f..0000000 --- a/backend/edge/product/interface/accountservice/internal/service_deletion_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package accountserviceimp - -import ( - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestDeleteAccount_Validation(t *testing.T) { - t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) { - orgID := bson.NewObjectID() - accountID := bson.NewObjectID() - - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Single Member Org"}, - }, - Members: []bson.ObjectID{accountID}, // Only one member - } - org.ID = orgID - - // This should fail because it's the only member - err := validateDeleteAccount(org) - require.Error(t, err) - assert.Contains(t, err.Error(), "Cannot delete the only member") - }) - - t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) { - orgID := bson.NewObjectID() - accountID := bson.NewObjectID() - otherAccountID := bson.NewObjectID() - - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Multi Member Org"}, - }, - Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members - } - org.ID = orgID - - // This should succeed because there are multiple members - err := validateDeleteAccount(org) - require.NoError(t, err) - }) - - t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) { - orgID := bson.NewObjectID() - - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Empty Org"}, - }, - Members: []bson.ObjectID{}, // No members - } - org.ID = orgID - - // This should fail because there are no members - err := validateDeleteAccount(org) - require.Error(t, err) - assert.Contains(t, err.Error(), "Cannot delete the only member") - }) -} - -func TestDeleteOrganization_Validation(t *testing.T) { - t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) { - err := validateDeleteOrganization(nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "organization cannot be nil") - }) - - t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) { - org := &model.Organization{} - err := validateDeleteOrganization(org) - require.Error(t, err) - assert.Contains(t, err.Error(), "organization ID cannot be empty") - }) - - t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) { - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Valid Organization"}, - }, - } - org.ID = bson.NewObjectID() - - err := validateDeleteOrganization(org) - require.NoError(t, err) - }) -} - -func TestDeleteAll_Validation(t *testing.T) { - t.Run("DeleteAll_NilOrganization", func(t *testing.T) { - accountID := bson.NewObjectID() - err := validateDeleteAll(nil, accountID) - require.Error(t, err) - assert.Contains(t, err.Error(), "organization cannot be nil") - }) - - t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) { - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Valid Organization"}, - }, - } - org.ID = bson.NewObjectID() - - err := validateDeleteAll(org, bson.NilObjectID) - require.Error(t, err) - assert.Contains(t, err.Error(), "account ID cannot be empty") - }) - - t.Run("DeleteAll_ValidInput", func(t *testing.T) { - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Valid Organization"}, - }, - } - org.ID = bson.NewObjectID() - accountID := bson.NewObjectID() - - err := validateDeleteAll(org, accountID) - require.NoError(t, err) - }) -} - -// Helper functions that implement the validation logic from the service -func validateDeleteAccount(org *model.Organization) error { - if len(org.Members) <= 1 { - return merrors.InvalidArgument("Cannot delete the only member of an organization") - } - return nil -} - -func validateDeleteOrganization(org *model.Organization) error { - if org == nil { - return merrors.InvalidArgument("organization cannot be nil") - } - if org.ID == bson.NilObjectID { - return merrors.InvalidArgument("organization ID cannot be empty") - } - return nil -} - -func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error { - if org == nil { - return merrors.InvalidArgument("organization cannot be nil") - } - if accountRef == bson.NilObjectID { - return merrors.InvalidArgument("account ID cannot be empty") - } - return nil -} diff --git a/backend/edge/product/interface/accountservice/types.go b/backend/edge/product/interface/accountservice/types.go index 146a45c..fa2807f 100644 --- a/backend/edge/product/interface/accountservice/types.go +++ b/backend/edge/product/interface/accountservice/types.go @@ -4,9 +4,8 @@ import ( "context" accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" "git.sendico.io/tech/sxchnge/bff/pkg/db" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/model" @@ -15,14 +14,6 @@ import ( // AccountService defines all account-related workflows. type AccountService interface { - // ValidateAccount will: - // 1) check it's completeness - // 2) hash password - // 3) prepare verification token - ValidateAccount( - acct *model.Account, - ) error - // ValidatePassword will: // 1) check passsword conformance ValidatePassword( @@ -38,14 +29,11 @@ type AccountService interface { ) (verificationToken string, err error) // CreateAccount will: - // 1) create the account - // 2) add it to the org’s member list - // 3) assign the given role description to it + // 1) validate and hash the password, then create the account + // 2) issue an activation token CreateAccount( ctx context.Context, - org *model.Organization, acct *model.Account, - roleDescID bson.ObjectID, ) (verificationToken string, err error) VerifyAccount( @@ -53,42 +41,15 @@ type AccountService interface { acct *model.Account, ) (verificationToken string, err error) - JoinOrganization( - ctx context.Context, - org *model.Organization, - acct *model.Account, - roleDescID bson.ObjectID, - ) error - UpdateLogin( ctx context.Context, acct *model.Account, newLogin string, ) (verificationToken string, err error) - // DeleteAccount deletes the account and removes it from the org. + // DeleteAccount deletes the local customer account. DeleteAccount( ctx context.Context, - org *model.Organization, - accountRef bson.ObjectID, - ) error - - // RemoveAccountFromOrganization just drops it from the member slice. - RemoveAccountFromOrganization( - ctx context.Context, - org *model.Organization, - accountRef bson.ObjectID, - ) error - - DeleteOrganization( - ctx context.Context, - org *model.Organization, - ) error - - // DeleteAll deletes both the organization and the account. - DeleteAll( - ctx context.Context, - org *model.Organization, accountRef bson.ObjectID, ) error } @@ -96,9 +57,8 @@ type AccountService interface { func NewAccountService( logger mlogger.Logger, dbf db.Factory, - enforcer auth.Enforcer, - roleManeger management.Role, config *middleware.PasswordConfig, + storageConfig *api.AuthStorageConfig, ) (AccountService, error) { - return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config) + return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig) } diff --git a/backend/edge/shared/interface/api/api.go b/backend/edge/shared/interface/api/api.go index f68cf6d..480518d 100644 --- a/backend/edge/shared/interface/api/api.go +++ b/backend/edge/shared/interface/api/api.go @@ -3,7 +3,6 @@ package api import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db" "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" @@ -17,7 +16,6 @@ type API interface { Config() *Config ProfileConfig() ConfigProvider DBFactory() db.Factory - Permissions() auth.Provider RuntimeComponent(name string) any Register() Register } diff --git a/backend/edge/shared/interface/api/config.go b/backend/edge/shared/interface/api/config.go index 9b1c500..ce81552 100644 --- a/backend/edge/shared/interface/api/config.go +++ b/backend/edge/shared/interface/api/config.go @@ -2,14 +2,12 @@ package api import ( mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage" ) type Config struct { - Mw *mwa.Config `yaml:"middleware"` - AuthStorage *AuthStorageConfig `yaml:"auth_storage"` - Verification *VerificationConfig `yaml:"verification"` - Storage *storageconfig.Config `yaml:"storage"` + Mw *mwa.Config `yaml:"middleware"` + AuthStorage *AuthStorageConfig `yaml:"auth_storage"` + Verification *VerificationConfig `yaml:"verification"` } func (c *Config) SharedConfig() *Config { diff --git a/backend/edge/shared/interface/api/register.go b/backend/edge/shared/interface/api/register.go index a412717..d20e924 100644 --- a/backend/edge/shared/interface/api/register.go +++ b/backend/edge/shared/interface/api/register.go @@ -2,7 +2,6 @@ package api import ( "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" ) @@ -11,5 +10,4 @@ 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) - WSHandler(messageType string, handler ws.HandlerFunc) } diff --git a/backend/edge/shared/interface/api/runtime.go b/backend/edge/shared/interface/api/runtime.go index a75aa5c..439b4fb 100644 --- a/backend/edge/shared/interface/api/runtime.go +++ b/backend/edge/shared/interface/api/runtime.go @@ -12,12 +12,11 @@ type RuntimeBootstrapFunc func(API) (RuntimeComponents, error) type RuntimeFinishFunc func(API) error type RuntimeConfig struct { - Name string - Services []ServiceFactory - EnableWebSocket bool - ConfigFactory ConfigFactory - Bootstrap RuntimeBootstrapFunc - Finish RuntimeFinishFunc + Name string + Services []ServiceFactory + ConfigFactory ConfigFactory + Bootstrap RuntimeBootstrapFunc + Finish RuntimeFinishFunc } type ServiceFactory struct { diff --git a/backend/edge/shared/interface/api/srequest/changepolicies.go b/backend/edge/shared/interface/api/srequest/changepolicies.go deleted file mode 100644 index 7b12476..0000000 --- a/backend/edge/shared/interface/api/srequest/changepolicies.go +++ /dev/null @@ -1,12 +0,0 @@ -package srequest - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type ChangePolicies struct { - RoleRef bson.ObjectID `json:"roleRef"` - Add *[]model.RolePolicy `json:"add,omitempty"` - Remove *[]model.RolePolicy `json:"remove,omitempty"` -} diff --git a/backend/edge/shared/interface/api/srequest/changerole.go b/backend/edge/shared/interface/api/srequest/changerole.go deleted file mode 100644 index ec2b351..0000000 --- a/backend/edge/shared/interface/api/srequest/changerole.go +++ /dev/null @@ -1,8 +0,0 @@ -package srequest - -import "go.mongodb.org/mongo-driver/v2/bson" - -type ChangeRole struct { - AccountRef bson.ObjectID `json:"accountRef"` - NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"` -} diff --git a/backend/edge/shared/interface/api/sresponse/account.go b/backend/edge/shared/interface/api/sresponse/account.go index 1e7ee59..ee2c821 100644 --- a/backend/edge/shared/interface/api/sresponse/account.go +++ b/backend/edge/shared/interface/api/sresponse/account.go @@ -6,12 +6,15 @@ import ( "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" ) type AccountData struct { - model.AccountPublic `json:",inline"` - IsAnonymous bool `json:"isAnonymous"` + ID string `json:"id"` + Name string `json:"name"` + LastName string `json:"lastName"` + Login string `json:"login"` + Locale string `json:"locale"` + AvatarURL *string `json:"avatarUrl,omitempty"` } type accountResponse struct { @@ -19,15 +22,10 @@ type accountResponse struct { Account AccountData `json:"account"` } -type accountsResponse struct { - authResponse `json:",inline"` - Accounts []AccountData `json:"accounts"` -} - -func NewAccountData(account *model.Account, isAnonymous bool) *AccountData { +func NewAccountData(account *model.Account) *AccountData { return &AccountData{ - AccountPublic: account.AccountPublic, - IsAnonymous: isAnonymous, + ID: account.ID.Hex(), Name: account.Name, LastName: account.LastName, + Login: account.Login, Locale: account.Locale, AvatarURL: account.AvatarURL, } } @@ -35,25 +33,7 @@ func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenDa return response.Ok( logger, &accountResponse{ - Account: *NewAccountData(account, false), - authResponse: authResponse{AccessToken: *accessToken}, - }, - ) -} - -func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc { - publicAccounts := make([]AccountData, len(accounts)) - for i := range accounts { - publicAccounts[i] = *NewAccountData( - &accounts[i], - model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef), - ) - } - - return response.Ok( - logger, - &accountsResponse{ - Accounts: publicAccounts, + Account: *NewAccountData(account), authResponse: authResponse{AccessToken: *accessToken}, }, ) diff --git a/backend/edge/shared/interface/api/sresponse/login.go b/backend/edge/shared/interface/api/sresponse/login.go index ae2f7da..c987f10 100644 --- a/backend/edge/shared/interface/api/sresponse/login.go +++ b/backend/edge/shared/interface/api/sresponse/login.go @@ -25,7 +25,7 @@ func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshTo logger, &loginResponse{ accountResponse: accountResponse{ - Account: *NewAccountData(account, false), + Account: *NewAccountData(account), authResponse: authResponse{AccessToken: *accessToken}, }, RefreshToken: responseRefreshToken, diff --git a/backend/edge/shared/interface/api/sresponse/login_pending.go b/backend/edge/shared/interface/api/sresponse/login_pending.go index 8e054ad..d0333d0 100644 --- a/backend/edge/shared/interface/api/sresponse/login_pending.go +++ b/backend/edge/shared/interface/api/sresponse/login_pending.go @@ -19,7 +19,7 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T logger, &pendingLoginResponse{ Account: accountResponse{ - Account: *NewAccountData(account, false), + Account: *NewAccountData(account), authResponse: authResponse{}, }, PendingToken: *pendingToken, diff --git a/backend/edge/shared/interface/api/sresponse/login_test.go b/backend/edge/shared/interface/api/sresponse/login_test.go index d0e4f84..9866324 100644 --- a/backend/edge/shared/interface/api/sresponse/login_test.go +++ b/backend/edge/shared/interface/api/sresponse/login_test.go @@ -44,3 +44,15 @@ func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) { require.Contains(t, recorder.Body.String(), refresh.Token) require.Empty(t, recorder.Header().Get("Set-Cookie")) } + +func TestCustomerResponseExcludesStorageAndPlatformFields(t *testing.T) { + account := &model.Account{Password: "private-password-hash", Status: model.AccountActive} + account.Login = "customer@example.test" + account.Name = "Customer" + recorder := httptest.NewRecorder() + Account(zap.NewNop(), account, &TokenData{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/accounts/me", nil)) + require.Contains(t, recorder.Body.String(), "customer@example.test") + for _, field := range []string{"private-password-hash", "password", "status\":\"active", "organization", "role", "policy", "isAnonymous", "createdAt", "isArchived"} { + require.NotContains(t, recorder.Body.String(), field) + } +} diff --git a/backend/edge/shared/interface/api/sresponse/organization.go b/backend/edge/shared/interface/api/sresponse/organization.go deleted file mode 100644 index e632a31..0000000 --- a/backend/edge/shared/interface/api/sresponse/organization.go +++ /dev/null @@ -1,35 +0,0 @@ -package sresponse - -import ( - "net/http" - - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type organizationsResponse struct { - authResponse `json:",inline"` - Organizations []model.Organization `json:"organizations"` -} - -func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc { - return Organizations(logger, []model.Organization{*organization}, accessToken) -} - -func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc { - return response.Ok(logger, organizationsResponse{ - Organizations: organizations, - authResponse: authResponse{AccessToken: *accessToken}, - }) -} - -type organizationPublicResponse struct { - Organizations []model.OrganizationBase `json:"organizations"` -} - -func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc { - return response.Ok(logger, organizationPublicResponse{ - Organizations: []model.OrganizationBase{*organization}, - }) -} diff --git a/backend/edge/shared/interface/api/sresponse/permissions.go b/backend/edge/shared/interface/api/sresponse/permissions.go deleted file mode 100644 index f1ca7f2..0000000 --- a/backend/edge/shared/interface/api/sresponse/permissions.go +++ /dev/null @@ -1,45 +0,0 @@ -package sresponse - -import ( - "net/http" - - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type permissionsDescription struct { - Roles []model.RoleDescription `json:"roles"` - Policies []model.PolicyDescription `json:"policies"` -} - -type permissionsData struct { - Roles []model.Role `json:"roles"` - Policies []model.RolePolicy `json:"policies"` - Permissions []model.Permission `json:"permissions"` -} - -type permissionsResponse struct { - authResponse `json:",inline"` - Descriptions permissionsDescription `json:"descriptions"` - Permissions permissionsData `json:"permissions"` -} - -func Permissions(logger mlogger.Logger, - rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription, - roles []model.Role, policies []model.RolePolicy, permissions []model.Permission, - accessToken *TokenData, -) http.HandlerFunc { - return response.Ok(logger, permissionsResponse{ - Descriptions: permissionsDescription{ - Roles: rolesDescs, - Policies: policiesDescs, - }, - Permissions: permissionsData{ - Roles: roles, - Policies: policies, - Permissions: permissions, - }, - authResponse: authResponse{AccessToken: *accessToken}, - }) -} diff --git a/backend/edge/shared/interface/api/ws/config.go b/backend/edge/shared/interface/api/ws/config.go deleted file mode 100644 index b37a5a7..0000000 --- a/backend/edge/shared/interface/api/ws/config.go +++ /dev/null @@ -1,9 +0,0 @@ -package ws - -import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" -) - -type ( - Config = middleware.WebSocketConfig -) diff --git a/backend/edge/shared/interface/api/ws/handler.go b/backend/edge/shared/interface/api/ws/handler.go deleted file mode 100644 index 34979bb..0000000 --- a/backend/edge/shared/interface/api/ws/handler.go +++ /dev/null @@ -1,12 +0,0 @@ -package ws - -import ( - "context" - - "golang.org/x/net/websocket" -) - -type ( - ResponseHandler func(messageType string, conn *websocket.Conn) - HandlerFunc func(ctx context.Context, msg Message) ResponseHandler -) diff --git a/backend/edge/shared/interface/api/ws/message.go b/backend/edge/shared/interface/api/ws/message.go deleted file mode 100644 index e4bb5d6..0000000 --- a/backend/edge/shared/interface/api/ws/message.go +++ /dev/null @@ -1,9 +0,0 @@ -package ws - -import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - -type Message struct { - response.BaseResponse - ID string `json:"id"` - MessageType string `json:"messageType"` -} diff --git a/backend/edge/shared/interface/middleware/middleware.go b/backend/edge/shared/interface/middleware/middleware.go index 7a3ebb8..86fded4 100644 --- a/backend/edge/shared/interface/middleware/middleware.go +++ b/backend/edge/shared/interface/middleware/middleware.go @@ -68,11 +68,6 @@ func (c TokenConfig) Validate() error { return nil } -type WebSocketConfig struct { - EndpointEnv string `yaml:"endpoint_env"` - Timeout int `yaml:"timeout"` -} - type PasswordChecks struct { Digit bool `yaml:"digit"` Upper bool `yaml:"upper"` @@ -92,7 +87,6 @@ type Config struct { APIProtocolEnv string `yaml:"api_protocol_env"` Signature SignatureConfig `yaml:"signature"` CORS CORSSettings `yaml:"CORS"` - WebSocket WebSocketConfig `yaml:"websocket"` Token TokenConfig `yaml:"token"` Password PasswordConfig `yaml:"password"` LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"` diff --git a/backend/edge/shared/interface/storage/config.go b/backend/edge/shared/interface/storage/config.go deleted file mode 100644 index 5024e6a..0000000 --- a/backend/edge/shared/interface/storage/config.go +++ /dev/null @@ -1,25 +0,0 @@ -package storage - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type Driver string - -const ( - LocalFS Driver = "local_fs" - AWSS3 Driver = "aws_s3" -) - -type Config = model.DriverConfig[Driver] - -type LocalFSConfig struct { - RootPath string `mapstructure:"root_path" yaml:"root_path"` -} - -type AWSS3Config struct { - AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"` - SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"` - RegionEnv string `mapstructure:"region_env" yaml:"region_env"` - BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"` - EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"` - UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"` -} diff --git a/backend/edge/shared/internal/runtime/api.go b/backend/edge/shared/internal/runtime/api.go index 165c605..e20747b 100644 --- a/backend/edge/shared/internal/runtime/api.go +++ b/backend/edge/shared/internal/runtime/api.go @@ -6,7 +6,6 @@ import ( "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http" amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db" "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" @@ -58,10 +57,6 @@ func (a *APIImp) Register() api.Register { return a.mw } -func (a *APIImp) Permissions() auth.Provider { - return a.db.Permissions() -} - func (a *APIImp) RuntimeComponent(name string) any { if a == nil || a.components == nil { return nil diff --git a/backend/edge/shared/internal/runtime/bootstrap.go b/backend/edge/shared/internal/runtime/bootstrap.go index f4fb56e..bdc136b 100644 --- a/backend/edge/shared/internal/runtime/bootstrap.go +++ b/backend/edge/shared/internal/runtime/bootstrap.go @@ -79,11 +79,9 @@ func (a *APIImp) installRuntimeBasics( if a.mw, err = httptransport.CreateMiddleware( logger, db, - a.db.Permissions().Enforcer(), apiRouter, a.config.Mw, a.config.AuthStorage, - a.runtime.EnableWebSocket, debug, ); err != nil { a.logger.Error("Failed to create middleware", zap.Error(err)) diff --git a/backend/edge/shared/internal/runtime/bootstrap_test.go b/backend/edge/shared/internal/runtime/bootstrap_test.go new file mode 100644 index 0000000..91b76f9 --- /dev/null +++ b/backend/edge/shared/internal/runtime/bootstrap_test.go @@ -0,0 +1,63 @@ +package runtime_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime" + "git.sendico.io/tech/sxchnge/bff/internal/profile" + "git.sendico.io/tech/sxchnge/bff/pkg/db" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +func TestProfileStartsOnEmptyDatabase(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) + uri, err := container.ConnectionString(ctx) + require.NoError(t, err) + endpoint, err := url.Parse(uri) + require.NoError(t, err) + query := endpoint.Query() + query.Set("directConnection", "true") + endpoint.RawQuery = query.Encode() + uri = endpoint.String() + factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_runtime"}}) + require.NoError(t, err) + t.Cleanup(factory.CloseConnection) + data, err := os.ReadFile("../../../../config.example.yml") + require.NoError(t, err) + var example struct { + API api.Config `yaml:"api"` + } + require.NoError(t, yaml.Unmarshal(data, &example)) + t.Setenv("SXCHNGE_DOMAIN", "localhost") + t.Setenv("SXCHNGE_API_PROTOCOL", "http") + t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") + t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") + root, routes := chi.NewRouter(), chi.NewRouter() + runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) }) + root.Mount("/", routes) + result := httptest.NewRecorder() + root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)) + require.Equal(t, http.StatusOK, result.Code) + // Runtime auth routes are installed even with zero customers and no seed. + result = httptest.NewRecorder() + root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil)) + require.Equal(t, http.StatusBadRequest, result.Code) +} diff --git a/backend/edge/shared/internal/transport/http/middleware.go b/backend/edge/shared/internal/transport/http/middleware.go index ea519cc..6e8a502 100644 --- a/backend/edge/shared/internal/transport/http/middleware.go +++ b/backend/edge/shared/internal/transport/http/middleware.go @@ -9,14 +9,11 @@ import ( authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage" edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router" mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics" - ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket" api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" @@ -34,7 +31,6 @@ type Middleware struct { router *chi.Mux apiEndpoint string metrics mr.Metrics - wshandler ws.Router epdispatcher *httprouter.Dispatcher } @@ -50,14 +46,6 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler) } -func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) { - if mw.wshandler == nil { - mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType)) - return - } - mw.wshandler.InstallHandler(messageType, handler) -} - func (mw *Middleware) Finish() { mw.metrics.Finish() } @@ -116,11 +104,9 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool { func CreateMiddleware( logger mlogger.Logger, db db.Factory, - enforcer auth.Enforcer, apiRouter *chi.Mux, config *middleware.Config, authStorage *edgeapi.AuthStorageConfig, - installWebSocket bool, debug bool, ) (*Middleware, error) { if config == nil { @@ -161,12 +147,8 @@ func CreateMiddleware( stores.Verification, stores.RefreshTokens, stores.LoginAttempts, - enforcer, config, loginRateLimits, ) - if installWebSocket { - p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint) - } return p, nil } diff --git a/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go b/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go new file mode 100644 index 0000000..26c25bb --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go @@ -0,0 +1,74 @@ +package authorized + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" + "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" + emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" + api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" + "git.sendico.io/tech/sxchnge/bff/pkg/db/account" + "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type customerDB struct { + account.DB + customer model.Account + requested bson.ObjectID +} + +func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error { + d.requested = id + if id != d.customer.ID { + return merrors.NoData("customer not found") + } + *result = d.customer + return nil +} +func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) { + return &model.Client{ClientID: "web"}, nil +} + +func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) { + for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} { + t.Run(string(status), func(t *testing.T) { + accounts := &customerDB{} + accounts.customer.ID = bson.NewObjectID() + accounts.customer.Status = status + routes := chi.NewRouter() + signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")} + router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature) + called := false + router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc { + called = true + require.Equal(t, accounts.customer.ID, c.ID) + return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + }) + ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil) + _, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"})) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil) + req.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + routes.ServeHTTP(recorder, req) + require.Equal(t, accounts.customer.ID, accounts.requested) + if status == model.AccountActive { + require.Equal(t, http.StatusNoContent, recorder.Code) + require.True(t, called) + } else { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.False(t, called) + } + }) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/authorized/handler.go b/backend/edge/shared/internal/transport/http/router/authorized/handler.go index fdb5fb7..0abafd0 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/handler.go +++ b/backend/edge/shared/internal/transport/http/router/authorized/handler.go @@ -128,6 +128,10 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin } return response.Internal(ar.logger, ar.service, err) } + if !a.IsActive() || a.IsArchived() { + return response.Unauthorized(ar.logger, ar.service, "customer is inactive") + } + accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier) if err != nil { ar.logger.Warn("Failed to generate access token", zap.Error(err)) @@ -148,6 +152,9 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin } return response.Internal(ar.logger, ar.service, err) } + if a.Status == model.AccountBlocked || a.IsArchived() { + return response.Unauthorized(ar.logger, ar.service, "customer is inactive") + } return handler(r, &a, t) } ar.tokenHandler(service, endpoint, method, hndlr) diff --git a/backend/edge/shared/internal/transport/http/router/authorized/router.go b/backend/edge/shared/internal/transport/http/router/authorized/router.go index 1abc55a..a7af5b4 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/router.go +++ b/backend/edge/shared/internal/transport/http/router/authorized/router.go @@ -10,7 +10,6 @@ import ( "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" @@ -28,7 +27,7 @@ type AuthorizedRouter struct { service mservice.Type } -func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { +func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey) router.Use(jwtauth.Verifier(ja)) router.Use(jwtauth.Authenticator(ja)) diff --git a/backend/edge/shared/internal/transport/http/router/dispatcher.go b/backend/edge/shared/internal/transport/http/router/dispatcher.go index 6c6c7cd..733109b 100644 --- a/backend/edge/shared/internal/transport/http/router/dispatcher.go +++ b/backend/edge/shared/internal/transport/http/router/dispatcher.go @@ -13,7 +13,6 @@ import ( "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" @@ -44,7 +43,6 @@ func NewDispatcher( vdb verification.DB, rtdb refreshtokens.DB, attempts ratelimits.Store, - enforcer auth.Enforcer, config *middleware.Config, loginRateLimits middleware.LoginRateLimits, ) *Dispatcher { @@ -59,7 +57,7 @@ func NewDispatcher( d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits) }) router.Group(func(r chi.Router) { - d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature) + d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature) }) return d diff --git a/backend/edge/shared/internal/transport/http/router/public/login.go b/backend/edge/shared/internal/transport/http/router/public/login.go index 79636f5..f6b3c7f 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login.go +++ b/backend/edge/shared/internal/transport/http/router/public/login.go @@ -46,7 +46,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") } - if !account.IsActive() { + if !account.IsActive() || account.IsArchived() { return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required") } diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go index 2a60f86..df513c2 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go +++ b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go @@ -87,10 +87,6 @@ func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.A return db.result, db.err } -func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) { - return nil, merrors.NotImplemented("stub") -} - func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error { return merrors.NotImplemented("stub") } diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go index 54acf0c..2a8dabf 100644 --- a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go +++ b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go @@ -135,3 +135,17 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) { require.Contains(t, response.Body.String(), `"error":"unauthorized"`) require.Empty(t, response.Header().Get("Set-Cookie")) } + +func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) { + for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} { + t.Run(string(status), func(t *testing.T) { + router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour)) + router.db.(*testAccountDB).result.Status = status + require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code) + }) + } + router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour)) + other := testActiveAccount(t) + sessions.client.AccountRef = &other.ID + require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/validate.go b/backend/edge/shared/internal/transport/http/router/public/validate.go index bd7b514..0d4b93e 100644 --- a/backend/edge/shared/internal/transport/http/router/public/validate.go +++ b/backend/edge/shared/internal/transport/http/router/public/validate.go @@ -88,6 +88,13 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques return nil, nil, nil, err } + if !account.IsActive() || account.IsArchived() { + return nil, nil, nil, merrors.Unauthorized("customer is inactive") + } + if client.AccountRef != nil && *client.AccountRef != account.ID { + return nil, nil, nil, merrors.Unauthorized("client account mismatch") + } + accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier) if err != nil { pr.logger.Warn("Failed to generate access token", zap.Error(err)) diff --git a/backend/edge/shared/internal/transport/http/websocket/dispimp.go b/backend/edge/shared/internal/transport/http/websocket/dispimp.go deleted file mode 100644 index 7b9cac3..0000000 --- a/backend/edge/shared/internal/transport/http/websocket/dispimp.go +++ /dev/null @@ -1,68 +0,0 @@ -package websocket - -import ( - "context" - "fmt" - "net/http" - "os" - "time" - - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "github.com/go-chi/chi/v5" - "go.uber.org/zap" - "golang.org/x/net/websocket" -) - -type DispatcherImpl struct { - logger mlogger.Logger - handlers map[string]ws.HandlerFunc - timeout int -} - -func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) { - d.handlers[messageType] = handler - d.logger.Info("Handler installed", zap.String("message_type", messageType)) -} - -func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) { - var msg ws.Message - err := websocket.JSON.Receive(conn, &msg) - if err != nil { - d.logger.Warn("Failed to read websocket message", zap.Error(err)) - return - } - - if handler, exists := d.handlers[msg.MessageType]; exists { - responseHandler := handler(ctx, msg) - responseHandler(msg.MessageType, conn) - } else { - d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg)) - } -} - -func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) { - //nolint:contextcheck // websocket.Handler callback signature does not carry request context. - websocket.Handler(func(conn *websocket.Conn) { - ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second) - defer cancel() - d.dispatchMessage(ctx, conn) - }).ServeHTTP(w, r) -} - -func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl { - d := &DispatcherImpl{ - logger: logger.Named("websocket"), - handlers: make(map[string]ws.HandlerFunc), - timeout: config.Timeout, - } - - d.logger.Debug("Installing websocket middleware...") - router.Group(func(r chi.Router) { - ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv)) - d.logger.Info("Installing websockets handler", zap.String("endpoint", ep)) - r.Get(ep, d.handle) - }) - - return d -} diff --git a/backend/edge/shared/internal/transport/http/websocket/router.go b/backend/edge/shared/internal/transport/http/websocket/router.go deleted file mode 100644 index 600c41e..0000000 --- a/backend/edge/shared/internal/transport/http/websocket/router.go +++ /dev/null @@ -1,15 +0,0 @@ -package websocket - -import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "github.com/go-chi/chi/v5" -) - -type Router interface { - InstallHandler(messageType string, handler ws.HandlerFunc) -} - -func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router { - return NewDispatcher(logger, router, config, apiEndpoint) -} diff --git a/backend/edge/shared/storage/mongo/dependencies.go b/backend/edge/shared/storage/mongo/dependencies.go index 84105e8..0b22592 100644 --- a/backend/edge/shared/storage/mongo/dependencies.go +++ b/backend/edge/shared/storage/mongo/dependencies.go @@ -1,19 +1,15 @@ package mongo import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "go.mongodb.org/mongo-driver/v2/mongo" ) -// Dependencies resolves shared DB/auth/transaction primitives for edge-local store wiring only. +// Dependencies resolves local DB/transaction primitives for edge-local store wiring only. type Dependencies struct { Database *mongo.Database - Enforcer auth.Enforcer - Policies policy.DB Transactions transaction.Factory } @@ -31,20 +27,8 @@ func ResolveDependencies(dbf db.Factory) (*Dependencies, error) { return nil, merrors.Internal("mongo database provider is not available for edge-local storage wiring") } - permissions := dbf.Permissions() - if permissions == nil || permissions.Enforcer() == nil { - return nil, merrors.Internal("permissions provider is not available for edge-local storage wiring") - } - - pdb, err := dbf.NewPoliciesDB() - if err != nil { - return nil, err - } - return &Dependencies{ Database: provider.MongoDatabase(), - Enforcer: permissions.Enforcer(), - Policies: pdb, Transactions: dbf.TransactionFactory(), }, nil } diff --git a/backend/edge/shared/storage/organization/archived.go b/backend/edge/shared/storage/organization/archived.go deleted file mode 100644 index 68c2ca3..0000000 --- a/backend/edge/shared/storage/organization/archived.go +++ /dev/null @@ -1,32 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// SetArchived sets the archived status of an organization and optionally cascades to projects, tasks, comments, and reactions -func (db *OrganizationDB) SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error { - db.DBImp.Logger.Debug("Setting organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade)) - - // Get the organization first - var organization model.Organization - if err := db.Get(ctx, accountRef, organizationRef, &organization); err != nil { - db.DBImp.Logger.Warn("Error retrieving organization for archival", zap.Error(err), mzap.OrgRef(organizationRef)) - return err - } - - // Update the organization's archived status - organization.SetArchived(archived) - if err := db.Update(ctx, accountRef, &organization); err != nil { - db.DBImp.Logger.Warn("Error updating organization archived status", zap.Error(err), mzap.OrgRef(organizationRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully set organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived)) - return nil -} diff --git a/backend/edge/shared/storage/organization/cascade.go b/backend/edge/shared/storage/organization/cascade.go deleted file mode 100644 index b2f11d4..0000000 --- a/backend/edge/shared/storage/organization/cascade.go +++ /dev/null @@ -1,23 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// DeleteCascade deletes an organization and all its related data (projects, tasks, comments, reactions, statuses) -func (db *OrganizationDB) DeleteCascade(ctx context.Context, organizationRef bson.ObjectID) error { - db.DBImp.Logger.Debug("Starting organization deletion with projects", mzap.OrgRef(organizationRef)) - - // Delete the organization itself - if err := db.Unprotected().Delete(ctx, organizationRef); err != nil { - db.DBImp.Logger.Warn("Error deleting organization", zap.Error(err), mzap.OrgRef(organizationRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully deleted organization with projects", mzap.OrgRef(organizationRef)) - return nil -} diff --git a/backend/edge/shared/storage/organization/create.go b/backend/edge/shared/storage/organization/create.go deleted file mode 100644 index 15a2928..0000000 --- a/backend/edge/shared/storage/organization/create.go +++ /dev/null @@ -1,19 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *OrganizationDB) Create(ctx context.Context, _, _ bson.ObjectID, org *model.Organization) error { - if org == nil { - return merrors.InvalidArgument("Organization object is nil", "organization") - } - org.SetID(bson.NewObjectID()) - // Organizaiton reference must be set to the same value as own organization reference - org.SetOrganizationRef(*org.GetID()) - return db.DBImp.Create(ctx, org) -} diff --git a/backend/edge/shared/storage/organization/db.go b/backend/edge/shared/storage/organization/db.go deleted file mode 100644 index 2bf8f4b..0000000 --- a/backend/edge/shared/storage/organization/db.go +++ /dev/null @@ -1,113 +0,0 @@ -package organization - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type OrganizationDB struct { - auth.ProtectedDBImp[*model.Organization] -} - -func Create(ctx context.Context, - logger mlogger.Logger, - enforcer auth.Enforcer, - pdb policy.DB, - db *mongo.Database, -) (*OrganizationDB, error) { - p, err := auth.CreateDBImp[*model.Organization](ctx, logger, pdb, enforcer, mservice.Organizations, db) - if err != nil { - return nil, err - } - - res := &OrganizationDB{ - ProtectedDBImp: *p, - } - p.DBImp.SetDeleter(res.DeleteCascade) - if err := ensureProvisioningIndexes(ctx, res); err != nil { - return nil, err - } - return res, nil -} - -// ensureProvisioningIndexes guards concurrent operator-driven provisioning: -// two attempts creating an organization for the same owner and name race the -// find-then-create in the provisioner, and the unique index makes the loser -// fail over to the winner's organization instead of duplicating it. The index -// spans every provisioned state, not only pending: activation keeps the -// provisioning provenance, so a retry racing a concurrent finalization is -// stopped here rather than by the pre-create lookups alone. It is keyed by the -// immutable provisioning name snapshot, not the mutable display name, so a -// rename can neither free the key for a duplicate nor collide with a neighbour. -func ensureProvisioningIndexes(ctx context.Context, db *OrganizationDB) error { - definition := &ri.Definition{ - Name: "uq_organization_provisioning_owner_name", - Keys: []ri.Key{ - {Field: model.OrganizationProvisioningOwnerRefField, Sort: ri.Asc}, - {Field: model.OrganizationProvisioningNameField, Sort: ri.Asc}, - }, - Unique: true, - // Archived organizations leave the index: archiving is how an operator - // retires a name, and a retired name must be reusable. Documents from - // builds that predate the name snapshot stay out as well; sharing the - // missing-name key would otherwise make them collide with each other. - PartialFilter: repository.Query(). - Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true). - Comparison(repository.Field(model.OrganizationProvisioningNameField), builder.Exists, true). - Filter(repository.Field(storable.IsArchivedField), false), - } - if err := db.DBImp.Repository.CreateIndex(definition); err != nil { - db.DBImp.Logger.Error("Failed to create provisioned organization index", - zap.String("index", definition.Name), zap.Error(err)) - return merrors.InternalWrap(err, "failed to create provisioned organization index") - } - // Predecessors of this index on environments that ran earlier builds of - // this feature: the pending-only variant ignored isArchived, the display - // name variant let a rename detach the organization from its dedup key. - if err := dropSupersededProvisioningIndexes(ctx, db.DBImp.Repository); err != nil { - db.DBImp.Logger.Error("Failed to remove superseded organization provisioning indexes", zap.Error(err)) - return err - } - return nil -} - -var supersededProvisioningIndexes = [...]string{ - "uq_organization_pending_provisioning_owner_name", - "uq_organization_provisioned_owner_name", -} - -func dropSupersededProvisioningIndexes(ctx context.Context, repo repository.Repository) error { - for _, name := range supersededProvisioningIndexes { - if err := repo.DropIndex(ctx, name); err != nil { - if provisioningIndexNotFound(err) { - continue - } - return merrors.InternalWrap(err, "failed to drop superseded organization provisioning index "+name) - } - } - return nil -} - -const ( - mongoNamespaceNotFoundCode = 26 - mongoIndexNotFoundCode = 27 -) - -func provisioningIndexNotFound(err error) bool { - var serverErr mongo.ServerError - return errors.As(err, &serverErr) && - (serverErr.HasErrorCode(mongoNamespaceNotFoundCode) || serverErr.HasErrorCode(mongoIndexNotFoundCode)) -} diff --git a/backend/edge/shared/storage/organization/get.go b/backend/edge/shared/storage/organization/get.go deleted file mode 100644 index c6a001f..0000000 --- a/backend/edge/shared/storage/organization/get.go +++ /dev/null @@ -1,12 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *OrganizationDB) GetByRef(ctx context.Context, organizationRef bson.ObjectID, org *model.Organization) error { - return db.Unprotected().Get(ctx, organizationRef, org) -} diff --git a/backend/edge/shared/storage/organization/index_test.go b/backend/edge/shared/storage/organization/index_test.go deleted file mode 100644 index 786e2d0..0000000 --- a/backend/edge/shared/storage/organization/index_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package organization - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -func TestDropSupersededProvisioningIndexesIgnoresMissingIndexes(t *testing.T) { - repo := &dropIndexRepository{dropErrors: []error{ - mongo.CommandError{Code: mongoIndexNotFoundCode}, - mongo.CommandError{Code: mongoNamespaceNotFoundCode}, - }} - - if err := dropSupersededProvisioningIndexes(context.Background(), repo); err != nil { - t.Fatalf("missing superseded indexes must be ignored: %v", err) - } - if len(repo.dropped) != len(supersededProvisioningIndexes) { - t.Fatalf("drop count mismatch: got=%d want=%d", len(repo.dropped), len(supersededProvisioningIndexes)) - } -} - -func TestDropSupersededProvisioningIndexesPreservesUnexpectedFailure(t *testing.T) { - wantErr := merrors.ServiceUnavailable("MongoDB is unavailable") - repo := &dropIndexRepository{dropErrors: []error{wantErr}} - - err := dropSupersededProvisioningIndexes(context.Background(), repo) - if !errors.Is(err, wantErr) { - t.Fatalf("unexpected drop failure must be preserved: got=%v want=%v", err, wantErr) - } - if len(repo.dropped) != 1 { - t.Fatalf("cleanup must stop after an unexpected failure: dropped=%v", repo.dropped) - } -} - -type dropIndexRepository struct { - repository.Repository - dropErrors []error - dropped []string -} - -func (r *dropIndexRepository) DropIndex(_ context.Context, name string) error { - r.dropped = append(r.dropped, name) - if len(r.dropErrors) == 0 { - return nil - } - err := r.dropErrors[0] - r.dropErrors = r.dropErrors[1:] - return err -} diff --git a/backend/edge/shared/storage/organization/list.go b/backend/edge/shared/storage/organization/list.go deleted file mode 100644 index a4a677c..0000000 --- a/backend/edge/shared/storage/organization/list.go +++ /dev/null @@ -1,16 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *OrganizationDB) List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) { - filter := repository.Query().Comparison(repository.Field("members"), builder.Eq, accountRef) - return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, filter, cursor, db.DBImp.Repository) -} diff --git a/backend/edge/shared/storage/organization/member.go b/backend/edge/shared/storage/organization/member.go deleted file mode 100644 index 8c43150..0000000 --- a/backend/edge/shared/storage/organization/member.go +++ /dev/null @@ -1,49 +0,0 @@ -package organization - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func (db *OrganizationDB) IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) { - db.DBImp.Logger.Debug("Checking organization membership", mzap.OrgRef(orgRef), mzap.AccRef(callerRef)) - - filter := repository.Query(). - Filter(repository.IDField(), orgRef). - Comparison(repository.Field("members"), builder.Eq, callerRef) - var org model.Organization - if err := db.Unprotected().FindOne(ctx, filter, &org); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return false, nil - } - db.DBImp.Logger.Warn("Membership check failed", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(callerRef)) - return false, err - } - return true, nil -} - -// AddMember atomically appends the account to the members list and reports -// false when it already was one. A point patch on purpose: replacing the whole -// document would clobber concurrent writes to fields the caller never read, -// most notably the provisioning lease. -func (db *OrganizationDB) AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) { - filter := repository.Query(). - Filter(repository.IDField(), orgRef). - Comparison(repository.Field("members"), builder.Ne, accountRef) - patch := repository.Patch().AddToSet(repository.Field("members"), accountRef) - - matched, err := db.DBImp.PatchMany(ctx, filter, patch) - if err != nil { - db.DBImp.Logger.Warn("Failed to add organization member", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(accountRef)) - return false, err - } - return matched > 0, nil -} diff --git a/backend/edge/shared/storage/organization/member_test.go b/backend/edge/shared/storage/organization/member_test.go deleted file mode 100644 index a32daf2..0000000 --- a/backend/edge/shared/storage/organization/member_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package organization - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// membershipTestRepository shadows FindOneByFilter with a members-aware implementation. -// When findErr is set, FindOneByFilter returns it immediately. -type membershipTestRepository struct { - *memoryOrganizationRepository - findErr error -} - -func (r *membershipTestRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error { - if r.findErr != nil { - return r.findErr - } - r.mu.RLock() - defer r.mu.RUnlock() - for _, id := range r.order { - if org, ok := r.data[id]; ok && memberMatchesQuery(query, org) { - dst, ok := result.(*model.Organization) - if !ok { - return merrors.InvalidDataType("expected organization result") - } - *dst = *cloneOrganization(org) - return nil - } - } - return merrors.ErrNoData -} - -func memberMatchesQuery(query builder.Query, org *model.Organization) bool { - if query == nil { - return true - } - for _, elem := range query.BuildQuery() { - switch elem.Key { - case storable.IDField: - id, ok := elem.Value.(bson.ObjectID) - if !ok || *org.GetID() != id { - return false - } - case "members": - cond, ok := elem.Value.(bson.M) - if !ok { - return false - } - ref, ok := cond[string(builder.Eq)].(bson.ObjectID) - if !ok { - return false - } - found := false - for _, m := range org.Members { - if m == ref { - found = true - break - } - } - if !found { - return false - } - } - } - return true -} - -func newMemberTestDB(t *testing.T, findErr error) *OrganizationDB { - t.Helper() - repo := &membershipTestRepository{ - memoryOrganizationRepository: newMemoryOrganizationRepository(), - findErr: findErr, - } - dbImp := &template.DBImp[*model.Organization]{ - Logger: zap.NewNop(), - Repository: repo, - } - dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error { - return repo.Delete(ctx, objectRef) - }) - return &OrganizationDB{ - ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{ - DBImp: dbImp, - Enforcer: allowAllEnforcer{}, - PermissionRef: bson.NewObjectID(), - Collection: mservice.Organizations, - }, - } -} - -func TestOrganizationDB_IsMember_Member(t *testing.T) { - ctx := context.Background() - db := newMemberTestDB(t, nil) - callerRef := bson.NewObjectID() - - org := &model.Organization{} - org.SetID(bson.NewObjectID()) - org.Members = []bson.ObjectID{callerRef} - require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org)) - - ok, err := db.IsMember(ctx, *org.GetID(), callerRef) - require.NoError(t, err) - assert.True(t, ok) -} - -func TestOrganizationDB_IsMember_NonMember(t *testing.T) { - ctx := context.Background() - db := newMemberTestDB(t, nil) - callerRef := bson.NewObjectID() - otherRef := bson.NewObjectID() - - org := &model.Organization{} - org.SetID(bson.NewObjectID()) - org.Members = []bson.ObjectID{otherRef} - require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org)) - - ok, err := db.IsMember(ctx, *org.GetID(), callerRef) - require.NoError(t, err) - assert.False(t, ok) -} - -func TestOrganizationDB_IsMember_OrgNotFound(t *testing.T) { - ctx := context.Background() - db := newMemberTestDB(t, nil) - - ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID()) - require.NoError(t, err) - assert.False(t, ok) -} - -func TestOrganizationDB_IsMember_DBError(t *testing.T) { - ctx := context.Background() - boom := merrors.Internal("backend down") - db := newMemberTestDB(t, boom) - - ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID()) - assert.False(t, ok) - assert.True(t, errors.Is(err, boom)) -} diff --git a/backend/edge/shared/storage/organization/memory_repository_queries_test.go b/backend/edge/shared/storage/organization/memory_repository_queries_test.go deleted file mode 100644 index 2d5c300..0000000 --- a/backend/edge/shared/storage/organization/memory_repository_queries_test.go +++ /dev/null @@ -1,378 +0,0 @@ -package organization - -import ( - "context" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (m *memoryOrganizationRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { - return merrors.NotImplemented("aggregate is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error { - m.mu.RLock() - defer m.mu.RUnlock() - for _, id := range m.order { - if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { - dst, okCast := result.(*model.Organization) - if !okCast { - return merrors.InvalidDataType("expected organization result") - } - *dst = *cloneOrganization(org) - return nil - } - } - return merrors.ErrNoData -} - -func (m *memoryOrganizationRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { - return m.FindOneByFilter(ctx, query, result) -} - -func (m *memoryOrganizationRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error { - return merrors.NotImplemented("FindManyByFilter is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) FindAllByFilter(_ context.Context, query builder.Query, result any) error { - m.mu.RLock() - defer m.mu.RUnlock() - - dst, ok := result.(*[]model.Organization) - if !ok { - return merrors.InvalidDataType("expected organization slice result") - } - *dst = (*dst)[:0] - for _, id := range m.order { - if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { - *dst = append(*dst, *cloneOrganization(org)) - } - } - return nil -} - -func (m *memoryOrganizationRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var count int64 - for _, id := range m.order { - if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { - count++ - } - } - return count, nil -} - -func (m *memoryOrganizationRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error { - return merrors.NotImplemented("Patch is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) { - return false, merrors.NotImplemented("PatchOneByFilter is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) PatchMany(_ context.Context, query builder.Query, patch builder.Patch) (int, error) { - m.mu.Lock() - defer m.mu.Unlock() - - matched := 0 - for _, id := range m.order { - org, ok := m.data[id] - if !ok || !m.matchesQuery(query, org) { - continue - } - if err := applyOrganizationPatch(org, patch); err != nil { - return matched, err - } - matched++ - } - return matched, nil -} - -// applyOrganizationPatch supports the operations the provisioning storage -// issues; anything else fails loudly so a test cannot pass vacuously. -func applyOrganizationPatch(org *model.Organization, patch builder.Patch) error { - for _, op := range patch.Build() { - fields, ok := op.Value.(bson.D) - if !ok { - return merrors.NotImplemented("unsupported patch value shape: " + op.Key) - } - for _, field := range fields { - switch op.Key { - case string(builder.Set): - if err := setOrganizationField(org, field.Key, field.Value); err != nil { - return err - } - case string(builder.Unset): - if err := unsetOrganizationField(org, field.Key); err != nil { - return err - } - case string(builder.AddToSet): - if field.Key != "members" { - return merrors.NotImplemented("unsupported $addToSet field: " + field.Key) - } - member, ok := field.Value.(bson.ObjectID) - if !ok { - return merrors.InvalidDataType("expected member object id") - } - if !containsObjectID(org.Members, member) { - org.Members = append(org.Members, member) - } - default: - return merrors.NotImplemented("unsupported patch operation: " + op.Key) - } - } - } - return nil -} - -func setOrganizationField(org *model.Organization, field string, value any) error { - switch field { - case storable.UpdatedAtField: - if updatedAt, ok := value.(time.Time); ok { - org.UpdatedAt = updatedAt - } - case model.OrganizationSignupStateField: - switch state := value.(type) { - case model.OrganizationSignupState: - org.SignupState = state - case string: - org.SignupState = model.OrganizationSignupState(state) - default: - return merrors.InvalidDataType("expected signup state value") - } - case model.OrganizationProvisioningLeaseOwnerField: - owner, ok := value.(string) - if !ok || org.Provisioning == nil { - return merrors.InvalidDataType("expected lease owner on a provisioned organization") - } - org.Provisioning.LeaseOwner = owner - case model.OrganizationProvisioningLeaseField: - expiresAt, ok := value.(time.Time) - if !ok || org.Provisioning == nil { - return merrors.InvalidDataType("expected lease expiry on a provisioned organization") - } - org.Provisioning.LeaseExpiresAt = expiresAt - default: - return merrors.NotImplemented("unsupported $set field: " + field) - } - return nil -} - -func unsetOrganizationField(org *model.Organization, field string) error { - switch field { - case model.OrganizationProvisioningField: - org.Provisioning = nil - case model.OrganizationProvisioningLeaseOwnerField: - if org.Provisioning != nil { - org.Provisioning.LeaseOwner = "" - } - case model.OrganizationProvisioningLeaseField: - if org.Provisioning != nil { - org.Provisioning.LeaseExpiresAt = time.Time{} - } - default: - return merrors.NotImplemented("unsupported $unset field: " + field) - } - return nil -} - -func containsObjectID(ids []bson.ObjectID, id bson.ObjectID) bool { - for _, item := range ids { - if item == id { - return true - } - } - return false -} - -func (m *memoryOrganizationRepository) DeleteMany(context.Context, builder.Query) error { - return merrors.NotImplemented("DeleteMany is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) CreateIndex(*ri.Definition) error { - return nil -} - -func (m *memoryOrganizationRepository) DropIndex(context.Context, string) error { - return nil -} - -func (m *memoryOrganizationRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { - return nil, merrors.NotImplemented("ListIndexDefinitions is not supported in memory repository") -} - -func (m *memoryOrganizationRepository) ListIDs(_ context.Context, query builder.Query) ([]bson.ObjectID, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - var ids []bson.ObjectID - for _, id := range m.order { - if org, ok := m.data[id]; ok && m.matchesQuery(query, org) { - ids = append(ids, id) - } - } - if len(ids) == 0 { - return nil, merrors.ErrNoData - } - return ids, nil -} - -func (m *memoryOrganizationRepository) ListPermissionBound(_ context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - var res []model.PermissionBoundStorable - for _, org := range m.data { - if m.matchesQuery(query, org) { - res = append(res, cloneOrganization(org)) - } - } - return res, nil -} - -func (m *memoryOrganizationRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { - return nil, merrors.NotImplemented("Account bound list not supported") -} - -func (m *memoryOrganizationRepository) matchesQuery(query builder.Query, org *model.Organization) bool { - if query == nil { - return true - } - for _, elem := range query.BuildQuery() { - switch elem.Key { - case storable.IDField: - if !matchesObjectIDConstraint(*org.GetID(), elem.Value) { - return false - } - case storable.IsArchivedField: - value, ok := elem.Value.(bool) - if ok && org.IsArchived() != value { - return false - } - case model.OrganizationSignupStateField: - if !matchesStringConstraint(string(org.SignupState), elem.Value) { - return false - } - case model.NameField: - if !matchesStringConstraint(org.Name, elem.Value) { - return false - } - case model.OrganizationProvisioningOwnerRefField: - if org.Provisioning == nil { - return false - } - if exists, ok := existsConstraint(elem.Value); ok { - if !exists { - return false - } - continue - } - if !matchesObjectIDConstraint(org.Provisioning.OwnerRef, elem.Value) { - return false - } - case model.OrganizationProvisioningNameField: - if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.Name, elem.Value) { - return false - } - case model.OrganizationProvisioningLeaseOwnerField: - if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.LeaseOwner, elem.Value) { - return false - } - case "members": - if !matchesMembersConstraint(org.Members, elem.Value) { - return false - } - } - } - return true -} - -func matchesStringConstraint(current string, value any) bool { - switch expected := value.(type) { - case string: - return current == expected - case model.OrganizationSignupState: - return current == string(expected) - default: - return false - } -} - -func existsConstraint(value any) (bool, bool) { - ops, ok := value.(bson.M) - if !ok { - return false, false - } - exists, ok := ops[string(builder.Exists)].(bool) - return exists, ok -} - -func matchesMembersConstraint(members []bson.ObjectID, value any) bool { - ops, ok := value.(bson.M) - if !ok { - return false - } - for op, raw := range ops { - member, ok := raw.(bson.ObjectID) - if !ok { - return false - } - switch op { - case string(builder.Eq): - if !containsObjectID(members, member) { - return false - } - case string(builder.Ne): - if containsObjectID(members, member) { - return false - } - default: - return false - } - } - return true -} - -func matchesObjectIDConstraint(id bson.ObjectID, value any) bool { - if direct, ok := value.(bson.ObjectID); ok { - return id == direct - } - ops, ok := value.(bson.M) - if !ok { - return false - } - for op, raw := range ops { - ids, ok := raw.([]any) - if !ok { - return false - } - contains := false - for _, item := range ids { - if itemID, ok := item.(bson.ObjectID); ok && itemID == id { - contains = true - break - } - } - switch op { - case string(builder.In): - if !contains { - return false - } - case string(builder.NotIn): - if contains { - return false - } - default: - return false - } - } - return true -} diff --git a/backend/edge/shared/storage/organization/memory_repository_test.go b/backend/edge/shared/storage/organization/memory_repository_test.go deleted file mode 100644 index f7576c3..0000000 --- a/backend/edge/shared/storage/organization/memory_repository_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package organization - -import ( - "context" - "errors" - "sync" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type memoryOrganizationRepository struct { - mu sync.RWMutex - data map[bson.ObjectID]*model.Organization - order []bson.ObjectID -} - -func newMemoryOrganizationRepository() *memoryOrganizationRepository { - return &memoryOrganizationRepository{ - data: make(map[bson.ObjectID]*model.Organization), - } -} - -func (m *memoryOrganizationRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { - m.mu.Lock() - defer m.mu.Unlock() - - org, ok := obj.(*model.Organization) - if !ok { - return merrors.InvalidDataType("expected organization") - } - id := org.GetID() - if id == nil || *id == bson.NilObjectID { - return merrors.InvalidArgument("organization ID must be set") - } - if _, exists := m.data[*id]; exists { - return merrors.DataConflict("organization already exists") - } - m.data[*id] = cloneOrganization(org) - m.order = append(m.order, *id) - return nil -} - -func (m *memoryOrganizationRepository) InsertMany(ctx context.Context, objects []storable.Storable) error { - for _, obj := range objects { - if err := m.Insert(ctx, obj, nil); err != nil { - return err - } - } - return nil -} - -func (m *memoryOrganizationRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { - m.mu.RLock() - defer m.mu.RUnlock() - - org, ok := m.data[id] - if !ok { - return merrors.ErrNoData - } - dst, ok := result.(*model.Organization) - if !ok { - return merrors.InvalidDataType("expected organization result") - } - *dst = *cloneOrganization(org) - return nil -} - -func (m *memoryOrganizationRepository) Update(_ context.Context, obj storable.Storable) error { - m.mu.Lock() - defer m.mu.Unlock() - - org, ok := obj.(*model.Organization) - if !ok { - return merrors.InvalidDataType("expected organization") - } - id := org.GetID() - if id == nil { - return merrors.InvalidArgument("organization ID must be set") - } - if _, exists := m.data[*id]; !exists { - return merrors.ErrNoData - } - m.data[*id] = cloneOrganization(org) - return nil -} - -func (m *memoryOrganizationRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - - org, ok := obj.(*model.Organization) - if !ok { - return false, merrors.InvalidDataType("expected organization") - } - id := org.GetID() - if id == nil { - return false, merrors.InvalidArgument("organization ID must be set") - } - - current, exists := m.data[*id] - if !exists || !m.matchesQuery(query, current) { - return false, nil - } - - m.data[*id] = cloneOrganization(org) - return true, nil -} - -func (m *memoryOrganizationRepository) Upsert(ctx context.Context, obj storable.Storable) error { - if err := m.Update(ctx, obj); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return m.Insert(ctx, obj, nil) - } - return err - } - return nil -} - -func (m *memoryOrganizationRepository) Delete(_ context.Context, id bson.ObjectID) error { - m.mu.Lock() - defer m.mu.Unlock() - if _, exists := m.data[id]; !exists { - return merrors.ErrNoData - } - delete(m.data, id) - return nil -} - -func (m *memoryOrganizationRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { - return cb(ctx) -} - -func (m *memoryOrganizationRepository) Collection() string { - return mservice.Organizations -} - -func cloneOrganization(src *model.Organization) *model.Organization { - dst := *src - if len(src.Members) > 0 { - dst.Members = append([]bson.ObjectID{}, src.Members...) - } - return &dst -} diff --git a/backend/edge/shared/storage/organization/organization.go b/backend/edge/shared/storage/organization/organization.go deleted file mode 100644 index 401121f..0000000 --- a/backend/edge/shared/storage/organization/organization.go +++ /dev/null @@ -1,19 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// DB is the interface which must be implemented by all db drivers -type DB interface { - auth.ProtectedDB[*model.Organization] - List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) - ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) - SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error - IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) - AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) -} diff --git a/backend/edge/shared/storage/organization/owned.go b/backend/edge/shared/storage/organization/owned.go deleted file mode 100644 index 01a36a6..0000000 --- a/backend/edge/shared/storage/organization/owned.go +++ /dev/null @@ -1,14 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *OrganizationDB) ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) { - return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, repository.Filter("ownerRef", accountRef), nil, db.DBImp.Repository) -} diff --git a/backend/edge/shared/storage/organization/provider.go b/backend/edge/shared/storage/organization/provider.go deleted file mode 100644 index 25eaf99..0000000 --- a/backend/edge/shared/storage/organization/provider.go +++ /dev/null @@ -1,14 +0,0 @@ -package organization - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -func (db *OrganizationDB) ListProvider(ctx context.Context, query builder.Query, cursor *model.ViewCursor) ([]model.Organization, error) { - orgs := make([]model.Organization, 0) - return orgs, db.DBImp.Repository.FindAllByFilter(ctx, repository.ApplyCursor(query, cursor), &orgs) -} diff --git a/backend/edge/shared/storage/organization/provisioning.go b/backend/edge/shared/storage/organization/provisioning.go deleted file mode 100644 index ac02647..0000000 --- a/backend/edge/shared/storage/organization/provisioning.go +++ /dev/null @@ -1,166 +0,0 @@ -package organization - -import ( - "context" - "errors" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// AcquireProvisioningLease claims an organization that is still being -// provisioned. The update is atomic: it only matches a pending organization -// that carries a provisioning record and whose lease has expired or is already -// owned by this attempt, so two concurrent attempts cannot provision the same -// organization. Requiring the pending state here (not only in the caller's -// earlier read) closes the race with a concurrent finalization: an organization -// that just went active stops matching even though its lease fields are gone. -func (db *OrganizationDB) AcquireProvisioningLease( - ctx context.Context, - organizationRef bson.ObjectID, - leaseOwner string, - now time.Time, - expiresAt time.Time, -) (bool, error) { - // A freshly created attempt carries no lease yet, so "missing" counts as free - // alongside "expired"; otherwise the first attempt would block itself. - unset := repository.Exists(repository.Field(model.OrganizationProvisioningLeaseField), false) - expired := repository.Query().Comparison(repository.Field(model.OrganizationProvisioningLeaseField), builder.Lte, now) - mine := repository.Query().Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) - filter := repository.Query(). - Filter(repository.IDField(), organizationRef). - Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). - Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true). - Or(unset, expired, mine) - - patch := repository.Patch(). - Set(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner). - Set(repository.Field(model.OrganizationProvisioningLeaseField), expiresAt) - - matched, err := db.DBImp.PatchMany(ctx, filter, patch) - if err != nil { - db.DBImp.Logger.Warn("Failed to acquire organization provisioning lease", - zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) - return false, err - } - return matched > 0, nil -} - -// ReleaseProvisioningLease frees the lease so another attempt can take over -// immediately instead of waiting out the expiry. It only matches the lease of -// the given owner: a lease taken over by a newer attempt stays untouched. -func (db *OrganizationDB) ReleaseProvisioningLease( - ctx context.Context, - organizationRef bson.ObjectID, - leaseOwner string, -) error { - filter := repository.Query(). - Filter(repository.IDField(), organizationRef). - Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) - - patch := repository.Patch(). - Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)). - Unset(repository.Field(model.OrganizationProvisioningLeaseField)) - - if _, err := db.DBImp.PatchMany(ctx, filter, patch); err != nil { - db.DBImp.Logger.Warn("Failed to release organization provisioning lease", - zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) - return err - } - return nil -} - -// FinalizeProvisioning atomically activates a pending organization while the -// given attempt still owns its provisioning lease. It reports false when the -// organization is no longer pending or the lease was taken over, so a slow -// attempt cannot finalize an organization another attempt now works on. -// Only the lease is cleared: the provisioning provenance stays on the document -// so a retry after a lost success response can recognize the activated -// organization instead of creating a duplicate. -func (db *OrganizationDB) FinalizeProvisioning( - ctx context.Context, - organizationRef bson.ObjectID, - leaseOwner string, -) (bool, error) { - filter := repository.Query(). - Filter(repository.IDField(), organizationRef). - Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). - Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner) - - patch := repository.Patch(). - Set(repository.Field(model.OrganizationSignupStateField), model.OrganizationSignupStateActive). - Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)). - Unset(repository.Field(model.OrganizationProvisioningLeaseField)) - - matched, err := db.DBImp.PatchMany(ctx, filter, patch) - if err != nil { - db.DBImp.Logger.Warn("Failed to finalize organization provisioning", - zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner)) - return false, err - } - return matched > 0, nil -} - -// FindPendingProvisioning returns the organization that a previous attempt left -// pending for the same owner and name, if any. The name matches the immutable -// provisioning snapshot, not the mutable display name, so a rename cannot -// detach the organization from the retries of the create that produced it. -func (db *OrganizationDB) FindPendingProvisioning( - ctx context.Context, - ownerRef bson.ObjectID, - name string, -) (*model.Organization, error) { - query := repository.Query(). - Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef). - Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)). - Filter(repository.Field(model.OrganizationProvisioningNameField), name) - - org := &model.Organization{} - if err := db.DBImp.FindOne(ctx, query, org); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return nil, nil - } - db.DBImp.Logger.Warn("Failed to look up pending organization provisioning", - zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name)) - return nil, err - } - return org, nil -} - -// FindActiveProvisioned returns the already activated organization a previous -// attempt provisioned for the same owner and name, if any. It is the retry -// barrier for the success that got lost on the wire: without it a repeated -// create after a timed out but committed finalization would provision a -// duplicate organization. The name matches the immutable provisioning -// snapshot, so the barrier survives a rename of the activated organization. -func (db *OrganizationDB) FindActiveProvisioned( - ctx context.Context, - ownerRef bson.ObjectID, - name string, -) (*model.Organization, error) { - // Archived organizations release their name: they are excluded here and in - // the unique index alike, so the checks and the constraint stay in step. - query := repository.Query(). - Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef). - Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStateActive)). - Filter(repository.Field(model.OrganizationProvisioningNameField), name). - Filter(repository.Field(storable.IsArchivedField), false) - - org := &model.Organization{} - if err := db.DBImp.FindOne(ctx, query, org); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return nil, nil - } - db.DBImp.Logger.Warn("Failed to look up activated organization provisioning", - zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name)) - return nil, err - } - return org, nil -} diff --git a/backend/edge/shared/storage/organization/provisioning_test.go b/backend/edge/shared/storage/organization/provisioning_test.go deleted file mode 100644 index e1adc6d..0000000 --- a/backend/edge/shared/storage/organization/provisioning_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package organization - -import ( - "context" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func provisionedOrganization(t *testing.T, db *OrganizationDB, ownerRef bson.ObjectID, name, leaseOwner string) *model.Organization { - t.Helper() - - org := &model.Organization{Members: []bson.ObjectID{}} - org.SetID(bson.NewObjectID()) - org.Name = name - org.SignupState = model.OrganizationSignupStatePending - org.Provisioning = &model.OrganizationProvisioning{ - OwnerRef: ownerRef, - Name: name, - Origin: "backoffice", - StartedAt: time.Now().UTC(), - LeaseOwner: leaseOwner, - LeaseExpiresAt: time.Now().UTC().Add(10 * time.Minute), - } - if err := db.Unprotected().Create(context.Background(), org); err != nil { - t.Fatalf("failed to store organization: %v", err) - } - return org -} - -func TestAddMemberIsAPointPatch(t *testing.T) { - db := newTestOrganizationDB(t) - ownerRef := bson.NewObjectID() - org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") - memberRef := bson.NewObjectID() - - added, err := db.AddMember(context.Background(), org.ID, memberRef) - if err != nil || !added { - t.Fatalf("first add must report success: added=%v err=%v", added, err) - } - // The second add must be a no-op, not an error: transaction replays and - // concurrent joins both hit this path. - added, err = db.AddMember(context.Background(), org.ID, memberRef) - if err != nil || added { - t.Fatalf("second add must report an existing member: added=%v err=%v", added, err) - } - - stored := &model.Organization{} - if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { - t.Fatalf("failed to read organization back: %v", err) - } - if len(stored.Members) != 1 || stored.Members[0] != memberRef { - t.Fatalf("members mismatch: got=%v", stored.Members) - } - // The membership write must not touch the provisioning lease: the fence in - // FinalizeProvisioning checks the stored lease, not this attempt's copy. - if stored.Provisioning == nil || stored.Provisioning.LeaseOwner != "operator/attempt-1" { - t.Fatalf("membership write clobbered the provisioning lease: %+v", stored.Provisioning) - } -} - -func TestFinalizeProvisioningKeepsProvenance(t *testing.T) { - db := newTestOrganizationDB(t) - ownerRef := bson.NewObjectID() - org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") - - finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") - if err != nil || !finalized { - t.Fatalf("finalization by the lease owner must succeed: finalized=%v err=%v", finalized, err) - } - - stored := &model.Organization{} - if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { - t.Fatalf("failed to read organization back: %v", err) - } - if stored.SignupState != model.OrganizationSignupStateActive { - t.Fatalf("organization must be active, got=%s", stored.SignupState) - } - // Provenance survives activation so a retry after a lost success response - // can recognize the organization instead of provisioning a duplicate. - if stored.Provisioning == nil || stored.Provisioning.OwnerRef != ownerRef { - t.Fatalf("provisioning provenance must survive activation: %+v", stored.Provisioning) - } - if stored.Provisioning.LeaseOwner != "" || !stored.Provisioning.LeaseExpiresAt.IsZero() { - t.Fatalf("lease must be cleared on activation: %+v", stored.Provisioning) - } - - // A replayed finalization must not match: the organization left pending. - finalized, err = db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") - if err != nil || finalized { - t.Fatalf("an active organization must not finalize again: finalized=%v err=%v", finalized, err) - } -} - -func TestFinalizeProvisioningRejectsTakenOverLease(t *testing.T) { - db := newTestOrganizationDB(t) - org := provisionedOrganization(t, db, bson.NewObjectID(), "ProCorp", "operator/attempt-2") - - finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1") - if err != nil || finalized { - t.Fatalf("an attempt whose lease was taken over must not finalize: finalized=%v err=%v", finalized, err) - } -} - -func TestFindActiveProvisioned(t *testing.T) { - db := newTestOrganizationDB(t) - ownerRef := bson.NewObjectID() - org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") - - // Pending organizations belong to FindPendingProvisioning, not here. - found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") - if err != nil || found != nil { - t.Fatalf("a pending organization must not be reported as activated: found=%v err=%v", found, err) - } - - if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { - t.Fatalf("failed to finalize: %v", err) - } - - found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") - if err != nil || found == nil || found.ID != org.ID { - t.Fatalf("the activated organization must be found: found=%v err=%v", found, err) - } - if found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "Other"); err != nil || found != nil { - t.Fatalf("a different name must not match: found=%v err=%v", found, err) - } -} - -// The dedup key is the provisioning name snapshot: renaming an activated -// organization must not detach it from a retry of the create that produced it, -// or the retry would provision a duplicate. -func TestFindActiveProvisionedSurvivesRename(t *testing.T) { - db := newTestOrganizationDB(t) - ownerRef := bson.NewObjectID() - org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") - if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { - t.Fatalf("failed to finalize: %v", err) - } - - stored := &model.Organization{} - if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { - t.Fatalf("failed to read organization back: %v", err) - } - stored.Name = "Renamed Corp" - if err := db.Unprotected().Update(context.Background(), stored); err != nil { - t.Fatalf("failed to rename organization: %v", err) - } - - found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") - if err != nil || found == nil || found.ID != org.ID { - t.Fatalf("the renamed organization must still match its provisioning name: found=%v err=%v", found, err) - } -} - -// Archiving is how an operator retires a name: an archived organization must -// stop blocking the creation of a fresh one with the same owner and name. -func TestFindActiveProvisionedReleasesArchivedName(t *testing.T) { - db := newTestOrganizationDB(t) - ownerRef := bson.NewObjectID() - org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1") - if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil { - t.Fatalf("failed to finalize: %v", err) - } - - stored := &model.Organization{} - if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil { - t.Fatalf("failed to read organization back: %v", err) - } - stored.Archived = true - if err := db.Unprotected().Update(context.Background(), stored); err != nil { - t.Fatalf("failed to archive organization: %v", err) - } - - found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp") - if err != nil || found != nil { - t.Fatalf("an archived organization must release its name: found=%v err=%v", found, err) - } -} diff --git a/backend/edge/shared/storage/organization/setarchived_test.go b/backend/edge/shared/storage/organization/setarchived_test.go deleted file mode 100644 index dc4e9b4..0000000 --- a/backend/edge/shared/storage/organization/setarchived_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package organization - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestOrganizationDB_SetArchived_TogglesState(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - orgDB := newTestOrganizationDB(t) - - org := &model.Organization{ - OrganizationBase: model.OrganizationBase{ - Describable: model.Describable{Name: "Sendico"}, - TimeZone: "UTC", - }, - } - org.SetID(bson.NewObjectID()) - - require.NoError(t, orgDB.Create(ctx, accountRef, *org.GetID(), org)) - - var stored model.Organization - require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) - assert.False(t, stored.IsArchived()) - - require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), true, false)) - require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) - assert.True(t, stored.IsArchived()) - - require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), false, false)) - require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored)) - assert.False(t, stored.IsArchived()) -} - -func TestOrganizationDB_SetArchived_UnknownOrganization(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - orgDB := newTestOrganizationDB(t) - - err := orgDB.SetArchived(ctx, accountRef, bson.NewObjectID(), true, false) - require.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrNoData)) -} diff --git a/backend/edge/shared/storage/organization/test_helpers_test.go b/backend/edge/shared/storage/organization/test_helpers_test.go deleted file mode 100644 index 2d256e0..0000000 --- a/backend/edge/shared/storage/organization/test_helpers_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package organization - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// newTestOrganizationDB wires the real OrganizationDB implementation to an in-memory repository. -func newTestOrganizationDB(t *testing.T) *OrganizationDB { - t.Helper() - - repo := newMemoryOrganizationRepository() - dbImp := &template.DBImp[*model.Organization]{ - Logger: zap.NewNop(), - Repository: repo, - } - dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error { - return repo.Delete(ctx, objectRef) - }) - - return &OrganizationDB{ - ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{ - DBImp: dbImp, - Enforcer: allowAllEnforcer{}, - PermissionRef: bson.NewObjectID(), - Collection: mservice.Organizations, - }, - } -} - -type allowAllEnforcer struct{} - -func (allowAllEnforcer) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) { - return true, nil -} - -func (allowAllEnforcer) EnforceBatch(_ context.Context, objects []model.PermissionBoundStorable, _ bson.ObjectID, _ model.Action) (map[bson.ObjectID]bool, error) { - result := make(map[bson.ObjectID]bool, len(objects)) - for _, obj := range objects { - result[*obj.GetID()] = true - } - return result, nil -} - -func (allowAllEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { - return nil, nil -} - -func (allowAllEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { - return nil, nil, nil -} diff --git a/backend/edge/shared/storage/refreshtokens/crud.go b/backend/edge/shared/storage/refreshtokens/crud.go index 86ce4f4..cef1040 100644 --- a/backend/edge/shared/storage/refreshtokens/crud.go +++ b/backend/edge/shared/storage/refreshtokens/crud.go @@ -164,7 +164,7 @@ func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshTo db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt), zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID), zap.Time("expires_at", rt.ExpiresAt)) - return nil, merrors.AccessDenied(mservice.RefreshTokens, string(model.ActionRead), *rt.GetID()) + return nil, merrors.AccessDenied(mservice.RefreshTokens, "read", *rt.GetID()) } // Check if token is revoked diff --git a/backend/edge/shared/storage/s3key/key.go b/backend/edge/shared/storage/s3key/key.go deleted file mode 100644 index cfd9fb7..0000000 --- a/backend/edge/shared/storage/s3key/key.go +++ /dev/null @@ -1,35 +0,0 @@ -package s3key - -import ( - "path" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// Join confines an object reference to a relative S3 prefix. -func Join(prefix, reference string) (string, error) { - cleanPrefix, err := cleanRelative(prefix, "prefix") - if err != nil { - return "", err - } - cleanReference, err := cleanRelative(reference, "obj_ref") - if err != nil { - return "", err - } - return path.Join(cleanPrefix, cleanReference), nil -} - -func cleanRelative(value, argument string) (string, error) { - value = strings.TrimSpace(value) - for _, segment := range strings.Split(value, "/") { - if segment == ".." { - return "", merrors.InvalidArgument(argument+" is invalid", argument) - } - } - clean := path.Clean(value) - if value == "" || clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { - return "", merrors.InvalidArgument(argument+" is invalid", argument) - } - return clean, nil -} diff --git a/backend/edge/shared/storage/s3key/key_test.go b/backend/edge/shared/storage/s3key/key_test.go deleted file mode 100644 index e016ff8..0000000 --- a/backend/edge/shared/storage/s3key/key_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package s3key - -import "testing" - -func TestJoinConfinesReferenceToPrefix(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - prefix string - reference string - want string - wantError bool - }{ - {name: "nested reference", prefix: "compliance-evidence", reference: "account/evidence", want: "compliance-evidence/account/evidence"}, - {name: "empty reference", prefix: "accounts", wantError: true}, - {name: "absolute reference", prefix: "accounts", reference: "/private", wantError: true}, - {name: "parent reference", prefix: "compliance-evidence", reference: "../accounts/avatar", wantError: true}, - {name: "normalized parent reference", prefix: "accounts", reference: "customer/../avatar", wantError: true}, - {name: "nested parent escape", prefix: "compliance-evidence", reference: "account/../../accounts/avatar", wantError: true}, - {name: "invalid prefix", prefix: "../private", reference: "object", wantError: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - got, err := Join(test.prefix, test.reference) - if test.wantError { - if err == nil { - t.Fatalf("Join(%q, %q) error = nil", test.prefix, test.reference) - } - return - } - if err != nil { - t.Fatalf("Join(%q, %q) error = %v", test.prefix, test.reference, err) - } - if got != test.want { - t.Fatalf("Join(%q, %q) = %q, want %q", test.prefix, test.reference, got, test.want) - } - }) - } -} diff --git a/backend/edge/shared/storage/verification/memory_token_repository_test.go b/backend/edge/shared/storage/verification/memory_token_repository_test.go index a935b0b..a5afafe 100644 --- a/backend/edge/shared/storage/verification/memory_token_repository_test.go +++ b/backend/edge/shared/storage/verification/memory_token_repository_test.go @@ -309,14 +309,6 @@ func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson. return nil, merrors.NotImplemented("not needed") } -func (m *memoryTokenRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) { - return nil, merrors.NotImplemented("not needed") -} - -func (m *memoryTokenRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { - return nil, merrors.NotImplemented("not needed") -} - func (m *memoryTokenRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { return cb(ctx) } diff --git a/backend/go.mod b/backend/go.mod index e909a1a..faf3ea6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,6 @@ module git.sendico.io/tech/sxchnge/bff go 1.27.0 require ( - github.com/casbin/casbin/v3 v3.11.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -18,7 +17,6 @@ require ( go.mongodb.org/mongo-driver/v2 v2.9.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.56.0 - golang.org/x/net v0.58.0 gopkg.in/yaml.v3 v3.0.1 moul.io/chizap v1.0.3 ) @@ -28,8 +26,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect - github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -76,7 +72,6 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/objx v0.5.3 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 246ab68..21fb41b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -9,12 +9,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/casbin/casbin/v3 v3.11.0 h1:xVlnpCGs0c2Hp2WuUF36o8iZgEsXUne9/j1qJjOgJPA= -github.com/casbin/casbin/v3 v3.11.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= -github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= -github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -232,8 +226,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/internal/architecture/boundary_test.go b/backend/internal/architecture/boundary_test.go index 280c91d..6e552f0 100644 --- a/backend/internal/architecture/boundary_test.go +++ b/backend/internal/architecture/boundary_test.go @@ -28,7 +28,7 @@ func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) { if err != nil { return err } - for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/"} { + for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} { if strings.Contains(name, blocked) { t.Errorf("%s imports private provider transport or shared runtime: %s", path, name) } diff --git a/backend/internal/architecture/customer_test.go b/backend/internal/architecture/customer_test.go new file mode 100644 index 0000000..594627c --- /dev/null +++ b/backend/internal/architecture/customer_test.go @@ -0,0 +1,48 @@ +package architecture + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +func TestLocalCustomerModelHasNoPlatformTenancy(t *testing.T) { + // Provider organization fields are allowed in the isolated public wire snapshot, + // never in the local customer model, stores or service contracts. + forbidden := map[string]bool{"organization": true, "organizationref": true, "orgref": true, "orgdb": true, "role": true, "roles": true, "roledescription": true, "rolemanager": true, "policy": true, "policies": true, "policydescription": true, "policydb": true, "permissionbound": true, "signup_provisioning": true} + err := filepath.WalkDir("../..", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return err + } + ast.Inspect(parsed, func(node ast.Node) bool { + var names []*ast.Ident + switch n := node.(type) { + case *ast.TypeSpec: + names = []*ast.Ident{n.Name} + case *ast.Field: + names = n.Names + } + for _, name := range names { + if forbidden[strings.ToLower(name.Name)] { + t.Errorf("%s reintroduces local platform tenancy: %s", path, name.Name) + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/profile/runtime.go b/backend/internal/profile/runtime.go index d6bddf5..119749f 100644 --- a/backend/internal/profile/runtime.go +++ b/backend/internal/profile/runtime.go @@ -13,8 +13,7 @@ func Runtime() api.RuntimeConfig { } func bootstrap(a api.API) (api.RuntimeComponents, error) { - permissions := a.Permissions() - accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), permissions.Enforcer(), permissions.Manager().Role(), &a.Config().Mw.Password) + accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage) if err != nil { return nil, err } diff --git a/backend/pkg/api/http/response/response.go b/backend/pkg/api/http/response/response.go index 71681e5..01a331b 100644 --- a/backend/pkg/api/http/response/response.go +++ b/backend/pkg/api/http/response/response.go @@ -120,9 +120,6 @@ func Auto(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFu if errors.Is(err, merrors.ErrAccessDenied) { return AccessDenied(logger, source, errMessage(err)) } - if errors.Is(err, merrors.ErrDocumentsReportOwned) { - return Error(logger, source, http.StatusConflict, "documents_report_owned", errMessage(err)) - } if errors.Is(err, merrors.ErrDataConflict) { return DataConflict(logger, source, errMessage(err)) } diff --git a/backend/pkg/api/http/response/response_test.go b/backend/pkg/api/http/response/response_test.go index b65c101..b71e996 100644 --- a/backend/pkg/api/http/response/response_test.go +++ b/backend/pkg/api/http/response/response_test.go @@ -24,21 +24,6 @@ func TestAutoMapsServiceUnavailable(t *testing.T) { require.True(t, strings.Contains(recorder.Body.String(), "service_unavailable")) } -func TestAutoMapsDocumentsReportOwned(t *testing.T) { - req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/reports", nil) - recorder := httptest.NewRecorder() - - Auto(zap.NewNop(), mservice.Type("documents_report"), merrors.DocumentsReportOwned("report has an active payout")).ServeHTTP(recorder, req) - - var payload struct { - Data ErrorResponse `json:"data"` - } - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) - require.Equal(t, http.StatusConflict, recorder.Code) - require.Equal(t, "documents_report_owned", payload.Data.Error) - require.Equal(t, "report has an active payout", payload.Data.Details) -} - func TestErrorWithDetailsPreservesStructuredPayload(t *testing.T) { req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/actions", nil) recorder := httptest.NewRecorder() diff --git a/backend/pkg/auth/anyobject/anyobject.go b/backend/pkg/auth/anyobject/anyobject.go deleted file mode 100644 index 124da0f..0000000 --- a/backend/pkg/auth/anyobject/anyobject.go +++ /dev/null @@ -1,3 +0,0 @@ -package anyobject - -const ID = "*" diff --git a/backend/pkg/auth/archivable.go b/backend/pkg/auth/archivable.go deleted file mode 100644 index 4eebcf1..0000000 --- a/backend/pkg/auth/archivable.go +++ /dev/null @@ -1,35 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ArchivableDB implements archive operations with permission checking -type ArchivableDB[T model.PermissionBoundStorable] interface { - // SetArchived sets the archived status of an entity with permission checking - SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error - // IsArchived checks if an entity is archived with permission checking - IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) - - // Archive archives an entity with permission checking (sets archived to true) - Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error - - // Unarchive unarchives an entity with permission checking (sets archived to false) - Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error -} - -// NewArchivableDB creates a new auth.ArchivableDB instance -func NewArchivableDB[T model.PermissionBoundStorable]( - dbImp *template.DBImp[T], - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getArchivable func(T) model.Archivable, -) ArchivableDB[T] { - return newArchivableDBImp(dbImp, logger, enforcer, createEmpty, getArchivable) -} diff --git a/backend/pkg/auth/archivableimp.go b/backend/pkg/auth/archivableimp.go deleted file mode 100644 index b07be6a..0000000 --- a/backend/pkg/auth/archivableimp.go +++ /dev/null @@ -1,107 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ArchivableDB implements archive operations with permission checking -type ArchivableDBImp[T model.PermissionBoundStorable] struct { - dbImp *template.DBImp[T] - logger mlogger.Logger - enforcer Enforcer - createEmpty func() T - getArchivable func(T) model.Archivable -} - -// NewArchivableDB creates a new auth.ArchivableDB instance -func newArchivableDBImp[T model.PermissionBoundStorable]( - dbImp *template.DBImp[T], - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getArchivable func(T) model.Archivable, -) ArchivableDB[T] { - return &ArchivableDBImp[T]{ - dbImp: dbImp, - logger: logger.Named("archivable"), - enforcer: enforcer, - createEmpty: createEmpty, - getArchivable: getArchivable, - } -} - -// SetArchived sets the archived status of an entity with permission checking -func (db *ArchivableDBImp[T]) SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error { - // Check permissions using enforceObject helper - if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil { - db.logger.Warn("Failed to enforce object permission", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - // Get the object to check current archived status - obj := db.createEmpty() - if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for setting archived status", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - // Extract archivable from the object - archivable := db.getArchivable(obj) - currentArchived := archivable.IsArchived() - if currentArchived == archived { - db.logger.Debug("No change needed - same archived status", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return nil // No change needed - } - - // Set the archived status - patch := repository.Patch().Set(repository.IsArchivedField(), archived) - if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil { - db.logger.Warn("Failed to set archived status on object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - db.logger.Debug("Successfully set archived status on object", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return nil -} - -// IsArchived checks if an entity is archived with permission checking -func (db *ArchivableDBImp[T]) IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) { - // // Check permissions using single Enforce - if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil { - db.logger.Debug("Permission denied for checking archived status", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionRead))) - return false, merrors.AccessDenied("read", "object", objectRef) - } - obj := db.createEmpty() - if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for checking archived status", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return false, err - } - archivable := db.getArchivable(obj) - return archivable.IsArchived(), nil -} - -// Archive archives an entity with permission checking (sets archived to true) -func (db *ArchivableDBImp[T]) Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - return db.SetArchived(ctx, accountRef, objectRef, true) -} - -// Unarchive unarchives an entity with permission checking (sets archived to false) -func (db *ArchivableDBImp[T]) Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - return db.SetArchived(ctx, accountRef, objectRef, false) -} diff --git a/backend/pkg/auth/authorizer.go b/backend/pkg/auth/authorizer.go deleted file mode 100644 index 0f3a295..0000000 --- a/backend/pkg/auth/authorizer.go +++ /dev/null @@ -1,87 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -type MembershipChecker interface { - IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) -} - -type ScopedAuthorizer struct { - // Logger receives Debug/Warn messages from the enforcement path. - Logger mlogger.Logger - Membership MembershipChecker - Enforcer Enforcer -} - -// NewScopedAuthorizer constructs a ScopedAuthorizer and validates required dependencies. -// Logger defaults to zap.NewNop() if nil. -func NewScopedAuthorizer(membership MembershipChecker, enforcer Enforcer, logger mlogger.Logger) *ScopedAuthorizer { - if membership == nil { - panic("auth.NewScopedAuthorizer: MembershipChecker must not be nil") - } - if enforcer == nil { - panic("auth.NewScopedAuthorizer: Enforcer must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedAuthorizer{ - Logger: logger, - Membership: membership, - Enforcer: enforcer, - } -} - -func (a *ScopedAuthorizer) Authorize(ctx context.Context, p ScopedAccessParams) error { - a.Logger.Debug("scoped access check started", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - - // Personal resources are guarded by ownership alone — membership is not consulted. - // Checking membership first would lock the owner out after org membership is revoked. - if p.OwnerKind == model.OwnerKindAccount { - err := enforceOwnershipAccess(ctx, a.Enforcer, p) - if err != nil && errors.Is(err, merrors.ErrAccessDenied) { - a.Logger.Debug("access denied: not the owner", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - zap.String("action", string(p.Action))) - } - return err - } - - ok, err := a.Membership.IsMember(ctx, p.OrgRef, p.CallerRef) - if err != nil { - a.Logger.Warn("membership check failed", - zap.Error(err), mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef)) - return err - } - if !ok { - a.Logger.Debug("access denied: caller is not a member of the organization", - mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } - - err = enforceOwnershipAccess(ctx, a.Enforcer, p) - if err != nil { - if errors.Is(err, merrors.ErrAccessDenied) { - a.Logger.Debug("access denied", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - } else { - a.Logger.Warn("enforcer returned error during scoped access check", - zap.Error(err), mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - zap.String("action", string(p.Action))) - } - } - return err -} diff --git a/backend/pkg/auth/authorizer_test.go b/backend/pkg/auth/authorizer_test.go deleted file mode 100644 index bdbaad5..0000000 --- a/backend/pkg/auth/authorizer_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -type fakeMembershipChecker struct { - isMember bool - err error -} - -func (f *fakeMembershipChecker) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { - return f.isMember, f.err -} - -func TestScopedAuthorizer_Authorize(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - org := bson.NewObjectID() - object := bson.NewObjectID() - policy := bson.NewObjectID() - - base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { - return ScopedAccessParams{ - Service: mservice.Recipients, - PolicyRef: policy, - CallerRef: caller, - OrgRef: org, - ObjectRef: object, - OwnerKind: kind, - OwnerRef: owner, - Action: model.ActionRead, - } - } - - t.Run("membership checker error propagates for org-owned resource and is not masked as denied", func(t *testing.T) { - boom := merrors.Define("checker down") - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{err: boom}, - Enforcer: &fakeEnforcer{}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("checker error must not be reported as access denied") - } - }) - - t.Run("non-member owning account-owned resource is allowed", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: false}, - Enforcer: &fakeEnforcer{}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) - } - }) - - t.Run("non-member is denied for org-owned resource", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: false}, - Enforcer: &fakeEnforcer{allow: true}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member on org resource, got %v", err) - } - }) - - t.Run("member owning account-owned resource is allowed", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - }) - - t.Run("member accessing another account's resource is denied", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindAccount, other)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("member with org-owned resource allowed when enforcer approves", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: true}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - }) - - t.Run("member with org-owned resource denied when enforcer refuses", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: false}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("enforcer error propagates and is not masked as denied", func(t *testing.T) { - boom := merrors.Define("policy backend down") - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{err: boom}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("enforcer error must not be reported as access denied") - } - }) - - t.Run("unknown owner kind is denied", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: true}, - } - err := a.Authorize(context.Background(), base(model.OwnerKind("garbage"), caller)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for unknown owner kind, got %v", err) - } - }) -} diff --git a/backend/pkg/auth/config.go b/backend/pkg/auth/config.go deleted file mode 100644 index 0891512..0000000 --- a/backend/pkg/auth/config.go +++ /dev/null @@ -1,12 +0,0 @@ -package auth - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type EnforcerType string - -const ( - Casbin EnforcerType = "casbin" - Native EnforcerType = "native" -) - -type Config = model.DriverConfig[EnforcerType] diff --git a/backend/pkg/auth/db.go b/backend/pkg/auth/db.go deleted file mode 100644 index f43841c..0000000 --- a/backend/pkg/auth/db.go +++ /dev/null @@ -1,38 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type ProtectedDB[T model.PermissionBoundStorable] interface { - Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error - InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error - Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error - Update(ctx context.Context, accountRef bson.ObjectID, object T) error - Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error - DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error - Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error - PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) - Unprotected() template.DB[T] - ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) -} - -func CreateDB[T model.PermissionBoundStorable]( - ctx context.Context, - l mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (ProtectedDB[T], error) { - return CreateDBImp[T](ctx, l, pdb, enforcer, collection, db) -} diff --git a/backend/pkg/auth/dbab.go b/backend/pkg/auth/dbab.go deleted file mode 100644 index 2cbf2f5..0000000 --- a/backend/pkg/auth/dbab.go +++ /dev/null @@ -1,51 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type AccountBoundDB[T model.AccountBoundStorable] interface { - Create(ctx context.Context, accountRef bson.ObjectID, object T) error - Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error - Update(ctx context.Context, accountRef bson.ObjectID, object T) error - Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error - Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error - DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error - FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error - ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) - ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) -} - -func CreateAccountBound[T model.AccountBoundStorable]( - ctx context.Context, - logger mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (AccountBoundDB[T], error) { - logger = logger.Named("account_bound") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { - logger.Warn("Failed to fetch organization policy description", zap.Error(err)) - return nil, err - } - res := &AccountBoundDBImp[T]{ - Logger: logger, - DBImp: template.Create[T](logger, collection, db), - Enforcer: enforcer, - PermissionRef: policy.ID, - Collection: collection, - } - return res, nil -} diff --git a/backend/pkg/auth/dbimp.go b/backend/pkg/auth/dbimp.go deleted file mode 100644 index 19876e3..0000000 --- a/backend/pkg/auth/dbimp.go +++ /dev/null @@ -1,319 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type ProtectedDBImp[T model.PermissionBoundStorable] struct { - DBImp *template.DBImp[T] - Enforcer Enforcer - PermissionRef bson.ObjectID - Collection mservice.Type -} - -func (db *ProtectedDBImp[T]) enforce(ctx context.Context, action model.Action, object model.PermissionBoundStorable, accountRef, objectRef bson.ObjectID) error { - res, err := db.Enforcer.Enforce(ctx, object.GetPermissionRef(), accountRef, object.GetOrganizationRef(), objectRef, action) - if err != nil { - db.DBImp.Logger.Warn("Failed to enforce permission", - zap.Error(err), mzap.PermissionRef(object.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return err - } - if !res { - db.DBImp.Logger.Debug("Access denied", mzap.PermissionRef(object.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), objectRef) - } - return nil -} - -func (db *ProtectedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - - if object.GetPermissionRef() == bson.NilObjectID { - object.SetPermissionRef(db.PermissionRef) - } - object.SetOrganizationRef(organizationRef) - - if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { - return err - } - - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - return err - } - - db.DBImp.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - return nil -} - -func (db *ProtectedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { - if len(objects) == 0 { - return nil - } - - db.DBImp.Logger.Debug("Attempting to insert many objects", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - - // Set permission and organization refs for all objects and enforce permissions - for _, object := range objects { - if object.GetPermissionRef() == bson.NilObjectID { - object.SetPermissionRef(db.PermissionRef) - } - object.SetOrganizationRef(organizationRef) - - if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { - return err - } - } - - if err := db.DBImp.InsertMany(ctx, objects); err != nil { - db.DBImp.Logger.Warn("Failed to insert many objects", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - return err - } - - db.DBImp.Logger.Debug("Successfully inserted many objects", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - return nil -} - -func (db *ProtectedDBImp[T]) enforceObject(ctx context.Context, action model.Action, accountRef, objectRef bson.ObjectID) error { - l, err := db.ListIDs(ctx, action, accountRef, repository.IDFilter(objectRef)) - if err != nil { - db.DBImp.Logger.Warn("Error occured while checking access rights", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return err - } - if len(l) == 0 { - db.DBImp.Logger.Debug("Access denied", zap.String("action", string(action)), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return merrors.AccessDenied(db.Collection, string(action), objectRef) - } - return nil -} - -func (db *ProtectedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.DBImp.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionRead, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.DBImp.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.DBImp.Logger.Debug("Successfully retrieved object", - mzap.AccRef(accountRef), mzap.OrgRef(result.GetOrganizationRef()), - mzap.StorableRef(result), mzap.PermissionRef(result.GetPermissionRef())) - return nil -} - -func (db *ProtectedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) - - if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, *object.GetID()); err != nil { - return err - } - - if err := db.DBImp.Update(ctx, object); err != nil { - db.DBImp.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return err - } - - db.DBImp.Logger.Debug("Successfully updated object", - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.StorableRef(object), mzap.PermissionRef(object.GetPermissionRef())) - return nil -} - -func (db *ProtectedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("Attempting to delete object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("Failed to delete object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully deleted object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *ProtectedDBImp[T]) ListIDs( - ctx context.Context, - action model.Action, - accountRef bson.ObjectID, - query builder.Query, -) ([]bson.ObjectID, error) { - db.DBImp.Logger.Debug("Attempting to list object IDs", - mzap.AccRef(accountRef), zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) - - // 1. Fetch all candidate IDs from the underlying DB - allIDs, err := db.DBImp.ListPermissionBound(ctx, query) - if err != nil { - db.DBImp.Logger.Warn("Failed to list object IDs", zap.Error(err), mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return nil, err - } - if len(allIDs) == 0 { - db.DBImp.Logger.Debug("No objects found matching filter", mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) - return []bson.ObjectID{}, merrors.NoData(fmt.Sprintf("no %s found", db.Collection)) - } - - // 2. Check read permission for each ID - var allowedIDs []bson.ObjectID - for _, desc := range allIDs { - enforceErr := db.enforce(ctx, action, desc, accountRef, *desc.GetID()) - if enforceErr == nil { - allowedIDs = append(allowedIDs, *desc.GetID()) - } else if !errors.Is(enforceErr, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.DBImp.Logger.Warn("Error while enforcing read permission", zap.Error(enforceErr), - mzap.PermissionRef(desc.GetPermissionRef()), zap.String("action", string(action)), - mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), - mzap.ObjRef("object_ref", *desc.GetID()), zap.String("collection", db.Collection), - ) - return nil, enforceErr - } - // If AccessDenied, we simply skip that ID. - } - - db.DBImp.Logger.Debug("Successfully enforced read permission on IDs", zap.Int("fetched_count", len(allIDs)), - zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - - // 3. Return only the IDs that passed permission checks - return allowedIDs, nil -} - -func (db *ProtectedDBImp[T]) Unprotected() template.DB[T] { - return db.DBImp -} - -func (db *ProtectedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { - return err - } - if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("Failed to delete dependent object", zap.Error(err)) - return err - } - return nil -} - -func CreateDBImp[T model.PermissionBoundStorable]( - ctx context.Context, - l mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (*ProtectedDBImp[T], error) { - logger := l.Named("protected") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, collection, &policy); err != nil { - logger.Warn("Failed to fetch policy description", zap.Error(err), zap.String("resource_type", collection)) - return nil, err - } - p := &ProtectedDBImp[T]{ - DBImp: template.Create[T](logger, collection, db), - PermissionRef: policy.ID, - Collection: collection, - Enforcer: enforcer, - } - if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: storable.OrganizationRefField, Sort: ri.Asc}}, - }); err != nil { - logger.Warn("Failed to create index", zap.Error(err), zap.String("resource_type", collection)) - return nil, err - } - - return p, nil -} - -func (db *ProtectedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.DBImp.Logger.Debug("Attempting to patch object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Repository.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("Failed to patch object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully patched object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *ProtectedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { - db.DBImp.Logger.Debug("Attempting to patch many objects", - mzap.AccRef(accountRef), zap.Any("filter", query.BuildQuery())) - - ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) - if err != nil { - return 0, err - } - if len(ids) == 0 { - return 0, nil - } - - values := make([]any, len(ids)) - for i, id := range ids { - values[i] = id - } - idFilter := repository.Query().In(repository.IDField(), values...) - finalQuery := query.And(idFilter) - - modified, err := db.DBImp.Repository.PatchMany(ctx, finalQuery, patch) - if err != nil { - db.DBImp.Logger.Warn("Failed to patch many objects", zap.Error(err), - mzap.AccRef(accountRef)) - return 0, err - } - - db.DBImp.Logger.Debug("Successfully patched many objects", - mzap.AccRef(accountRef), zap.Int("modified_count", modified)) - return modified, nil -} diff --git a/backend/pkg/auth/dbimpab.go b/backend/pkg/auth/dbimpab.go deleted file mode 100644 index 0276ec6..0000000 --- a/backend/pkg/auth/dbimpab.go +++ /dev/null @@ -1,394 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type AccountBoundDBImp[T model.AccountBoundStorable] struct { - Logger mlogger.Logger - DBImp *template.DBImp[T] - Enforcer Enforcer - PermissionRef bson.ObjectID - Collection mservice.Type -} - -func (db *AccountBoundDBImp[T]) enforce(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { - // FIRST: Check if the object's AccountRef equals the calling accountRef - if so, ALLOW - objectAccountRef := object.GetAccountRef() - if objectAccountRef != nil && *objectAccountRef == accountRef { - db.Logger.Debug("Access granted - object belongs to calling account", - mzap.ObjRef("object_account_ref", *objectAccountRef), - mzap.ObjRef("calling_account_ref", accountRef), - zap.String("action", string(action))) - return nil - } - - // SECOND: If not owned by calling account, check organization-level permissions - organizationRef := object.GetOrganizationRef() - res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, organizationRef, action) - if err != nil { - db.Logger.Warn("Failed to enforce permission", - zap.Error(err), mzap.PermissionRef(db.PermissionRef), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("action", string(action))) - return err - } - if !res { - db.Logger.Debug("Access denied", mzap.PermissionRef(db.PermissionRef), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - return nil -} - -func (db *AccountBoundDBImp[T]) enforceInterface(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { - return db.enforce(ctx, action, object, accountRef) -} - -func (db *AccountBoundDBImp[T]) Create(ctx context.Context, accountRef bson.ObjectID, object T) error { - orgRef := object.GetOrganizationRef() - db.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - - // Check organization update permission for create operations - if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { - return err - } - - if err := db.DBImp.Create(ctx, object); err != nil { - db.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - return nil -} - -func (db *AccountBoundDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - // Check organization read permission - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully retrieved object", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef()), zap.String("collection", db.Collection)) - return nil -} - -func (db *AccountBoundDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) - - // Check organization update permission - if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { - return err - } - - if err := db.DBImp.Update(ctx, object); err != nil { - db.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return err - } - - db.Logger.Debug("Successfully updated object", mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return nil -} - -func (db *AccountBoundDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.Logger.Debug("Attempting to patch object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(objs) == 0 { - db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) - } - - // Check organization update permission - if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { - return err - } - - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.Logger.Warn("Failed to patch object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully patched object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *AccountBoundDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.Logger.Debug("Attempting to delete object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(objs) == 0 { - db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) - } - // Check organization update permission for delete operations - if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { - return err - } - - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.Logger.Warn("Failed to delete object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully deleted object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error { - db.Logger.Debug("Attempting to delete many objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Get all candidate objects for batch permission checking - allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for delete many", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Use batch enforcement for efficiency - allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionUpdate) - if err != nil { - db.Logger.Warn("Failed to enforce batch permissions for delete many", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Build query for objects that passed permission check - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if allowedResults[*obj.GetID()] { - allowedIDs = append(allowedIDs, *obj.GetID()) - } - } - - if len(allowedIDs) == 0 { - db.Logger.Debug("No objects allowed for deletion", mzap.AccRef(accountRef)) - return nil - } - - // Delete only the allowed objects - allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) - if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { - db.Logger.Warn("Failed to delete many objects", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - db.Logger.Debug("Successfully deleted many objects", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) - return nil -} - -func (db *AccountBoundDBImp[T]) FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error { - db.Logger.Debug("Attempting to find one object", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // For FindOne, we need to check read permission after finding the object - if err := db.DBImp.FindOne(ctx, query, result); err != nil { - db.Logger.Warn("Failed to find one object", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check organization read permission for the found object - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully found one object", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef())) - return nil -} - -func (db *AccountBoundDBImp[T]) ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { - db.Logger.Debug("Attempting to list object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Get all candidate objects for batch permission checking - allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for ID filtering", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Use batch enforcement for efficiency - allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionRead) - if err != nil { - db.Logger.Warn("Failed to enforce batch permissions for ID listing", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Filter to only allowed object IDs - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if allowedResults[*obj.GetID()] { - allowedIDs = append(allowedIDs, *obj.GetID()) - } - } - - db.Logger.Debug("Successfully filtered object IDs", zap.Int("total_count", len(allObjects)), - zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef)) - return allowedIDs, nil -} - -func (db *AccountBoundDBImp[T]) ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) { - db.Logger.Debug("Attempting to list account bound objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Build query to find objects where accountRef matches OR is null/absent - accountQuery := repository.WithOrg(accountRef, organizationRef) - - // Combine with the provided query - finalQuery := query.And(accountQuery) - - // Get all candidate objects - allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, finalQuery) - if err != nil { - db.Logger.Warn("Failed to list account bound objects", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Filter objects based on read permissions (AccountBoundStorable doesn't have permission info, so we check organization level) - var allowedObjects []model.AccountBoundStorable - for _, obj := range allObjects { - if err := db.enforceInterface(ctx, model.ActionRead, obj, accountRef); err == nil { - allowedObjects = append(allowedObjects, obj) - } else if !errors.Is(err, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.Logger.Warn("Error while enforcing read permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) - return nil, err - } - // If AccessDenied, we simply skip that object - } - - db.Logger.Debug("Successfully filtered account bound objects", zap.Int("total_count", len(allObjects)), - zap.Int("allowed_count", len(allowedObjects)), mzap.AccRef(accountRef)) - return allowedObjects, nil -} - -func (db *AccountBoundDBImp[T]) GetByAccountRef(ctx context.Context, accountRef bson.ObjectID, result T) error { - db.Logger.Debug("Attempting to get object by account ref", mzap.AccRef(accountRef)) - - // Build query to find objects where accountRef matches OR is null/absent - query := repository.WithoutOrg(accountRef) - - if err := db.DBImp.FindOne(ctx, query, result); err != nil { - db.Logger.Warn("Failed to get object by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check organization read permission for the found object - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully retrieved object by account ref", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef())) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteByAccountRef(ctx context.Context, accountRef bson.ObjectID) error { - db.Logger.Debug("Attempting to delete objects by account ref", mzap.AccRef(accountRef)) - - // Build query to find objects where accountRef matches OR is null/absent - query := repository.WithoutOrg(accountRef) - - // Get all candidate objects for individual permission checking - allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for delete by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check permissions for each object individually (AccountBoundStorable doesn't have permission info) - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if err := db.enforceInterface(ctx, model.ActionUpdate, obj, accountRef); err == nil { - allowedIDs = append(allowedIDs, *obj.GetID()) - } else if !errors.Is(err, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.Logger.Warn("Error while enforcing update permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) - return err - } - // If AccessDenied, we simply skip that object - } - - if len(allowedIDs) == 0 { - db.Logger.Debug("No objects allowed for deletion by account ref", mzap.AccRef(accountRef)) - return nil - } - - // Delete only the allowed objects - allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) - if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { - db.Logger.Warn("Failed to delete objects by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - db.Logger.Debug("Successfully deleted objects by account ref", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { - return db.DBImp.DeleteCascade(ctx, objectRef) -} - -// CreateAccountBoundImp creates a concrete AccountBoundDBImp instance for internal use -func CreateAccountBoundImp[T model.AccountBoundStorable]( - ctx context.Context, - logger mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (*AccountBoundDBImp[T], error) { - logger = logger.Named("account_bound") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { - logger.Warn("Failed to fetch organization policy description", zap.Error(err)) - return nil, err - } - res := &AccountBoundDBImp[T]{ - Logger: logger, - DBImp: template.Create[T](logger, collection, db), - Enforcer: enforcer, - PermissionRef: policy.ID, - Collection: collection, - } - return res, nil -} diff --git a/backend/pkg/auth/dbimpab_test.go b/backend/pkg/auth/dbimpab_test.go deleted file mode 100644 index 529bd33..0000000 --- a/backend/pkg/auth/dbimpab_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package auth - -import ( - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// TestAccountBoundDBImp_Enforce tests the enforce method -func TestAccountBoundDBImp_Enforce(t *testing.T) { - logger := zap.NewNop() - db := &AccountBoundDBImp[model.AccountBoundStorable]{ - Logger: logger, - PermissionRef: bson.NewObjectID(), - Collection: "test_collection", - } - - t.Run("EnforceMethodExists", func(t *testing.T) { - // Test that the enforce method exists and can be called - // This is a basic test to ensure the method signature is correct - assert.NotNil(t, db.enforce) - }) - - t.Run("PermissionRefSet", func(t *testing.T) { - // Test that PermissionRef is properly set - assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) - }) - - t.Run("CollectionSet", func(t *testing.T) { - // Test that Collection is properly set - assert.Equal(t, "test_collection", db.Collection) - }) -} - -// TestAccountBoundDBImp_InterfaceCompliance tests that the struct implements required interfaces -func TestAccountBoundDBImp_InterfaceCompliance(t *testing.T) { - logger := zap.NewNop() - db := &AccountBoundDBImp[model.AccountBoundStorable]{ - Logger: logger, - PermissionRef: bson.NewObjectID(), - Collection: "test_collection", - } - - t.Run("StructInitialization", func(t *testing.T) { - // Test that the struct can be initialized - assert.NotNil(t, db) - assert.NotNil(t, db.Logger) - assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) - assert.NotEmpty(t, db.Collection) - }) - - t.Run("LoggerInitialization", func(t *testing.T) { - // Test that logger is properly initialized - assert.NotNil(t, db.Logger) - }) -} - -// TestAccountBoundDBImp_ErrorHandling tests error handling patterns -func TestAccountBoundDBImp_ErrorHandling(t *testing.T) { - t.Run("AccessDeniedError", func(t *testing.T) { - // Test that AccessDenied error is properly created - err := merrors.AccessDenied("test_collection", "read", bson.NilObjectID) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrAccessDenied)) - }) - - t.Run("ErrorTypeChecking", func(t *testing.T) { - // Test error type checking - accessDeniedErr := merrors.AccessDenied("test", "read", bson.NilObjectID) - otherErr := merrors.Define("other error") - - assert.True(t, errors.Is(accessDeniedErr, merrors.ErrAccessDenied)) - assert.False(t, errors.Is(otherErr, merrors.ErrAccessDenied)) - }) -} diff --git a/backend/pkg/auth/dbimpscoped.go b/backend/pkg/auth/dbimpscoped.go deleted file mode 100644 index 525c4b9..0000000 --- a/backend/pkg/auth/dbimpscoped.go +++ /dev/null @@ -1,142 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ownerKindField and ownerRefField are the canonical BSON paths produced by -// model.Owner embedded with bson:"owner". Resources managed by ScopedDBImp -// must use these same tags for BuildScopedQuery to produce correct filters. -const ( - ownerKindField = "owner.kind" - ownerRefField = "owner.ref" -) - -// ScopedDBImp provides generic CRUD, patch, and bulk operations for resources -// that follow the personal-vs-org dual-ownership model (ownerKind + ownerRef). -// -// Unlike ProtectedDBImp, it never uses permissionRef as a routing sentinel. -// Every read/mutate operation calls LoadAndEnforce, which reads ownerKind and -// ownerRef from the authoritative database record before making the access -// decision: -// - personal (ownerKind=account): caller must be the owner (no policy consulted) -// - org (ownerKind=organization): caller must pass the org policy check -// -// This eliminates the permissionRef==NilObjectID pattern that would otherwise -// be required for personal-owned resources under ProtectedDBImp. -type ScopedDBImp[T model.ScopedStorable] struct { - DBImp *template.DBImp[T] - scoped *ScopedDB[T] - Enforcer Enforcer - OrgPolicyRef bson.ObjectID - Collection mservice.Type -} - -// NewScopedDBImp wires a ScopedDBImp. Panics on nil required dependencies to -// surface misconfiguration at startup, matching the contract of NewScopedDB -// and CreateDBImp. -func NewScopedDBImp[T model.ScopedStorable]( - dbImp *template.DBImp[T], - enforcer Enforcer, - membership MembershipChecker, - orgPolicyRef bson.ObjectID, - collection mservice.Type, - newFn func() T, - logger mlogger.Logger, -) *ScopedDBImp[T] { - if dbImp == nil { - panic("auth.NewScopedDBImp: DBImp must not be nil") - } - if enforcer == nil { - panic("auth.NewScopedDBImp: Enforcer must not be nil") - } - if membership == nil { - panic("auth.NewScopedDBImp: MembershipChecker must not be nil") - } - if newFn == nil { - panic("auth.NewScopedDBImp: New must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedDBImp[T]{ - DBImp: dbImp, - Enforcer: enforcer, - OrgPolicyRef: orgPolicyRef, - Collection: collection, - scoped: NewScopedDB( - dbImp, - NewScopedAuthorizer(membership, enforcer, logger.Named("scoped")), - orgPolicyRef, - collection, - newFn, - logger.Named("scoped"), - ), - } -} - -func (db *ScopedDBImp[T]) loadAndEnforce(ctx context.Context, accountRef, objectRef bson.ObjectID, action model.Action) (T, error) { - return db.scoped.LoadAndEnforce(ctx, accountRef, objectRef, action) -} - -// Unprotected returns the raw DBImp for operations that bypass scoped auth. -// Callers are responsible for their own authorization. -func (db *ScopedDBImp[T]) Unprotected() template.DB[T] { - return db.DBImp -} - -// BuildScopedQuery returns the ownership-aware OR-query for use in List -// implementations. It mirrors the access model used by all other ScopedDBImp -// operations: membership is checked first, then org policy read access. -// -// The returned query always includes personal resources owned by accountRef, -// regardless of the organization they were created in — personal resources -// follow their owner across every org the account is a member of. Org-owned -// resources are bounded to organizationRef and added only when the caller is -// a member of the org AND the org policy grants read access. -// -// Callers must compose the returned query with resource-specific filters -// (additional field conditions, cursor pagination, etc.). -// -// Resources must embed model.Owner with bson:"owner" so the generated field -// paths (owner.kind, owner.ref) match the stored documents. -func (db *ScopedDBImp[T]) BuildScopedQuery(ctx context.Context, accountRef, organizationRef bson.ObjectID) (builder.Query, error) { - personalQ := repository.Query(). - Filter(repository.Field(ownerKindField), model.OwnerKindAccount). - Filter(repository.Field(ownerRefField), accountRef) - - conditions := []builder.Query{personalQ} - - isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) - if err != nil { - // Membership backend failure degrades org access but must not prevent - // the caller from seeing their own personal resources. - db.scoped.Logger.Warn("membership check failed, returning personal-only results", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - } else if isMember { - hasOrgAccess, enforceErr := db.Enforcer.Enforce( - ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, model.ActionRead, - ) - if enforceErr != nil { - return nil, enforceErr - } - if hasOrgAccess { - conditions = append(conditions, repository.Query().And( - repository.OrgFilter(organizationRef), - repository.Filter(ownerKindField, model.OwnerKindOrganization), - )) - } - } - - return repository.Query().Or(conditions...), nil -} diff --git a/backend/pkg/auth/dbimpscoped_test.go b/backend/pkg/auth/dbimpscoped_test.go deleted file mode 100644 index 2a218bb..0000000 --- a/backend/pkg/auth/dbimpscoped_test.go +++ /dev/null @@ -1,848 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// testScopedObj is a minimal model.ScopedStorable used exclusively by -// ScopedDBImp tests. -type testScopedObj struct { - id bson.ObjectID - orgRef bson.ObjectID - ownerKind model.OwnerKind - ownerRef bson.ObjectID - permissionRef bson.ObjectID -} - -func (o *testScopedObj) GetID() *bson.ObjectID { return &o.id } -func (o *testScopedObj) SetID(id bson.ObjectID) { o.id = id } -func (o *testScopedObj) Update() {} -func (o *testScopedObj) Collection() string { return "test_scoped" } -func (o *testScopedObj) GetOrganizationRef() bson.ObjectID { return o.orgRef } -func (o *testScopedObj) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } -func (o *testScopedObj) GetOwnerKind() model.OwnerKind { return o.ownerKind } -func (o *testScopedObj) GetOwnerRef() bson.ObjectID { return o.ownerRef } -func (o *testScopedObj) SetOwnerKind(k model.OwnerKind) { o.ownerKind = k } -func (o *testScopedObj) SetOwnerRef(ref bson.ObjectID) { o.ownerRef = ref } -func (o *testScopedObj) GetPermissionRef() bson.ObjectID { return o.permissionRef } -func (o *testScopedObj) SetPermissionRef(ref bson.ObjectID) { o.permissionRef = ref } - -var _ model.ScopedStorable = (*testScopedObj)(nil) - -// fakeScopedRepository is a minimal in-memory repository.Repository that -// supports Insert, InsertMany, Get, Update, Patch, Delete, and ListIDs. -// All other methods return merrors.NotImplemented. -type fakeScopedRepository struct { - objects map[bson.ObjectID]*testScopedObj - insertErr error - getErr error - updateErr error - patchErr error - deleteErr error - listIDsErr error - patchedIDs []bson.ObjectID - // patchManyIDs is the set of IDs that PatchMany will record as affected. - // Tests using PatchMany must set this to the expected authorized IDs so the - // fake can simulate per-ID tracking without parsing the bulk filter. - patchManyIDs []bson.ObjectID -} - -func newFakeScopedRepository() *fakeScopedRepository { - return &fakeScopedRepository{objects: make(map[bson.ObjectID]*testScopedObj)} -} - -func (f *fakeScopedRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { - if f.insertErr != nil { - return f.insertErr - } - o := obj.(*testScopedObj) - if o.id == bson.NilObjectID { - o.id = bson.NewObjectID() - } - clone := *o - f.objects[o.id] = &clone - return nil -} - -func (f *fakeScopedRepository) InsertMany(_ context.Context, objects []storable.Storable) error { - if f.insertErr != nil { - return f.insertErr - } - for _, obj := range objects { - o := obj.(*testScopedObj) - if o.id == bson.NilObjectID { - o.id = bson.NewObjectID() - } - clone := *o - f.objects[o.id] = &clone - } - return nil -} - -func (f *fakeScopedRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { - if f.getErr != nil { - return f.getErr - } - obj, ok := f.objects[id] - if !ok { - return merrors.NoData(fmt.Sprintf("test_scoped %s not found", id.Hex())) - } - *result.(*testScopedObj) = *obj - return nil -} - -func (f *fakeScopedRepository) Update(_ context.Context, obj storable.Storable) error { - if f.updateErr != nil { - return f.updateErr - } - o := obj.(*testScopedObj) - clone := *o - f.objects[*o.GetID()] = &clone - return nil -} - -func (f *fakeScopedRepository) Patch(_ context.Context, id bson.ObjectID, _ builder.Patch) error { - if f.patchErr != nil { - return f.patchErr - } - f.patchedIDs = append(f.patchedIDs, id) - return nil -} - -func (f *fakeScopedRepository) Delete(_ context.Context, id bson.ObjectID) error { - if f.deleteErr != nil { - return f.deleteErr - } - delete(f.objects, id) - return nil -} - -func (f *fakeScopedRepository) ListIDs(_ context.Context, _ builder.Query) ([]bson.ObjectID, error) { - if f.listIDsErr != nil { - return nil, f.listIDsErr - } - ids := make([]bson.ObjectID, 0, len(f.objects)) - for id := range f.objects { - ids = append(ids, id) - } - return ids, nil -} - -// Unimplemented repository.Repository stubs. -func (f *fakeScopedRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { - return merrors.NotImplemented("aggregate") -} -func (f *fakeScopedRepository) FindOneByFilter(context.Context, builder.Query, storable.Storable) error { - return merrors.NotImplemented("FindOneByFilter") -} -func (f *fakeScopedRepository) FindFirstByFilter(context.Context, builder.Query, storable.Storable) error { - return merrors.NotImplemented("FindFirstByFilter") -} -func (f *fakeScopedRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error { - return merrors.NotImplemented("FindManyByFilter") -} -func (f *fakeScopedRepository) FindAllByFilter(context.Context, builder.Query, any) error { - return merrors.NotImplemented("FindAllByFilter") -} -func (f *fakeScopedRepository) CountByFilter(context.Context, builder.Query) (int64, error) { - return 0, merrors.NotImplemented("CountByFilter") -} -func (f *fakeScopedRepository) ReplaceOneByFilter(context.Context, builder.Query, storable.Storable) (bool, error) { - return false, merrors.NotImplemented("ReplaceOneByFilter") -} -func (f *fakeScopedRepository) Upsert(context.Context, storable.Storable) error { - return merrors.NotImplemented("Upsert") -} -func (f *fakeScopedRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) { - return false, merrors.NotImplemented("PatchOneByFilter") -} -func (f *fakeScopedRepository) PatchMany(_ context.Context, _ builder.Query, _ builder.Patch) (int, error) { - f.patchedIDs = append(f.patchedIDs, f.patchManyIDs...) - return len(f.patchManyIDs), nil -} -func (f *fakeScopedRepository) DeleteMany(context.Context, builder.Query) error { - return merrors.NotImplemented("DeleteMany") -} -func (f *fakeScopedRepository) CreateIndex(*ri.Definition) error { return nil } -func (f *fakeScopedRepository) DropIndex(context.Context, string) error { - return merrors.NotImplemented("DropIndex") -} -func (f *fakeScopedRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { - return nil, merrors.NotImplemented("ListIndexDefinitions") -} -func (f *fakeScopedRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) { - return nil, merrors.NotImplemented("ListPermissionBound") -} -func (f *fakeScopedRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { - return nil, merrors.NotImplemented("ListAccountBound") -} -func (f *fakeScopedRepository) WithTransaction(_ context.Context, cb transaction.Callback) (any, error) { - return cb(context.Background()) -} -func (f *fakeScopedRepository) Collection() string { return "test_scoped" } - -// newTestScopedDBImp wires a ScopedDBImp backed by the given in-memory repo, -// membership, and enforcer. The orgPolicyRef is returned so callers can verify -// it was applied correctly. -func newTestScopedDBImp(repo *fakeScopedRepository, membership *fakeMembership, enforcer Enforcer) (*ScopedDBImp[*testScopedObj], bson.ObjectID) { - orgPolicyRef := bson.NewObjectID() - dbImp := &template.DBImp[*testScopedObj]{ - Logger: zap.NewNop(), - Repository: repo, - } - dbImp.SetDeleter(func(_ context.Context, id bson.ObjectID) error { - return repo.Delete(context.Background(), id) - }) - imp := NewScopedDBImp[*testScopedObj]( - dbImp, - enforcer, - membership, - orgPolicyRef, - mservice.Type("test_scoped"), - func() *testScopedObj { return &testScopedObj{} }, - zap.NewNop(), - ) - return imp, orgPolicyRef -} - -// ── Create ──────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_Create_Personal_SkipsPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindAccount} - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success, got %v", err) - } - if enf.called { - t.Fatal("personal create must not consult the enforcer") - } - if obj.GetOwnerRef() != caller { - t.Fatalf("expected ownerRef=%s, got %s", caller.Hex(), obj.GetOwnerRef().Hex()) - } - if obj.GetOrganizationRef() != orgRef { - t.Fatal("organizationRef must be set on create") - } -} - -func TestScopedDBImp_Create_Personal_DefaultsToAccountWhenKindMissing(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{} // ownerKind is zero value (invalid) - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success with defaulted ownerKind, got %v", err) - } - if obj.GetOwnerKind() != model.OwnerKindAccount { - t.Fatalf("expected ownerKind=account, got %s", obj.GetOwnerKind()) - } - if enf.called { - t.Fatal("must not consult enforcer when defaulting to personal") - } -} - -func TestScopedDBImp_Create_Personal_DeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindAccount} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member personal create, got %v", err) - } - if !membership.called { - t.Fatal("personal create must check organization membership before insert") - } - if enf.called { - t.Fatal("personal create must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when membership denies") - } -} - -func TestScopedDBImp_Create_Org_CallsPolicyAndSetsFields(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: true} - db, orgPolicyRef := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("org create must consult the enforcer") - } - if enf.gotAction != model.ActionCreate { - t.Fatalf("expected ActionCreate, got %s", enf.gotAction) - } - if obj.GetOwnerRef() != orgRef { - t.Fatalf("expected ownerRef=orgRef for org object, got %s", obj.GetOwnerRef().Hex()) - } - if obj.GetPermissionRef() != orgPolicyRef { - t.Fatal("permissionRef must be set to OrgPolicyRef for org objects") - } -} - -func TestScopedDBImp_Create_Org_DeniedByPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when policy denies") - } -} - -func TestScopedDBImp_Create_Org_DeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org create, got %v", err) - } - if !membership.called { - t.Fatal("org create must check organization membership before policy") - } - if enf.called { - t.Fatal("org create must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when membership denies") - } -} - -func TestScopedDBImp_Create_Org_MembershipErrorFailsClosed(t *testing.T) { - boom := merrors.Define("membership backend down") - repo := newFakeScopedRepository() - membership := &fakeMembership{err: boom} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), bson.NewObjectID(), bson.NewObjectID(), obj) - if !errors.Is(err, boom) { - t.Fatalf("expected membership backend error propagated, got %v", err) - } - if enf.called { - t.Fatal("org create must fail closed and not consult policy on membership error") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when the membership check errors") - } -} - -// ── InsertMany ──────────────────────────────────────────────────────────────── - -func TestScopedDBImp_InsertMany_Empty_IsNoOp(t *testing.T) { - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - if err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), nil); err != nil { - t.Fatalf("empty InsertMany must succeed, got %v", err) - } - if enf.called { - t.Fatal("empty InsertMany must not call enforcer") - } -} - -func TestScopedDBImp_InsertMany_AllPersonal_SkipsPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindAccount}, - } - if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { - t.Fatalf("expected success, got %v", err) - } - if enf.called { - t.Fatal("all-personal InsertMany must not consult the enforcer") - } - if len(repo.objects) != 2 { - t.Fatalf("expected 2 inserted objects, got %d", len(repo.objects)) - } -} - -func TestScopedDBImp_InsertMany_AllPersonalDeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindAccount}, - } - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member personal InsertMany, got %v", err) - } - if !membership.called { - t.Fatal("personal InsertMany must check organization membership before insert") - } - if enf.called { - t.Fatal("personal InsertMany must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when membership denies") - } -} - -func TestScopedDBImp_InsertMany_MixedBatch_OrgPolicyCheckedOnce(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindOrganization}, - {ownerKind: model.OwnerKindOrganization}, - } - if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("enforcer must be called for mixed batch with org objects") - } - // Enforcer is called only once regardless of how many org objects are in the batch. - callCount := 0 - if enf.called { - callCount = 1 - } - if callCount != 1 { - t.Fatal("org policy must be checked exactly once for the entire batch") - } -} - -func TestScopedDBImp_InsertMany_OrgDenied_NoObjectsInsertedOrMutated(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - personal := &testScopedObj{} - org := &testScopedObj{ - orgRef: bson.NewObjectID(), - ownerKind: model.OwnerKindOrganization, - ownerRef: bson.NewObjectID(), - permissionRef: bson.NewObjectID(), - } - originalPersonal := *personal - originalOrg := *org - objects := []*testScopedObj{personal, org} - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when policy denies") - } - if *personal != originalPersonal { - t.Fatalf("personal object was mutated on denied InsertMany: got %+v want %+v", *personal, originalPersonal) - } - if *org != originalOrg { - t.Fatalf("org object was mutated on denied InsertMany: got %+v want %+v", *org, originalOrg) - } -} - -func TestScopedDBImp_InsertMany_OrgDeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org InsertMany, got %v", err) - } - if !membership.called { - t.Fatal("org InsertMany must check organization membership before policy") - } - if enf.called { - t.Fatal("org InsertMany must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when membership denies") - } -} - -func TestScopedDBImp_InsertMany_OrgMembershipErrorFailsClosed(t *testing.T) { - boom := merrors.Define("membership backend down") - repo := newFakeScopedRepository() - membership := &fakeMembership{err: boom} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} - err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), objects) - if !errors.Is(err, boom) { - t.Fatalf("expected membership backend error propagated, got %v", err) - } - if enf.called { - t.Fatal("org InsertMany must fail closed and not consult policy on membership error") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when the membership check errors") - } -} - -// ── Update ──────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_Update_RestoresOwnershipFields(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - orgPolicyRef := bson.NewObjectID() - - // Seed the repo with a personal object - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindAccount, - ownerRef: caller, - permissionRef: orgPolicyRef, - } - - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - // Submit an update that tries to change ownership - updated := &testScopedObj{ - id: objectRef, - ownerKind: model.OwnerKindOrganization, // attempt to escalate - ownerRef: bson.NewObjectID(), // attempt to hijack owner - } - if err := db.Update(context.Background(), caller, updated); err != nil { - t.Fatalf("expected success, got %v", err) - } - // After update the ownership fields must match the DB, not the submitted payload - if updated.GetOwnerKind() != model.OwnerKindAccount { - t.Fatalf("ownerKind must not change: got %s", updated.GetOwnerKind()) - } - if updated.GetOwnerRef() != caller { - t.Fatalf("ownerRef must not change: got %s", updated.GetOwnerRef().Hex()) - } - if updated.GetOrganizationRef() != orgRef { - t.Fatal("organizationRef must not change") - } - if updated.GetPermissionRef() != orgPolicyRef { - t.Fatal("permissionRef must not change") - } -} - -func TestScopedDBImp_Update_DeniedForNonOwner(t *testing.T) { - owner := bson.NewObjectID() - attacker := bson.NewObjectID() - objectRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindAccount, - ownerRef: owner, - } - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - err := db.Update(context.Background(), attacker, &testScopedObj{id: objectRef}) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-owner, got %v", err) - } -} - -// ── ListIDs ─────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_ListIDs_FiltersUnauthorized(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - orgRef := bson.NewObjectID() - - id1 := bson.NewObjectID() - id2 := bson.NewObjectID() - id3 := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects = map[bson.ObjectID]*testScopedObj{ - id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, // unauthorized - id3: {id: id3, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - } - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - ids, err := db.ListIDs(context.Background(), model.ActionRead, caller, nil) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if len(ids) != 2 { - t.Fatalf("expected 2 authorized IDs, got %d", len(ids)) - } - for _, id := range ids { - if id == id2 { - t.Fatal("unauthorized ID must not be returned") - } - } -} - -func TestScopedDBImp_ListIDs_PropagatesNonAuthError(t *testing.T) { - boom := merrors.Define("enforcer backend down") - objectRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{err: boom} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error propagated, got %v", err) - } -} - -func TestScopedDBImp_ListIDs_RawRepoErrorPropagated(t *testing.T) { - boom := merrors.Define("mongo down") - repo := newFakeScopedRepository() - repo.listIDsErr = boom - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) - if !errors.Is(err, boom) { - t.Fatalf("expected raw list error propagated, got %v", err) - } -} - -// ── PatchMany ───────────────────────────────────────────────────────────────── - -func TestScopedDBImp_PatchMany_PatchesOnlyAuthorizedIDs(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - orgRef := bson.NewObjectID() - - id1 := bson.NewObjectID() - id2 := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects = map[bson.ObjectID]*testScopedObj{ - id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, - } - // patchManyIDs declares which IDs the bulk DB call will report as affected. - // Only id1 is authorized (ownerRef==caller), so the IN filter contains only id1. - repo.patchManyIDs = []bson.ObjectID{id1} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - count, err := db.PatchMany(context.Background(), caller, nil, nil) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if count != 1 { - t.Fatalf("expected 1 patched object, got %d", count) - } - if len(repo.patchedIDs) != 1 || repo.patchedIDs[0] != id1 { - t.Fatal("only the authorized ID must be patched") - } -} - -// ── BuildScopedQuery ────────────────────────────────────────────────────────── - -// bsonHasPair reports whether the key/value pair occurs anywhere within the -// bson.D/bson.A tree produced by builder.Query.BuildQuery(). -func bsonHasPair(node any, key string, value any) bool { - switch n := node.(type) { - case bson.D: - for _, e := range n { - if e.Key == key && e.Value == value { - return true - } - if bsonHasPair(e.Value, key, value) { - return true - } - } - case bson.A: - for _, item := range n { - if bsonHasPair(item, key, value) { - return true - } - } - } - return false -} - -// bsonHasKey reports whether the key occurs anywhere within the tree. -func bsonHasKey(node any, key string) bool { - switch n := node.(type) { - case bson.D: - for _, e := range n { - if e.Key == key { - return true - } - if bsonHasKey(e.Value, key) { - return true - } - } - case bson.A: - for _, item := range n { - if bsonHasKey(item, key) { - return true - } - } - } - return false -} - -func TestScopedDBImp_BuildScopedQuery_WithOrgAccess_PersonalBranchIsCrossOrg(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("enforcer must be called to check org access") - } - - built := q.BuildQuery() - if len(built) != 1 || built[0].Key != "$or" { - t.Fatalf("expected a single top-level $or, got %v", built) - } - branches, ok := built[0].Value.(bson.A) - if !ok || len(branches) != 2 { - t.Fatalf("expected 2 $or branches, got %v", built[0].Value) - } - - personal := branches[0] - if !bsonHasPair(personal, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(personal, ownerRefField, caller) { - t.Fatalf("personal branch must match owner kind/ref, got %v", personal) - } - if bsonHasKey(personal, storable.OrganizationRefField) { - t.Fatalf("personal branch must not be org-bounded, got %v", personal) - } - - org := branches[1] - if !bsonHasPair(org, storable.OrganizationRefField, orgRef) || - !bsonHasPair(org, ownerKindField, model.OwnerKindOrganization) { - t.Fatalf("org branch must be org-bounded and org-owned, got %v", org) - } -} - -func TestScopedDBImp_BuildScopedQuery_WithoutOrgAccess_PersonalOnly(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - - built := q.BuildQuery() - if bsonHasKey(built, storable.OrganizationRefField) { - t.Fatalf("query must be personal-only without org access, got %v", built) - } - if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(built, ownerRefField, caller) { - t.Fatalf("query must match personal owner kind/ref, got %v", built) - } -} - -func TestScopedDBImp_BuildScopedQuery_MembershipError_PersonalOnly(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{err: merrors.Define("membership down")}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("membership failure must degrade to personal-only, got %v", err) - } - if enf.called { - t.Fatal("enforcer must not be consulted when the membership check fails") - } - - built := q.BuildQuery() - if bsonHasKey(built, storable.OrganizationRefField) { - t.Fatalf("query must be personal-only on membership failure, got %v", built) - } - if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(built, ownerRefField, caller) { - t.Fatalf("query must match personal owner kind/ref, got %v", built) - } -} - -func TestScopedDBImp_BuildScopedQuery_EnforcerError_Propagated(t *testing.T) { - boom := merrors.Define("enforcer down") - repo := newFakeScopedRepository() - enf := &fakeEnforcer{err: boom} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - _, err := db.BuildScopedQuery(context.Background(), bson.NewObjectID(), bson.NewObjectID()) - if !errors.Is(err, boom) { - t.Fatalf("expected enforcer error propagated, got %v", err) - } -} diff --git a/backend/pkg/auth/dbimpscopedcrud.go b/backend/pkg/auth/dbimpscopedcrud.go deleted file mode 100644 index c64afac..0000000 --- a/backend/pkg/auth/dbimpscopedcrud.go +++ /dev/null @@ -1,262 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// Create inserts a new resource. If ownerKind is absent or invalid it defaults -// to account. -// -// Personal path (ownerKind=account): requires the caller to be a member of -// organizationRef (so a non-member cannot plant personal objects in a foreign -// org namespace), then sets ownerRef=accountRef and inserts. The org policy is -// not consulted — after creation, access is governed by ownership alone. -// -// Org path (ownerKind=organization): checks org membership and the org policy -// (via authorizeOrgMutation) first, then stamps ownerRef/permissionRef and -// inserts. Ownership fields are only mutated after the access check so a denied -// caller's object is unchanged. -func (db *ScopedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("attempting to create scoped object", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection)) - - if !object.GetOwnerKind().IsValid() { - object.SetOwnerKind(model.OwnerKindAccount) - } - - switch object.GetOwnerKind() { - case model.OwnerKindAccount: - if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - object.SetOwnerRef(accountRef) - object.SetOrganizationRef(organizationRef) - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to create personal scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - return nil - - case model.OwnerKindOrganization: - if err := db.authorizeOrgMutation(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - object.SetOwnerRef(organizationRef) - object.SetOrganizationRef(organizationRef) - object.SetPermissionRef(db.OrgPolicyRef) - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to create org scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - return nil - - default: - db.DBImp.Logger.Warn("unsupported ownerKind on create", - zap.String("ownerKind", string(object.GetOwnerKind())), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return merrors.InvalidArgument("unsupported ownerKind", string(object.GetOwnerKind())) - } -} - -// InsertMany bulk-inserts resources. Ownership fields are normalized per -// object (defaulting to account when ownerKind is absent). Org membership, and -// any required org policy check, run before any object is mutated. -func (db *ScopedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { - if len(objects) == 0 { - return nil - } - - db.DBImp.Logger.Debug("attempting to insert many scoped objects", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.Int("count", len(objects)), zap.String("collection", db.Collection)) - - if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - - ownerKinds := make([]model.OwnerKind, len(objects)) - var hasOrgObjects bool - for i, obj := range objects { - ownerKind := obj.GetOwnerKind() - if !ownerKind.IsValid() { - ownerKind = model.OwnerKindAccount - } - switch ownerKind { - case model.OwnerKindAccount: - case model.OwnerKindOrganization: - hasOrgObjects = true - default: - return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKind)) - } - ownerKinds[i] = ownerKind - } - - if hasOrgObjects { - if err := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - } - - for i, obj := range objects { - switch ownerKinds[i] { - case model.OwnerKindAccount: - obj.SetOwnerKind(model.OwnerKindAccount) - obj.SetOwnerRef(accountRef) - obj.SetOrganizationRef(organizationRef) - case model.OwnerKindOrganization: - obj.SetOwnerKind(model.OwnerKindOrganization) - obj.SetOwnerRef(organizationRef) - obj.SetOrganizationRef(organizationRef) - obj.SetPermissionRef(db.OrgPolicyRef) - default: - return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKinds[i])) - } - } - - if err := db.DBImp.InsertMany(ctx, objects); err != nil { - db.DBImp.Logger.Warn("failed to insert many scoped objects", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.Int("count", len(objects))) - return err - } - return nil -} - -// authorizeOrgMembership requires the caller to be a member of the -// organization. It is the create-time gate shared by the personal and org -// paths: since no record exists yet to read ownership from, membership in the -// target org is the minimum proof that the caller may write into its -// namespace. It fails closed — a membership backend error denies the mutation. -func (db *ScopedDBImp[T]) authorizeOrgMembership(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) - if err != nil { - db.DBImp.Logger.Warn("membership check failed during create-time mutation", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection)) - return err - } - if !isMember { - db.DBImp.Logger.Debug("access denied: caller is not a member of the organization", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - - return nil -} - -// authorizeOrgPolicy checks that the org policy allows the caller to perform -// the action. It assumes membership has already been verified — callers must -// go through authorizeOrgMembership (or authorizeOrgMutation) first. -func (db *ScopedDBImp[T]) authorizeOrgPolicy(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - ok, err := db.Enforcer.Enforce(ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, action) - if err != nil { - return err - } - if !ok { - db.DBImp.Logger.Debug("access denied: org mutation policy", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - return nil -} - -// authorizeOrgMutation gates a create-time org mutation: the caller must be a -// member of the organization (authorizeOrgMembership) AND the org policy must -// allow the action (authorizeOrgPolicy). Because no record exists yet to read -// ownerKind from, it cannot go through loadAndEnforce; this mirrors the -// membership-first check ScopedAuthorizer applies to read/update/delete of -// org-owned resources. -func (db *ScopedDBImp[T]) authorizeOrgMutation(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - isMemberErr := db.authorizeOrgMembership(ctx, accountRef, organizationRef, action) - if isMemberErr != nil { - return isMemberErr - } - if isPolicyErr := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, action); isPolicyErr != nil { - return isPolicyErr - } - return nil -} - -// Get fetches a resource after verifying ActionRead via LoadAndEnforce. -func (db *ScopedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.DBImp.Logger.Debug("attempting to get scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionRead); err != nil { - return err - } - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.DBImp.Logger.Warn("failed to get scoped object after auth", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// Update replaces a resource after verifying ActionUpdate. Immutable ownership -// fields (ownerKind, ownerRef, orgRef, permissionRef) are always restored from -// the database record so callers cannot change resource ownership via the payload. -func (db *ScopedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("attempting to update scoped object", - mzap.AccRef(accountRef), mzap.StorableRef(object)) - - existing, err := db.loadAndEnforce(ctx, accountRef, *object.GetID(), model.ActionUpdate) - if err != nil { - return err - } - object.SetOwnerKind(existing.GetOwnerKind()) - object.SetOwnerRef(existing.GetOwnerRef()) - object.SetOrganizationRef(existing.GetOrganizationRef()) - object.SetPermissionRef(existing.GetPermissionRef()) - - if err := db.DBImp.Update(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to update scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.StorableRef(object)) - return err - } - return nil -} - -// Delete removes a resource after verifying ActionDelete. -func (db *ScopedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("attempting to delete scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { - return err - } - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("failed to delete scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// DeleteCascadeAuth removes a resource (with cascade via the registered deleter) -// after verifying ActionDelete. -func (db *ScopedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("attempting cascade-delete scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { - return err - } - if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("failed to cascade-delete scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} diff --git a/backend/pkg/auth/dbimpscopedpatch.go b/backend/pkg/auth/dbimpscopedpatch.go deleted file mode 100644 index ec9a50c..0000000 --- a/backend/pkg/auth/dbimpscopedpatch.go +++ /dev/null @@ -1,129 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// Patch applies a partial update after verifying ActionUpdate. -func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.DBImp.Logger.Debug("attempting to patch scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := denyImmutablePatch(patch); err != nil { - return err - } - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { - return err - } - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("failed to patch scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// SetArchived patches the isArchived field after verifying ActionUpdate. -// -// organizationRef is accepted for interface compatibility but not used for -// enforcement — ownership is read from the DB via LoadAndEnforce. -// cascade is accepted but not applied; resources that require cascading archived -// status must implement cascade logic in a dedicated wrapper. -func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error { - db.DBImp.Logger.Debug("attempting to change archive status of scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Bool("archived", isArchived)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { - return err - } - patch := repository.Patch().Set(repository.IsArchivedField(), isArchived) - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("failed to change archive status of scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// ListIDs returns the authorized subset of IDs matching query for the given -// action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are -// silently skipped. Non-access-denied errors abort the call. -func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { - db.DBImp.Logger.Debug("attempting to list scoped object IDs", - mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - rawIDs, err := db.DBImp.ListIDs(ctx, query) - if err != nil { - db.DBImp.Logger.Warn("failed to list raw object IDs", - zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - return nil, err - } - - allowed := make([]bson.ObjectID, 0, len(rawIDs)) - for _, id := range rawIDs { - if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil { - allowed = append(allowed, id) - } else if !errors.Is(authErr, merrors.ErrAccessDenied) { - db.DBImp.Logger.Warn("enforcer error during ListIDs", - zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id)) - return nil, authErr - } - // ErrAccessDenied: silently skip - } - - db.DBImp.Logger.Debug("completed ListIDs", - mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed))) - return allowed, nil -} - -// PatchMany applies patch to all resources in query that accountRef is -// authorized to update. Authorization is resolved via ListIDs; the write is a -// single bulk operation using originalQuery AND _id IN (authorizedIDs) so -// records that leave the original query between the read and write are not -// inadvertently mutated. -func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { - db.DBImp.Logger.Debug("attempting to patch many scoped objects", - mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - if err := denyImmutablePatch(patch); err != nil { - return 0, err - } - - ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) - if err != nil { - return 0, err - } - if len(ids) == 0 { - return 0, nil - } - - values := make([]any, len(ids)) - for i, id := range ids { - values[i] = id - } - idFilter := repository.Query().In(repository.IDField(), values...) - finalQuery := idFilter - if query != nil { - finalQuery = query.And(idFilter) - } - modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch) - if err != nil { - db.DBImp.Logger.Warn("failed to patch many scoped objects", - zap.Error(err), mzap.AccRef(accountRef)) - return 0, err - } - - db.DBImp.Logger.Debug("patched many scoped objects", - mzap.AccRef(accountRef), zap.Int("count", modified)) - return modified, nil -} diff --git a/backend/pkg/auth/enforcer.go b/backend/pkg/auth/enforcer.go deleted file mode 100644 index a3815a8..0000000 --- a/backend/pkg/auth/enforcer.go +++ /dev/null @@ -1,32 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Enforcer interface { - // Enforce checks if accountRef can do `action` on objectRef in an org (domainRef). - Enforce( - ctx context.Context, - permissionRef, accountRef, orgRef, objectRef bson.ObjectID, - action model.Action, - ) (bool, error) - - // Enforce batch of objects - EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, - ) (map[bson.ObjectID]bool, error) - - // GetRoles returns the user's roles in a given org domain, plus any partial scopes if relevant. - GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) - - // GetPermissions returns all effective permissions (with effect, object scoping) for a user in org domain. - // Merges from all roles the user holds, plus any denies/exceptions. - GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) -} diff --git a/backend/pkg/auth/factory.go b/backend/pkg/auth/factory.go deleted file mode 100644 index 7b100c2..0000000 --- a/backend/pkg/auth/factory.go +++ /dev/null @@ -1,58 +0,0 @@ -package auth - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func CreateAuth( - logger mlogger.Logger, - client *mongo.Client, - db *mongo.Database, - pdb policy.DB, - rdb role.DB, - config *Config, -) (Enforcer, Manager, error) { - lg := logger.Named("auth") - if config == nil || config.Driver == "" { - lg.Warn("Permissions enforcer config is missing, defaulting to native enforcer") - config = &Config{ - Driver: Native, - } - } - lg.Debug("Creating enforcer...", zap.String("driver", string(config.Driver))) - l := lg.Named(string(config.Driver)) - if config.Driver == Casbin { - enforcer, err := casbin.NewEnforcer(l, client, config.Settings) - if err != nil { - lg.Warn("Failed to create enforcer", zap.Error(err)) - return nil, nil, err - } - manager, err := casbin.NewManager(l, pdb, rdb, enforcer, config.Settings) - if err != nil { - lg.Warn("Failed to create managment interface", zap.Error(err)) - return nil, nil, err - } - return enforcer, manager, nil - } - if config.Driver == Native { - enforcer, err := native.NewEnforcer(l, db) - if err != nil { - lg.Warn("Failed to create enforcer", zap.Error(err)) - return nil, nil, err - } - manager, err := native.NewManager(l, pdb, rdb, enforcer) - if err != nil { - lg.Warn("Failed to create managment interface", zap.Error(err)) - return nil, nil, err - } - return enforcer, manager, nil - } - return nil, nil, merrors.InvalidArgument("Unknown enforcer type: "+string(config.Driver), "config.driver") -} diff --git a/backend/pkg/auth/helper.go b/backend/pkg/auth/helper.go deleted file mode 100644 index e82c171..0000000 --- a/backend/pkg/auth/helper.go +++ /dev/null @@ -1,61 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func enforceObject[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef bson.ObjectID, query builder.Query) error { - l, err := db.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Error occured while checking access rights", zap.Error(err), - mzap.AccRef(accountRef), zap.String("action", string(action))) - return err - } - if len(l) == 0 { - db.Logger.Debug("Access denied", mzap.AccRef(accountRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), bson.NilObjectID) - } - for _, item := range l { - db.Logger.Debug("Object found", mzap.ObjRef("object_ref", *item.GetID()), - mzap.OrgRef(item.GetOrganizationRef()), - mzap.PermissionRef(item.GetPermissionRef()), - zap.String("collection", item.Collection())) - } - res, err := enforcer.EnforceBatch(ctx, l, accountRef, action) - if err != nil { - db.Logger.Warn("Failed to enforce permission", zap.Error(err), - mzap.AccRef(accountRef), zap.String("action", string(action))) - } - for objectRef, hasPermission := range res { - if !hasPermission { - db.Logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) - } - } - return nil -} - -func enforceObjectByRef[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef, objectRef bson.ObjectID) error { - err := enforceObject(ctx, db, enforcer, action, accountRef, repository.IDFilter(objectRef)) - if err != nil { - if errors.Is(err, merrors.ErrAccessDenied) { - db.Logger.Debug("Access denied", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) - } else { - db.Logger.Warn("Error occurred while checking permissions", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - } - } - return err -} diff --git a/backend/pkg/auth/indexable.go b/backend/pkg/auth/indexable.go deleted file mode 100644 index dd205f2..0000000 --- a/backend/pkg/auth/indexable.go +++ /dev/null @@ -1,29 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// IndexableDB implements reordering with permission checking -type IndexableDB[T storable.Storable] interface { - // Reorder implements reordering with permission checking using EnforceBatch - Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error -} - -// NewIndexableDB creates a new auth.IndexableDB instance -func NewIndexableDB[T storable.Storable]( - repo repository.Repository, - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getIndexable func(T) *model.Indexable, -) IndexableDB[T] { - return newIndexableDBImp(repo, logger, enforcer, createEmpty, getIndexable) -} diff --git a/backend/pkg/auth/indexableimp.go b/backend/pkg/auth/indexableimp.go deleted file mode 100644 index cb2b4e6..0000000 --- a/backend/pkg/auth/indexableimp.go +++ /dev/null @@ -1,182 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// IndexableDB implements reordering with permission checking -type indexableDBImp[T storable.Storable] struct { - repo repository.Repository - logger mlogger.Logger - enforcer Enforcer - createEmpty func() T - getIndexable func(T) *model.Indexable -} - -// NewIndexableDB creates a new auth.IndexableDB instance -func newIndexableDBImp[T storable.Storable]( - repo repository.Repository, - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getIndexable func(T) *model.Indexable, -) IndexableDB[T] { - return &indexableDBImp[T]{ - repo: repo, - logger: logger.Named("indexable"), - enforcer: enforcer, - createEmpty: createEmpty, - getIndexable: getIndexable, - } -} - -// Reorder implements reordering with permission checking using EnforceBatch -func (db *indexableDBImp[T]) Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error { - // Get current object to find its index - obj := db.createEmpty() - if err := db.repo.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for reordering", zap.Error(err), zap.Int("new_index", newIndex), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - // Extract index from the object - indexable := db.getIndexable(obj) - currentIndex := indexable.Index - if currentIndex == newIndex { - db.logger.Debug("No reordering needed - same index", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return nil // No change needed - } - - // Determine which objects will be affected by the reordering - var affectedObjects []model.PermissionBoundStorable - - if currentIndex < newIndex { - // Moving down: items between currentIndex+1 and newIndex will be shifted up by -1 - reorderFilter := filter. - And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). - And(repository.IndexOpFilter(newIndex, builder.Lte)) - - // Get all affected objects using ListPermissionBound - objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) - if err != nil { - db.logger.Warn("Failed to get affected objects for reordering (moving down)", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - affectedObjects = append(affectedObjects, objects...) - db.logger.Debug("Found affected objects for moving down", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) - } else { - // Moving up: items between newIndex and currentIndex-1 will be shifted down by +1 - reorderFilter := filter. - And(repository.IndexOpFilter(newIndex, builder.Gte)). - And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) - - // Get all affected objects using ListPermissionBound - objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) - if err != nil { - db.logger.Warn("Failed to get affected objects for reordering (moving up)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - affectedObjects = append(affectedObjects, objects...) - db.logger.Debug("Found affected objects for moving up", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) - } - - // Add the target object to the list of objects that need permission checking - targetObjects, err := db.repo.ListPermissionBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.logger.Warn("Failed to get target object for permission checking", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(targetObjects) > 0 { - affectedObjects = append(affectedObjects, targetObjects[0]) - } - - // Check permissions for all affected objects using EnforceBatch - db.logger.Debug("Checking permissions for reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - - permissions, err := db.enforcer.EnforceBatch(ctx, affectedObjects, accountRef, model.ActionUpdate) - if err != nil { - db.logger.Warn("Failed to check permissions for reordering", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects))) - return merrors.Internal("failed to check permissions for reordering") - } - - // Verify all objects have update permission - for resObjectRef, hasPermission := range permissions { - if !hasPermission { - db.logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) - return merrors.AccessDenied(db.repo.Collection(), string(model.ActionUpdate), resObjectRef) - } - } - - db.logger.Debug("All permissions granted, proceeding with reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("permission_count", len(permissions))) - - // All permissions checked, proceed with reordering - if currentIndex < newIndex { - // Moving down: shift items between currentIndex+1 and newIndex up by -1 - patch := repository.Patch().Inc(repository.IndexField(), -1) - reorderFilter := filter. - And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). - And(repository.IndexOpFilter(newIndex, builder.Lte)) - - updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) - if err != nil { - db.logger.Warn("Failed to shift objects during reordering (moving down)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) - return err - } - db.logger.Debug("Successfully shifted objects (moving down)", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) - } else { - // Moving up: shift items between newIndex and currentIndex-1 down by +1 - patch := repository.Patch().Inc(repository.IndexField(), 1) - reorderFilter := filter. - And(repository.IndexOpFilter(newIndex, builder.Gte)). - And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) - - updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) - if err != nil { - db.logger.Warn("Failed to shift objects during reordering (moving up)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) - return err - } - db.logger.Debug("Successfully shifted objects (moving up)", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) - } - - // Update the target object to new index - if err := db.repo.Patch(ctx, objectRef, repository.Patch().Set(repository.IndexField(), newIndex)); err != nil { - db.logger.Warn("Failed to update target object index", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - - db.logger.Debug("Successfully reordered object with permission checking", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("old_index", currentIndex), - zap.Int("new_index", newIndex), zap.Int("affected_count", len(affectedObjects))) - return nil -} diff --git a/backend/pkg/auth/internal/casbin/config/config.go b/backend/pkg/auth/internal/casbin/config/config.go deleted file mode 100644 index 924ced4..0000000 --- a/backend/pkg/auth/internal/casbin/config/config.go +++ /dev/null @@ -1,127 +0,0 @@ -package casbin - -import ( - "os" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.uber.org/zap" -) - -type AdapterConfig struct { - DatabaseName *string `mapstructure:"database_name"` - DatabaseNameEnv *string `mapstructure:"database_name_env"` - CollectionName *string `mapstructure:"collection_name"` - CollectionNameEnv *string `mapstructure:"collection_name_env"` - TimeoutSeconds *int `mapstructure:"timeout_seconds"` - TimeoutSecondsEnv *string `mapstructure:"timeout_seconds_env"` - IsFiltered *bool `mapstructure:"is_filtered"` - IsFilteredEnv *string `mapstructure:"is_filtered_env"` -} - -type Config struct { - ModelPath *string `mapstructure:"model_path"` - ModelPathEnv *string `mapstructure:"model_path_env"` - Adapter *AdapterConfig `mapstructure:"adapter"` -} - -type EnforcerConfig struct { - ModelPath string - Adapter *mongoadapter.Config -} - -func getEnvValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - return os.Getenv(*envValue) - } - - if value != nil { - return *value - } - - return "" -} - -func getEnvIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - envStr := os.Getenv(*envValue) - if envStr != "" { - if parsed, err := time.ParseDuration(envStr + "s"); err == nil { - return int(parsed.Seconds()) - } - logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr)) - } - } - - if value != nil { - return *value - } - - return 30 // Default timeout in seconds -} - -func getEnvBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - envStr := os.Getenv(*envValue) - switch envStr { - case "true", "1": - return true - case "false", "0": - return false - } - logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr)) - } - - if value != nil { - return *value - } - - return false // Default for boolean -} - -func PrepareConfig(logger mlogger.Logger, config *Config) (*EnforcerConfig, error) { - if config == nil { - return nil, merrors.Internal("No configuration provided") - } - - adapter := &mongoadapter.Config{ - DatabaseName: getEnvValue(logger, "database_name", "database_name_env", config.Adapter.DatabaseName, config.Adapter.DatabaseNameEnv), - CollectionName: getEnvValue(logger, "collection_name", "collection_name_env", config.Adapter.CollectionName, config.Adapter.CollectionNameEnv), - Timeout: time.Duration(getEnvIntValue(logger, "timeout_seconds", "timeout_seconds_env", config.Adapter.TimeoutSeconds, config.Adapter.TimeoutSecondsEnv)) * time.Second, - IsFiltered: getEnvBoolValue(logger, "is_filtered", "is_filtered_env", config.Adapter.IsFiltered, config.Adapter.IsFilteredEnv), - } - - if len(adapter.DatabaseName) == 0 { - logger.Error("Database name is not set") - return nil, merrors.InvalidArgument("database name must be provided", "adapter.databaseName") - } - - path := getEnvValue(logger, "model_path", "model_path_env", config.ModelPath, config.ModelPathEnv) - - logger.Info("Configuration prepared", - zap.String("model_path", path), - zap.String("database_name", adapter.DatabaseName), - zap.String("collection_name", adapter.CollectionName), - zap.Duration("timeout", adapter.Timeout), - zap.Bool("is_filtered", adapter.IsFiltered), - ) - - return &EnforcerConfig{ModelPath: path, Adapter: adapter}, nil -} diff --git a/backend/pkg/auth/internal/casbin/enforcer.go b/backend/pkg/auth/internal/casbin/enforcer.go deleted file mode 100644 index 5b654d3..0000000 --- a/backend/pkg/auth/internal/casbin/enforcer.go +++ /dev/null @@ -1,206 +0,0 @@ -// casbin_enforcer.go -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "github.com/casbin/casbin/v3" - "github.com/go-viper/mapstructure/v2" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -// CasbinEnforcer implements the Enforcer interface using Casbin. -type CasbinEnforcer struct { - logger mlogger.Logger - enforcer *casbin.Enforcer - roleSerializer serialization.Role - permissionSerializer serialization.Policy -} - -// NewCasbinEnforcer initializes a new CasbinEnforcer with a MongoDB adapter, logger, and PolicySerializer. -// The 'domain' parameter is no longer stored internally, as the interface requires passing a domainRef per method call. -func NewEnforcer( - logger mlogger.Logger, - client *mongo.Client, - settings model.SettingsT, -) (*CasbinEnforcer, error) { - var config cc.Config - if err := mapstructure.Decode(settings, &config); err != nil { - logger.Warn("Failed to decode Casbin configuration", zap.Error(err), zap.Any("settings", settings)) - return nil, merrors.Internal("failed to decode Casbin configuration") - } - - // Create a Casbin adapter + enforcer from your config and client. - l := logger.Named("enforcer") - e, err := createAdapter(l, &config, client) - if err != nil { - logger.Warn("Failed to create Casbin enforcer", zap.Error(err)) - return nil, merrors.Internal("failed to create Casbin enforcer") - } - - logger.Info("Casbin enforcer created") - return &CasbinEnforcer{ - logger: l, - enforcer: e, - permissionSerializer: serialization.NewPolicySerializer(), - roleSerializer: serialization.NewRoleSerializer(), - }, nil -} - -// Enforce checks if a user has the specified action permission on an object within a domain. -func (c *CasbinEnforcer) Enforce( - _ context.Context, - permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, - action model.Action, -) (bool, error) { - // Convert ObjectIDs to strings for Casbin - account := accountRef.Hex() - organization := organizationRef.Hex() - permission := permissionRef.Hex() - object := anyobject.ID - if objectRef != bson.NilObjectID { - object = objectRef.Hex() - } - act := string(action) - - c.logger.Debug("Enforcing policy", - zap.String("account", account), zap.String("organization", organization), - zap.String("permission", permission), zap.String("object", object), - zap.String("action", act)) - - // Perform the enforcement - result, err := c.enforcer.Enforce(account, organization, permission, object, act) - if err != nil { - c.logger.Warn("Failed to enforce policy", zap.Error(err), - zap.String("account", account), zap.String("organization", organization), - zap.String("permission", permission), zap.String("object", object), - zap.String("action", act)) - return false, err - } - - c.logger.Debug("Policy enforcement result", zap.Bool("result", result)) - return result, nil -} - -// EnforceBatch checks a user’s permission for multiple objects at once. -// It returns a map from objectRef -> boolean indicating whether access is granted. -func (c *CasbinEnforcer) EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, -) (map[bson.ObjectID]bool, error) { - results := make(map[bson.ObjectID]bool, len(objectRefs)) - for _, desc := range objectRefs { - ok, err := c.Enforce(ctx, desc.GetPermissionRef(), accountRef, desc.GetOrganizationRef(), *desc.GetID(), action) - if err != nil { - c.logger.Warn("Failed to enforce", zap.Error(err), mzap.PermissionRef(desc.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), - mzap.StorableRef(desc), zap.String("action", string(action))) - return nil, err - } - results[*desc.GetID()] = ok - } - - return results, nil -} - -// GetRoles retrieves all roles assigned to the user within the domain. -func (c *CasbinEnforcer) GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) { - sub := accountRef.Hex() - dom := orgRef.Hex() - - c.logger.Debug("Fetching roles for user", zap.String("subject", sub), zap.String("domain", dom)) - - // Get all roles for the user in the domain - sroles, err := c.enforcer.GetFilteredGroupingPolicy(0, sub, "", dom) - if err != nil { - c.logger.Warn("Failed to get roles from policies", zap.Error(err), - zap.String("account_ref", sub), zap.String("organization_ref", dom), - ) - return nil, merrors.Internal("failed to fetch roles from policies") - } - - roles := make([]model.Role, 0, len(sroles)) - for _, srole := range sroles { - role, err := c.roleSerializer.Deserialize(srole) - if err != nil { - c.logger.Warn("Failed to deserialize role", zap.Error(err)) - return nil, err - } - roles = append(roles, *role) - } - - c.logger.Debug("Roles fetched successfully", zap.Int("count", len(roles))) - return roles, nil -} - -// GetPermissions retrieves all effective policies for the user within the domain. -func (c *CasbinEnforcer) GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) { - c.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - - // Step 1: Retrieve all roles assigned to the user within the domain - roles, err := c.GetRoles(ctx, accountRef, orgRef) - if err != nil { - c.logger.Warn("Failed to get roles", zap.Error(err)) - return nil, nil, err - } - - // Map to hold unique policies - permissionsMap := make(map[string]*model.Permission) - for _, role := range roles { - // Step 2a: Retrieve all policies associated with the role within the domain - policies, err := c.enforcer.GetFilteredPolicy(0, role.DescriptionRef.Hex()) - if err != nil { - c.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) - continue - } - - // Step 2b: Process each policy to extract Permission, Action, and Effect - for _, policy := range policies { - - if len(policy) < 5 { - c.logger.Warn("Incomplete policy encountered", zap.Strings("policy", policy)) - continue // Ensure the policy line has enough fields - } - - // Deserialize the policy using - deserializedPolicy, err := c.permissionSerializer.Deserialize(policy) - if err != nil { - c.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.Strings("policy", policy)) - continue - } - - // Construct a unique key combining Permission ID and Action to prevent duplicates - policyKey := deserializedPolicy.DescriptionRef.Hex() + ":" + string(deserializedPolicy.Effect.Action) - if _, exists := permissionsMap[policyKey]; exists { - continue // Policy-action pair already accounted for - } - - // Add the Policy to the map - permissionsMap[policyKey] = &model.Permission{ - RolePolicy: *deserializedPolicy, - AccountRef: accountRef, - } - c.logger.Debug("Policy added to policyMap", zap.Any("policy_key", policyKey)) - } - } - - // Convert the map to a slice - permissions := make([]model.Permission, 0, len(permissionsMap)) - for _, permission := range permissionsMap { - permissions = append(permissions, *permission) - } - - c.logger.Debug("Permissions fetched successfully", zap.Int("count", len(permissions))) - return roles, permissions, nil -} diff --git a/backend/pkg/auth/internal/casbin/factory.go b/backend/pkg/auth/internal/casbin/factory.go deleted file mode 100644 index 4377e22..0000000 --- a/backend/pkg/auth/internal/casbin/factory.go +++ /dev/null @@ -1,34 +0,0 @@ -package casbin - -import ( - cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "github.com/casbin/casbin/v3" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func createAdapter(logger mlogger.Logger, config *cc.Config, client *mongo.Client) (*casbin.Enforcer, error) { - dbc, err := cc.PrepareConfig(logger, config) - if err != nil { - logger.Warn("Failed to prepare database configuration", zap.Error(err)) - return nil, err - } - - adapter, err := mongoadapter.New(client, dbc.Adapter) - if err != nil { - logger.Warn("Failed to create DB adapter", zap.Error(err)) - return nil, err - } - - e, err := casbin.NewEnforcer(dbc.ModelPath, adapter, NewCasbinLogger(logger)) - if err != nil { - logger.Warn("Failed to create permissions enforcer", zap.Error(err)) - return nil, err - } - e.EnableAutoSave(true) - - // No need to manually load policy. Casbin does it for us - return e, nil -} diff --git a/backend/pkg/auth/internal/casbin/logger.go b/backend/pkg/auth/internal/casbin/logger.go deleted file mode 100644 index 70df728..0000000 --- a/backend/pkg/auth/internal/casbin/logger.go +++ /dev/null @@ -1,61 +0,0 @@ -package casbin - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.uber.org/zap" -) - -// CasbinZapLogger wraps a zap.Logger to implement Casbin's Logger interface. -type CasbinZapLogger struct { - logger mlogger.Logger -} - -// NewCasbinLogger constructs a new CasbinZapLogger. -func NewCasbinLogger(logger mlogger.Logger) *CasbinZapLogger { - return &CasbinZapLogger{ - logger: logger.Named("driver"), - } -} - -// EnableLog enables or disables logging. -func (l *CasbinZapLogger) EnableLog(_ bool) { - // ignore -} - -// IsEnabled returns whether logging is currently enabled. -func (l *CasbinZapLogger) IsEnabled() bool { - return true -} - -// LogModel is called by Casbin when loading model settings (you can customize if you want). -func (l *CasbinZapLogger) LogModel(m [][]string) { - l.logger.Info("Model loaded", zap.Any("model", m)) -} - -func (l *CasbinZapLogger) LogPolicy(m map[string][][]string) { - l.logger.Info("Policy loaded", zap.Int("entries", len(m))) -} - -func (l *CasbinZapLogger) LogError(err error, msg ...string) { - // If no custom message was passed, log a generic one - if len(msg) == 0 { - l.logger.Warn("Error occurred", zap.Error(err)) - return - } - - // Otherwise, join any provided messages and include them - l.logger.Warn(strings.Join(msg, " "), zap.Error(err)) -} - -// LogEnforce is called by Casbin to log each Enforce() call if logging is enabled. -func (l *CasbinZapLogger) LogEnforce(matcher string, request []any, result bool, explains [][]string) { - l.logger.Debug("Enforcing policy...", zap.String("matcher", matcher), zap.Any("request", request), - zap.Bool("result", result), zap.Any("explains", explains)) -} - -// LogRole is called by Casbin when role manager adds or deletes a role. -func (l *CasbinZapLogger) LogRole(roles []string) { - l.logger.Debug("Changing roles...", zap.Strings("roles", roles)) -} diff --git a/backend/pkg/auth/internal/casbin/manager.go b/backend/pkg/auth/internal/casbin/manager.go deleted file mode 100644 index d15926c..0000000 --- a/backend/pkg/auth/internal/casbin/manager.go +++ /dev/null @@ -1,61 +0,0 @@ -// package casbin - -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -// CasbinManager implements the auth.Manager interface by aggregating Role and Permission managers. -type CasbinManager struct { - logger mlogger.Logger - roleManager management.Role - permManager management.Permission -} - -// NewManager creates a new CasbinManager with specified domains and role-domain mappings. -func NewManager( - l mlogger.Logger, - pdb policy.DB, - rdb role.DB, - enforcer *CasbinEnforcer, - settings model.SettingsT, -) (*CasbinManager, error) { - logger := l.Named("manager") - - var pdesc model.PolicyDescription - if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { - logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) - return nil, err - } - - return &CasbinManager{ - logger: logger, - roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), - permManager: NewPermissionManager(logger, enforcer), - }, nil -} - -// Permission returns the Permission manager. -func (m *CasbinManager) Permission() management.Permission { - return m.permManager -} - -// Role returns the Role manager. -func (m *CasbinManager) Role() management.Role { - return m.roleManager -} - -// TransactionalGrants reports that casbin grants live in the enforcer's memory -// until Save rewrites the whole policy store: they neither respect a Mongo -// transaction context nor survive concurrent Save calls from other instances. -func (m *CasbinManager) TransactionalGrants() bool { - return false -} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go deleted file mode 100644 index 4427fa4..0000000 --- a/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go +++ /dev/null @@ -1,357 +0,0 @@ -package mongoadapter - -import ( - "context" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "time" - - "github.com/casbin/casbin/v3/model" - "github.com/casbin/casbin/v3/persist" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -const ( - defaultDatabaseName = "casbin" - defaultCollectionName = "casbin_rule" - defaultTimeout = 30 * time.Second -) - -var ( - _ persist.BatchAdapter = (*Adapter)(nil) - _ persist.FilteredAdapter = (*Adapter)(nil) - _ persist.UpdatableAdapter = (*Adapter)(nil) -) - -type Config struct { - DatabaseName string - CollectionName string - Timeout time.Duration - IsFiltered bool -} - -type Rule struct { - PType string - V0 string - V1 string - V2 string - V3 string - V4 string - V5 string -} - -type Adapter struct { - collection *mongo.Collection - timeout time.Duration - filtered bool -} - -func New(client *mongo.Client, config *Config) (*Adapter, error) { - if config == nil { - config = &Config{} - } - - databaseName := config.DatabaseName - if databaseName == "" { - databaseName = defaultDatabaseName - } - collectionName := config.CollectionName - if collectionName == "" { - collectionName = defaultCollectionName - } - timeout := config.Timeout - if timeout == 0 { - timeout = defaultTimeout - } - - adapter := &Adapter{ - collection: client.Database(databaseName).Collection(collectionName), - timeout: timeout, - filtered: config.IsFiltered, - } - if err := adapter.prepareIndexes(); err != nil { - return nil, err - } - return adapter, nil -} - -func (a *Adapter) prepareIndexes() error { - keys := bson.D{ - {Key: "ptype", Value: 1}, - {Key: "v0", Value: 1}, - {Key: "v1", Value: 1}, - {Key: "v2", Value: 1}, - {Key: "v3", Value: 1}, - {Key: "v4", Value: 1}, - {Key: "v5", Value: 1}, - } - - _, err := a.collection.Indexes().CreateOne( - context.Background(), - mongo.IndexModel{ - Keys: keys, - Options: options.Index().SetUnique(true), - }, - ) - return err -} - -func (a *Adapter) withTimeout() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), a.timeout) -} - -func loadPolicyLine(line Rule, m model.Model) error { - policy := line.policyWithType() - for len(policy) > 0 && policy[len(policy)-1] == "" { - policy = policy[:len(policy)-1] - } - if len(policy) == 0 { - return nil - } - return persist.LoadPolicyArray(policy, m) -} - -func (r Rule) policyWithType() []string { - return []string{r.PType, r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} -} - -func (r Rule) policyValues() []string { - values := []string{r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} - for len(values) > 0 && values[len(values)-1] == "" { - values = values[:len(values)-1] - } - return values -} - -func ruleFromPolicy(ptype string, policy []string) Rule { - rule := Rule{PType: ptype} - if len(policy) > 0 { - rule.V0 = policy[0] - } - if len(policy) > 1 { - rule.V1 = policy[1] - } - if len(policy) > 2 { - rule.V2 = policy[2] - } - if len(policy) > 3 { - rule.V3 = policy[3] - } - if len(policy) > 4 { - rule.V4 = policy[4] - } - if len(policy) > 5 { - rule.V5 = policy[5] - } - return rule -} - -func rulesFromPolicies(ptype string, policies [][]string) []Rule { - rules := make([]Rule, 0, len(policies)) - for _, policy := range policies { - rules = append(rules, ruleFromPolicy(ptype, policy)) - } - return rules -} - -func rulesAsDocuments(rules []Rule) []any { - docs := make([]any, 0, len(rules)) - for _, rule := range rules { - docs = append(docs, rule) - } - return docs -} - -func selectorForPolicy(ptype string, fieldIndex int, fieldValues ...string) bson.M { - selector := bson.M{"ptype": ptype} - fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"} - for i, fieldValue := range fieldValues { - if fieldValue == "" { - continue - } - field := fieldIndex + i - if field < 0 || field >= len(fields) { - continue - } - selector[fields[field]] = fieldValue - } - return selector -} - -func (a *Adapter) LoadPolicy(m model.Model) error { - return a.LoadFilteredPolicy(m, nil) -} - -func (a *Adapter) LoadFilteredPolicy(m model.Model, filter any) error { - if filter == nil { - a.filtered = false - filter = bson.D{} - } else { - a.filtered = true - } - - ctx, cancel := a.withTimeout() - defer cancel() - - cursor, err := a.collection.Find(ctx, filter) - if err != nil { - return err - } - defer func() { - _ = cursor.Close(ctx) - }() - - for cursor.Next(ctx) { - var line Rule - if err := cursor.Decode(&line); err != nil { - return err - } - if err := loadPolicyLine(line, m); err != nil { - return err - } - } - return cursor.Err() -} - -func (a *Adapter) IsFiltered() bool { - return a.filtered -} - -func (a *Adapter) SavePolicy(m model.Model) error { - if a.filtered { - return merrors.Define("cannot save a filtered policy") - } - - ctx, cancel := a.withTimeout() - defer cancel() - - if _, err := a.collection.DeleteMany(ctx, bson.D{}); err != nil { - return err - } - - var docs []any - for ptype, ast := range m["p"] { - docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) - } - for ptype, ast := range m["g"] { - docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) - } - if len(docs) == 0 { - return nil - } - - _, err := a.collection.InsertMany(ctx, docs) - return err -} - -func (a *Adapter) AddPolicy(_ string, ptype string, policy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.InsertOne(ctx, ruleFromPolicy(ptype, policy)) - return err -} - -func (a *Adapter) AddPolicies(_ string, ptype string, policies [][]string) error { - if len(policies) == 0 { - return nil - } - - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, policies))) - return err -} - -func (a *Adapter) RemovePolicy(_ string, ptype string, policy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.DeleteOne(ctx, ruleFromPolicy(ptype, policy)) - return err -} - -func (a *Adapter) RemovePolicies(_ string, ptype string, policies [][]string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - for _, rule := range rulesFromPolicies(ptype, policies) { - if _, err := a.collection.DeleteOne(ctx, rule); err != nil { - return err - } - } - return nil -} - -func (a *Adapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.DeleteMany(ctx, selectorForPolicy(ptype, fieldIndex, fieldValues...)) - return err -} - -func (a *Adapter) UpdatePolicy(_ string, ptype string, oldPolicy, newPolicy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicy), ruleFromPolicy(ptype, newPolicy)) - return err -} - -func (a *Adapter) UpdatePolicies(_ string, ptype string, oldPolicies, newPolicies [][]string) error { - if len(oldPolicies) != len(newPolicies) { - return merrors.Define("old and new policy counts differ") - } - - ctx, cancel := a.withTimeout() - defer cancel() - - for i := range oldPolicies { - if _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicies[i]), ruleFromPolicy(ptype, newPolicies[i])); err != nil { - return err - } - } - return nil -} - -func (a *Adapter) UpdateFilteredPolicies(_ string, ptype string, newPolicies [][]string, fieldIndex int, fieldValues ...string) ([][]string, error) { - ctx, cancel := a.withTimeout() - defer cancel() - - selector := selectorForPolicy(ptype, fieldIndex, fieldValues...) - cursor, err := a.collection.Find(ctx, selector) - if err != nil { - return nil, err - } - - var oldPolicies [][]string - for cursor.Next(ctx) { - var line Rule - if err := cursor.Decode(&line); err != nil { - _ = cursor.Close(ctx) - return nil, err - } - oldPolicies = append(oldPolicies, line.policyValues()) - } - if err := cursor.Close(ctx); err != nil { - return nil, err - } - if err := cursor.Err(); err != nil { - return nil, err - } - - if _, err := a.collection.DeleteMany(ctx, selector); err != nil { - return nil, err - } - if len(newPolicies) == 0 { - return oldPolicies, nil - } - - if _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, newPolicies))); err != nil { - return nil, err - } - return oldPolicies, nil -} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go deleted file mode 100644 index 36ea81d..0000000 --- a/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package mongoadapter - -import ( - "reflect" - "testing" - - "github.com/casbin/casbin/v3/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestRuleFromPolicyTrimsOnlyTrailingEmptyValues(t *testing.T) { - rule := ruleFromPolicy("p", []string{"role", "org", "", "object", ""}) - - if got, want := rule.PType, "p"; got != want { - t.Fatalf("PType = %q, want %q", got, want) - } - if got, want := rule.policyValues(), []string{"role", "org", "", "object"}; !reflect.DeepEqual(got, want) { - t.Fatalf("policyValues() = %#v, want %#v", got, want) - } -} - -func TestSelectorForPolicySkipsEmptyAndOutOfRangeFields(t *testing.T) { - got := selectorForPolicy("p", 1, "role", "", "object", "read", "ignored", "ignored") - want := bson.M{ - "ptype": "p", - "v1": "role", - "v3": "object", - "v4": "read", - "v5": "ignored", - } - - if !reflect.DeepEqual(got, want) { - t.Fatalf("selectorForPolicy() = %#v, want %#v", got, want) - } -} - -func TestLoadPolicyLineAddsPolicyToModel(t *testing.T) { - m, err := model.NewModelFromString(` -[request_definition] -r = sub, obj, act - -[policy_definition] -p = sub, obj, act - -[policy_effect] -e = some(where (p.eft == allow)) - -[matchers] -m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -`) - if err != nil { - t.Fatalf("NewModelFromString() error = %v", err) - } - - err = loadPolicyLine(Rule{PType: "p", V0: "alice", V1: "data1", V2: "read"}, m) - if err != nil { - t.Fatalf("loadPolicyLine() error = %v", err) - } - - got, err := m.GetPolicy("p", "p") - if err != nil { - t.Fatalf("GetPolicy() error = %v", err) - } - want := [][]string{{"alice", "data1", "read"}} - if !reflect.DeepEqual(got, want) { - t.Fatalf("GetPolicy() = %#v, want %#v", got, want) - } -} diff --git a/backend/pkg/auth/internal/casbin/permissions.go b/backend/pkg/auth/internal/casbin/permissions.go deleted file mode 100644 index a36f0a8..0000000 --- a/backend/pkg/auth/internal/casbin/permissions.go +++ /dev/null @@ -1,167 +0,0 @@ -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// CasbinPermissionManager manages permissions using Casbin. -type CasbinPermissionManager struct { - logger mlogger.Logger // Logger for logging operations - enforcer *CasbinEnforcer // Casbin enforcer for managing policies - serializer serialization.Policy // Serializer for converting policies to/from Casbin -} - -// GrantToRole adds a permission to a role in Casbin. -func (m *CasbinPermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := anyobject.ID - if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { - objRef = policy.ObjectRef.Hex() - } - - m.logger.Debug("Granting permission to role", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), - zap.String("effect", string(policy.Effect.Effect)), - ) - - // Serialize permission - serializedPolicy, err := m.serializer.Serialize(policy) - if err != nil { - m.logger.Error("Failed to serialize permission while granting permission", zap.Error(err), - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - mzap.OrgRef(policy.OrganizationRef), - ) - return err - } - - // Add policy to Casbin - added, err := m.enforcer.enforcer.AddPolicy(serializedPolicy...) - if err != nil { - m.logger.Error("Failed to add policy to Casbin", zap.Error(err)) - return err - } - if added { - m.logger.Info("Policy added to Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } else { - m.logger.Warn("Policy already exists in Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } - - return nil -} - -// RevokeFromRole removes a permission from a role in Casbin. -func (m *CasbinPermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := anyobject.ID - if policy.ObjectRef != nil { - objRef = policy.ObjectRef.Hex() - } - m.logger.Debug("Revoking permission from role", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), - zap.String("effect", string(policy.Effect.Effect)), - ) - - // Serialize policy - serializedPolicy, err := m.serializer.Serialize(policy) - if err != nil { - m.logger.Error("Failed to serialize policy while revoking permission from role", - zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.ObjRef("policy_ref", policy.DescriptionRef)) - return err - } - - // Remove policy from Casbin - removed, err := m.enforcer.enforcer.RemovePolicy(serializedPolicy...) - if err != nil { - m.logger.Error("Failed to remove policy from Casbin", zap.Error(err)) - return err - } - if removed { - m.logger.Info("Policy removed from Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } else { - m.logger.Warn("Policy does not exist in Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } - - return nil -} - -// GetPolicies retrieves all policies for a specific role. -func (m *CasbinPermissionManager) GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, -) ([]model.RolePolicy, error) { - m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) - - // Retrieve Casbin policies for the role - policies, err := m.enforcer.enforcer.GetFilteredPolicy(0, roleRef.Hex()) - if err != nil { - m.logger.Warn("Failed to get policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return nil, err - } - if len(policies) == 0 { - m.logger.Info("No policies found for role", mzap.ObjRef("role_ref", roleRef)) - return nil, merrors.NoData("no policies") - } - - // Deserialize policies - var result []model.RolePolicy - for _, policy := range policies { - permission, err := m.serializer.Deserialize(policy) - if err != nil { - m.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.String("policy", policy[0])) - continue - } - result = append(result, *permission) - } - - m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(result))) - return result, nil -} - -// Save persists changes to the Casbin policy store. -func (m *CasbinPermissionManager) Save() error { - if err := m.enforcer.enforcer.SavePolicy(); err != nil { - m.logger.Error("Failed to save policies in Casbin", zap.Error(err)) - return err - } - m.logger.Info("Policies successfully saved in Casbin") - return nil -} - -func NewPermissionManager(logger mlogger.Logger, enforcer *CasbinEnforcer) *CasbinPermissionManager { - return &CasbinPermissionManager{ - logger: logger.Named("permission"), - enforcer: enforcer, - serializer: serialization.NewPolicySerializer(), - } -} diff --git a/backend/pkg/auth/internal/casbin/role.go b/backend/pkg/auth/internal/casbin/role.go deleted file mode 100644 index 69f3919..0000000 --- a/backend/pkg/auth/internal/casbin/role.go +++ /dev/null @@ -1,209 +0,0 @@ -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// RoleManager manages roles using Casbin. -type RoleManager struct { - logger mlogger.Logger - enforcer *CasbinEnforcer - rdb role.DB - rolePermissionRef bson.ObjectID -} - -// NewRoleManager creates a new RoleManager. -func NewRoleManager(logger mlogger.Logger, enforcer *CasbinEnforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { - return &RoleManager{ - logger: logger.Named("role"), - enforcer: enforcer, - rdb: rdb, - rolePermissionRef: rolePermissionRef, - } -} - -// validateObjectIDs ensures that all provided ObjectIDs are non-zero. -func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { - for _, id := range ids { - if id.IsZero() { - return merrors.InvalidArgument("Object references cannot be zero", "objectRef") - } - } - return nil -} - -// removePolicies removes policies based on the provided filter and logs the results. -func (rm *RoleManager) removePolicies(policyType, role string, roleRef bson.ObjectID) error { - filterIndex := 1 - if policyType == "permission" { - filterIndex = 0 - } - policies, err := rm.enforcer.enforcer.GetFilteredPolicy(filterIndex, role) - if err != nil { - rm.logger.Warn("Failed to fetch "+policyType+" policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return err - } - - for _, policy := range policies { - args := make([]any, len(policy)) - for i, v := range policy { - args[i] = v - } - var removed bool - var removeErr error - if policyType == "grouping" { - removed, removeErr = rm.enforcer.enforcer.RemoveGroupingPolicy(args...) - } else { - removed, removeErr = rm.enforcer.enforcer.RemovePolicy(args...) - } - - if removeErr != nil { - rm.logger.Warn("Failed to remove "+policyType+" policy for role", zap.Error(removeErr), mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) - return removeErr - } - if removed { - rm.logger.Info("Removed "+policyType+" policy for role", mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) - } - } - return nil -} - -// fetchRolesFromPolicies retrieves and converts policies to roles. -func (rm *RoleManager) fetchRolesFromPolicies(policies [][]string, orgRef bson.ObjectID) []model.RoleDescription { - roles := make([]model.RoleDescription, 0, len(policies)) - for _, policy := range policies { - if len(policy) < 2 { - continue - } - - roleID, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - rm.logger.Warn("Invalid role ID", zap.String("roleID", policy[1])) - continue - } - roles = append(roles, model.RoleDescription{Base: storable.Base{ID: roleID}, OrganizationRef: orgRef}) - } - return roles -} - -// Create creates a new role in an organization. -func (rm *RoleManager) Create(ctx context.Context, orgRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { - if err := rm.validateObjectIDs(orgRef); err != nil { - return nil, err - } - - role := &model.RoleDescription{ - Describable: *description, - OrganizationRef: orgRef, - } - if err := rm.rdb.Create(ctx, role); err != nil { - rm.logger.Warn("Failed to create role", zap.Error(err), mzap.ObjRef("organiztion_ref", orgRef)) - return nil, err - } - - rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(orgRef)) - return role, nil -} - -// Assign assigns a role to a user in the given organization. -func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { - if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { - return err - } - - sub := role.AccountRef.Hex() - roleID := role.DescriptionRef.Hex() - domain := role.OrganizationRef.Hex() - - added, err := rm.enforcer.enforcer.AddGroupingPolicy(sub, roleID, domain) - return rm.logPolicyResult("assign", added, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) -} - -// Delete removes a role entirely and cleans up associated Casbin policies. -func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.rdb.Delete(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - role := roleRef.Hex() - - // Remove grouping policies - if err := rm.removePolicies("grouping", role, roleRef); err != nil { - return err - } - - // Remove permission policies - if err := rm.removePolicies("permission", role, roleRef); err != nil { - return err - } - - // // Save changes - // if err := rm.enforcer.enforcer.SavePolicy(); err != nil { - // rm.logger.Warn("Failed to save Casbin policies after role deletion", - // zap.Error(err), - // mzap.ObjRef("role_ref", roleRef), - // ) - // return err - // } - - rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) - return nil -} - -// Revoke removes a role from a user. -func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, orgRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef, accountRef, orgRef); err != nil { - return err - } - - sub := accountRef.Hex() - role := roleRef.Hex() - domain := orgRef.Hex() - - removed, err := rm.enforcer.enforcer.RemoveGroupingPolicy(sub, role, domain) - return rm.logPolicyResult("revoke", removed, err, roleRef, accountRef, orgRef) -} - -// logPolicyResult logs results for Assign and Revoke. -func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, orgRef bson.ObjectID) error { - if err != nil { - rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - return err - } - msg := "Role " + action + "ed successfully" - if !result { - msg = "Role already " + action + "ed" - } - rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - return nil -} - -// List retrieves all roles in an organization or all roles if orgRef is zero. -func (rm *RoleManager) List(ctx context.Context, orgRef bson.ObjectID) ([]model.RoleDescription, error) { - domain := orgRef.Hex() - groupingPolicies, err := rm.enforcer.enforcer.GetFilteredGroupingPolicy(2, domain) - if err != nil { - rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(orgRef)) - return nil, err - } - - roles := rm.fetchRolesFromPolicies(groupingPolicies, orgRef) - - rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(orgRef), zap.Int("count", len(roles))) - return roles, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/policy.go b/backend/pkg/auth/internal/casbin/serialization/internal/policy.go deleted file mode 100644 index a1096eb..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/internal/policy.go +++ /dev/null @@ -1,81 +0,0 @@ -package serializationimp - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// PolicySerializer implements CasbinSerializer for Permission. -type PolicySerializer struct{} - -// Serialize converts a Permission object into a Casbin policy. -func (s *PolicySerializer) Serialize(entity *model.RolePolicy) ([]any, error) { - if entity.RoleDescriptionRef.IsZero() || - entity.OrganizationRef.IsZero() || - entity.DescriptionRef.IsZero() || // Ensure permissionRef is valid - entity.Effect.Action == "" || // Ensure action is not empty - entity.Effect.Effect == "" { // Ensure effect (eft) is not empty - return nil, merrors.InvalidArgument("permission contains invalid object references or missing fields") - } - - objectRef := anyobject.ID - if entity.ObjectRef != nil { - objectRef = entity.ObjectRef.Hex() - } - - return []any{ - entity.RoleDescriptionRef.Hex(), // Maps to p.roleRef - entity.OrganizationRef.Hex(), // Maps to p.organizationRef - entity.DescriptionRef.Hex(), // Maps to p.permissionRef - objectRef, // Maps to p.objectRef (wildcard if empty) - string(entity.Effect.Action), // Maps to p.action - string(entity.Effect.Effect), // Maps to p.eft - }, nil -} - -// Deserialize converts a Casbin policy into a Permission object. -func (s *PolicySerializer) Deserialize(policy []string) (*model.RolePolicy, error) { - if len(policy) != 6 { // Ensure policy has the correct number of fields - return nil, merrors.Internal("invalid policy format") - } - - roleRef, err := bson.ObjectIDFromHex(policy[0]) - if err != nil { - return nil, merrors.InvalidArgument("invalid roleRef in policy") - } - - organizationRef, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - return nil, merrors.InvalidArgument("invalid organizationRef in policy") - } - - permissionRef, err := bson.ObjectIDFromHex(policy[2]) - if err != nil { - return nil, merrors.InvalidArgument("invalid permissionRef in policy") - } - - // Handle wildcard for ObjectRef - var objectRef *bson.ObjectID - if policy[3] != anyobject.ID { - ref, err := bson.ObjectIDFromHex(policy[3]) - if err != nil { - return nil, merrors.InvalidArgument("invalid objectRef in policy") - } - objectRef = &ref - } - - return &model.RolePolicy{ - RoleDescriptionRef: roleRef, - Policy: model.Policy{ - OrganizationRef: organizationRef, - DescriptionRef: permissionRef, - ObjectRef: objectRef, - Effect: model.ActionEffect{ - Action: model.Action(policy[4]), - Effect: model.Effect(policy[5]), - }, - }, - }, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/role.go b/backend/pkg/auth/internal/casbin/serialization/internal/role.go deleted file mode 100644 index c829235..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/internal/role.go +++ /dev/null @@ -1,57 +0,0 @@ -package serializationimp - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// RoleSerializer implements CasbinSerializer for Role. -type RoleSerializer struct{} - -// Serialize converts a Role object into a Casbin grouping policy. -func (s *RoleSerializer) Serialize(entity *model.Role) ([]any, error) { - // Validate required fields - if entity.AccountRef.IsZero() || entity.DescriptionRef.IsZero() || entity.OrganizationRef.IsZero() { - return nil, merrors.InvalidArgument("role contains invalid object references") - } - - return []any{ - entity.AccountRef.Hex(), // Maps to g(_, _, _) accountRef - entity.DescriptionRef.Hex(), // Maps to g(_, _, _) roleRef - entity.OrganizationRef.Hex(), // Maps to g(_, _, _) organizationRef - }, nil -} - -// Deserialize converts a Casbin grouping policy into a Role object. -func (s *RoleSerializer) Deserialize(policy []string) (*model.Role, error) { - // Ensure the policy has exactly 3 fields - if len(policy) != 3 { - return nil, merrors.Internal("invalid grouping policy format") - } - - // Parse accountRef - accountRef, err := bson.ObjectIDFromHex(policy[0]) - if err != nil { - return nil, merrors.InvalidArgument("invalid accountRef in grouping policy") - } - - // Parse roleDescriptionRef (roleRef) - roleDescriptionRef, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - return nil, merrors.InvalidArgument("invalid roleRef in grouping policy") - } - - // Parse organizationRef - organizationRef, err := bson.ObjectIDFromHex(policy[2]) - if err != nil { - return nil, merrors.InvalidArgument("invalid organizationRef in grouping policy") - } - - // Return the constructed Role object - return &model.Role{ - AccountRef: accountRef, - DescriptionRef: roleDescriptionRef, - OrganizationRef: organizationRef, - }, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/policy.go b/backend/pkg/auth/internal/casbin/serialization/policy.go deleted file mode 100644 index a6ce963..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/policy.go +++ /dev/null @@ -1,12 +0,0 @@ -package serialization - -import ( - serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type Policy = CasbinSerializer[model.RolePolicy] - -func NewPolicySerializer() Policy { - return &serializationimp.PolicySerializer{} -} diff --git a/backend/pkg/auth/internal/casbin/serialization/role.go b/backend/pkg/auth/internal/casbin/serialization/role.go deleted file mode 100644 index 4b04288..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/role.go +++ /dev/null @@ -1,12 +0,0 @@ -package serialization - -import ( - serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type Role = CasbinSerializer[model.Role] - -func NewRoleSerializer() Role { - return &serializationimp.RoleSerializer{} -} diff --git a/backend/pkg/auth/internal/casbin/serialization/serializer.go b/backend/pkg/auth/internal/casbin/serialization/serializer.go deleted file mode 100644 index 36d11db..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/serializer.go +++ /dev/null @@ -1,10 +0,0 @@ -package serialization - -// CasbinSerializer defines methods for serializing and deserializing any Casbin-compatible entity. -type CasbinSerializer[T any] interface { - // Serialize converts an entity (Role or Permission) into a Casbin policy. - Serialize(entity *T) ([]any, error) - - // Deserialize converts a Casbin policy into an entity (Role or Permission). - Deserialize(policy []string) (*T, error) -} diff --git a/backend/pkg/auth/internal/native/db/policies.go b/backend/pkg/auth/internal/native/db/policies.go deleted file mode 100644 index 3d9b16a..0000000 --- a/backend/pkg/auth/internal/native/db/policies.go +++ /dev/null @@ -1,151 +0,0 @@ -package db - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type PermissionsDBImp struct { - template.DBImp[*nstructures.PolicyAssignment] -} - -func (db *PermissionsDBImp) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("policy.organizationRef", object.GetOrganizationRef()), - repository.Filter("policy.descriptionRef", object.GetPermissionRef()), - repository.Filter("policy.effect.action", action), - repository.Query().Or( - repository.Filter("policy.objectRef", *object.GetID()), - repository.Filter("policy.objectRef", nil), - ), - ), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("roleRef", roleRef), - repository.Filter("policy.descriptionRef", permissionRef), - repository.Filter("policy.effect.action", action), - ), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) Remove(ctx context.Context, policy *model.RolePolicy) error { - objRefFilter := repository.Query().Or( - repository.Filter("policy.objectRef", nil), - repository.Filter("policy.objectRef", bson.NilObjectID), - ) - if policy.ObjectRef != nil { - objRefFilter = repository.Filter("policy.objectRef", *policy.ObjectRef) - } - return db.Repository.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("roleRef", policy.RoleDescriptionRef), - repository.Filter("policy.organizationRef", policy.OrganizationRef), - repository.Filter("policy.descriptionRef", policy.DescriptionRef), - objRefFilter, - repository.Filter("policy.effect.action", policy.Effect.Action), - repository.Filter("policy.effect.effect", policy.Effect.Effect), - ), - ) -} - -func (db *PermissionsDBImp) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Filter("roleRef", roleRef), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - if len(roleRefs) == 0 { - db.Logger.Debug("Empty role references list provided, returning empty resposnse") - return []nstructures.PolicyAssignment{}, nil - } - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Query().In(repository.Field("roleRef"), roleRefs), - repository.Filter("policy.effect.action", action), - ), - nil, - db.Repository, - ) -} - -func NewPoliciesDB(logger mlogger.Logger, db *mongo.Database) (*PermissionsDBImp, error) { - p := &PermissionsDBImp{ - DBImp: *template.Create[*nstructures.PolicyAssignment](logger, mservice.PolicyAssignements, db), - } - - // faster - // harder - // index - policiesQueryIndex := &ri.Definition{ - Keys: []ri.Key{ - {Field: "policy.organizationRef", Sort: ri.Asc}, - {Field: "policy.descriptionRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - {Field: "policy.objectRef", Sort: ri.Asc}, - }, - } - if err := p.Repository.CreateIndex(policiesQueryIndex); err != nil { - p.Logger.Warn("Failed to prepare policies query index", zap.Error(err)) - return nil, err - } - - roleBasedQueriesIndex := &ri.Definition{ - Keys: []ri.Key{ - {Field: "roleRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - }, - } - if err := p.Repository.CreateIndex(roleBasedQueriesIndex); err != nil { - p.Logger.Warn("Failed to prepare role based query index", zap.Error(err)) - return nil, err - } - - uniquePolicyConstaint := &ri.Definition{ - Keys: []ri.Key{ - {Field: "policy.organizationRef", Sort: ri.Asc}, - {Field: "roleRef", Sort: ri.Asc}, - {Field: "policy.descriptionRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - {Field: "policy.objectRef", Sort: ri.Asc}, - }, - Unique: true, - } - if err := p.Repository.CreateIndex(uniquePolicyConstaint); err != nil { - p.Logger.Warn("Failed to unique policy assignment index", zap.Error(err)) - return nil, err - } - - return p, nil -} diff --git a/backend/pkg/auth/internal/native/db/roles.go b/backend/pkg/auth/internal/native/db/roles.go deleted file mode 100644 index 1152a2a..0000000 --- a/backend/pkg/auth/internal/native/db/roles.go +++ /dev/null @@ -1,99 +0,0 @@ -package db - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type RolesDBImp struct { - template.DBImp[*nstructures.RoleAssignment] -} - -func (db *RolesDBImp) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - return mutil.GetObjects[nstructures.RoleAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("role.accountRef", accountRef), - repository.Filter("role.organizationRef", organizationRef), - ), - nil, - db.Repository, - ) -} - -func (db *RolesDBImp) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - return mutil.GetObjects[nstructures.RoleAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("role.organizationRef", organizationRef), - ), - nil, - db.Repository, - ) -} - -func (db *RolesDBImp) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { - return db.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("role.descriptionRef", roleRef), - ), - ) -} - -func (db *RolesDBImp) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { - return db.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("role.accountRef", accountRef), - repository.Filter("role.organizationRef", organizationRef), - repository.Filter("role.descriptionRef", roleRef), - ), - ) -} - -func NewRolesDB(logger mlogger.Logger, db *mongo.Database) (*RolesDBImp, error) { - p := &RolesDBImp{ - DBImp: *template.Create[*nstructures.RoleAssignment](logger, "role_assignments", db), - } - - if err := p.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "role.organizationRef", Sort: ri.Asc}}, - }); err != nil { - p.Logger.Warn("Failed to prepare venue index", zap.Error(err)) - return nil, err - } - - if err := p.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "role.descriptionRef", Sort: ri.Asc}}, - }); err != nil { - p.Logger.Warn("Failed to prepare role description index", zap.Error(err)) - return nil, err - } - - uniqueRoleConstaint := &ri.Definition{ - Keys: []ri.Key{ - {Field: "role.organizationRef", Sort: ri.Asc}, - {Field: "role.accountRef", Sort: ri.Asc}, - {Field: "role.descriptionRef", Sort: ri.Asc}, - }, - Unique: true, - } - if err := p.Repository.CreateIndex(uniqueRoleConstaint); err != nil { - p.Logger.Warn("Failed to prepare role assignment index", zap.Error(err)) - return nil, err - } - - return p, nil -} diff --git a/backend/pkg/auth/internal/native/dbpolicies.go b/backend/pkg/auth/internal/native/dbpolicies.go deleted file mode 100644 index bd4b3af..0000000 --- a/backend/pkg/auth/internal/native/dbpolicies.go +++ /dev/null @@ -1,27 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type PoliciesDB interface { - template.DB[*nstructures.PolicyAssignment] - // plenty of interfaces for performance reasons - Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) - PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) - PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) - PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) - Remove(ctx context.Context, policy *model.RolePolicy) error -} - -func NewPoliciesDBDB(logger mlogger.Logger, conn *mongo.Database) (PoliciesDB, error) { - return db.NewPoliciesDB(logger, conn) -} diff --git a/backend/pkg/auth/internal/native/dbroles.go b/backend/pkg/auth/internal/native/dbroles.go deleted file mode 100644 index a97ff9e..0000000 --- a/backend/pkg/auth/internal/native/dbroles.go +++ /dev/null @@ -1,24 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type RolesDB interface { - template.DB[*nstructures.RoleAssignment] - Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) - RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) - RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error - DeleteRole(ctx context.Context, roleRef bson.ObjectID) error -} - -func NewRolesDB(logger mlogger.Logger, conn *mongo.Database) (RolesDB, error) { - return db.NewRolesDB(logger, conn) -} diff --git a/backend/pkg/auth/internal/native/enforcer.go b/backend/pkg/auth/internal/native/enforcer.go deleted file mode 100644 index eaa728a..0000000 --- a/backend/pkg/auth/internal/native/enforcer.go +++ /dev/null @@ -1,282 +0,0 @@ -package native - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type Enforcer struct { - logger mlogger.Logger - pdb PoliciesDB - rdb RolesDB -} - -func NewEnforcer( - logger mlogger.Logger, - db *mongo.Database, -) (*Enforcer, error) { - e := &Enforcer{logger: logger.Named("enforcer")} - - var err error - if e.pdb, err = NewPoliciesDBDB(e.logger, db); err != nil { - e.logger.Warn("Failed to create permission assignments database", zap.Error(err)) - return nil, err - } - - if e.rdb, err = NewRolesDB(e.logger, db); err != nil { - e.logger.Warn("Failed to create role assignments database", zap.Error(err)) - return nil, err - } - - logger.Info("Native enforcer created") - return e, nil -} - -// Enforce checks if a user has the specified action permission on an object within a domain. -func (n *Enforcer) Enforce( - ctx context.Context, - permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, - action model.Action, -) (bool, error) { - if organizationRef.IsZero() { - n.logger.Warn("Missing organization context", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object", objectRef), zap.String("action", string(action))) - return false, merrors.InvalidArgument("organization context missing", "organizationRef") - } - roleAssignments, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles defined for account", mzap.AccRef(accountRef)) - return false, nil - } - if err != nil { - n.logger.Warn("Failed to fetch roles while checking permissions", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object", objectRef), zap.String("action", string(action))) - return false, err - } - if len(roleAssignments) == 0 { - n.logger.Warn("No roles found for account", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, merrors.Internal("No roles found for account " + accountRef.Hex()) - } - allowFound := false // Track if any allow is found across roles - - for _, roleAssignment := range roleAssignments { - policies, err := n.pdb.PoliciesForPermissionAction(ctx, roleAssignment.DescriptionRef, permissionRef, action) - if err != nil && !errors.Is(err, merrors.ErrNoData) { - n.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, err - } - - for _, permission := range policies { - if !policyAppliesToObject(permission.ObjectRef, objectRef) { - continue // Scoped to a different object; it says nothing about this one. - } - - if permission.Effect.Effect == model.EffectDeny { - n.logger.Debug("Found denying policy", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, nil // Deny takes precedence immediately - } - - if permission.Effect.Effect == model.EffectAllow { - n.logger.Debug("Allowing policy found", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - allowFound = true // At least one allow found - } else { - n.logger.Warn("Corrupted policy", mzap.StorableRef(&permission)) - return false, merrors.Internal("Corrupted action effect data for permissions entry " + permission.ID.Hex() + ": " + string(permission.Effect.Effect)) - } - } - } - - // Final decision based on whether any allow was found - if allowFound { - return true, nil // At least one allow and no deny - } - - n.logger.Debug("No allowing policy found", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - - return false, nil // No allow found, default deny -} - -// EnforceBatch checks a user’s permission for multiple objects at once. -// It returns a map from objectRef -> boolean indicating whether access is granted. -func (n *Enforcer) EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, -) (map[bson.ObjectID]bool, error) { - results := make(map[bson.ObjectID]bool, len(objectRefs)) - - // Group objectRefs by organizationRef. - objectsByVenue := make(map[bson.ObjectID][]model.PermissionBoundStorable) - for _, obj := range objectRefs { - organizationRef := obj.GetOrganizationRef() - objectsByVenue[organizationRef] = append(objectsByVenue[organizationRef], obj) - } - - // Process each venue group separately. - for organizationRef, objs := range objectsByVenue { - // 1. Fetch roles once for this account and venue. - roles, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if err != nil { - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles defined for account", zap.Error(err), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - // With no roles, mark all objects in this venue as denied. - for _, obj := range objs { - results[*obj.GetID()] = false - } - // Continue to next venue - continue - } - n.logger.Warn("Failed to fetch roles", zap.Error(err), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil, err - } - - // 2. Extract role description references - var roleRefs []bson.ObjectID - for _, role := range roles { - roleRefs = append(roleRefs, role.DescriptionRef) - } - - // 3. Fetch all policies for these roles and the given action in one call. - allPolicies, err := n.pdb.PoliciesForRoles(ctx, roleRefs, action) - if err != nil { - n.logger.Warn("Failed to fetch policies", zap.Error(err)) - return nil, err - } - - // 4. Build a lookup map keyed by PermissionRef. - policyMap := make(map[bson.ObjectID][]nstructures.PolicyAssignment) - for _, policy := range allPolicies { - policyMap[policy.DescriptionRef] = append(policyMap[policy.DescriptionRef], policy) - } - - // 5. Evaluate permissions for each object in this venue group. - for _, obj := range objs { - permRef := obj.GetPermissionRef() - allow := false - if policies, ok := policyMap[permRef]; ok { - for _, policy := range policies { - if !policyAppliesToObject(policy.ObjectRef, *obj.GetID()) { - continue // Scoped to a different object. - } - // Deny takes precedence. - if policy.Effect.Effect == model.EffectDeny { - allow = false - break - } - if policy.Effect.Effect == model.EffectAllow { - allow = true - // Continue checking in case a deny exists among policies. - } else { - // should never get here - return nil, merrors.Internal("Corrupted permissions effect in policy assignment '" + policy.GetID().Hex() + "': " + string(policy.Effect.Effect)) - } - } - } - results[*obj.GetID()] = allow - } - } - - return results, nil -} - -// GetRoles retrieves all roles assigned to the user within the domain. -func (n *Enforcer) GetRoles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, error) { - n.logger.Debug("Fetching roles for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - ra, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles assigned to user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return []model.Role{}, nil - } - if err != nil { - n.logger.Warn("Failed to fetch roles", zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil, err - } - - roles := make([]model.Role, len(ra)) - for i, roleAssignement := range ra { - roles[i] = roleAssignement.Role - } - - n.logger.Debug("Fetched roles", zap.Int("roles_count", len(roles))) - return roles, nil -} - -func (n *Enforcer) Reload() error { - n.logger.Info("Policies reloaded") // do nothing actually - return nil -} - -// GetPermissions retrieves all effective policies for the user within the domain. -func (n *Enforcer) GetPermissions(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, []model.Permission, error) { - n.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - - roles, err := n.GetRoles(ctx, accountRef, organizationRef) - if err != nil { - n.logger.Warn("Failed to get roles", zap.Error(err)) - return nil, nil, err - } - - uniquePermissions := make(map[bson.ObjectID]model.Permission) - for _, role := range roles { - perms, err := n.pdb.PoliciesForRole(ctx, role.DescriptionRef) - if err != nil { - n.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) - continue - } - n.logger.Debug("Policies fetched for role", mzap.ObjRef("role_ref", role.DescriptionRef), zap.Int("count", len(perms))) - for _, p := range perms { - uniquePermissions[*p.GetID()] = model.Permission{ - RolePolicy: model.RolePolicy{ - Policy: p.Policy, - RoleDescriptionRef: p.RoleRef, - }, - AccountRef: accountRef, - } - } - } - - permissionsSlice := make([]model.Permission, 0, len(uniquePermissions)) - for _, permission := range uniquePermissions { - permissionsSlice = append(permissionsSlice, permission) - } - - n.logger.Debug("Policies fetched successfully", zap.Int("count", len(permissionsSlice))) - return roles, permissionsSlice, nil -} - -// policyAppliesToObject reports whether a policy governs the object being -// checked. A policy without an object reference — nil or the zero value — is a -// wildcard covering every object of its resource type. A policy naming a -// specific object says nothing about any other one, so it is skipped rather -// than counted: otherwise an allow on one object would leak onto its siblings, -// and a deny on one object would block them. -func policyAppliesToObject(policyObjectRef *bson.ObjectID, objectRef bson.ObjectID) bool { - if policyObjectRef == nil || policyObjectRef.IsZero() { - return true - } - return *policyObjectRef == objectRef -} diff --git a/backend/pkg/auth/internal/native/enforcer_test.go b/backend/pkg/auth/internal/native/enforcer_test.go deleted file mode 100644 index 6dc8c2b..0000000 --- a/backend/pkg/auth/internal/native/enforcer_test.go +++ /dev/null @@ -1,746 +0,0 @@ -package native - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Mock implementations for testing -type MockPoliciesDB struct { - mock.Mock -} - -func (m *MockPoliciesDB) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRef, permissionRef, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRef) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRefs, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, object, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Remove(ctx context.Context, policy *model.RolePolicy) error { - args := m.Called(ctx, policy) - return args.Error(0) -} - -// Template DB methods - implement as needed for testing -func (m *MockPoliciesDB) Create(ctx context.Context, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, id, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Update(ctx context.Context, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { - args := m.Called(ctx, objectRef, patch) - return args.Error(0) -} - -func (m *MockPoliciesDB) Delete(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockPoliciesDB) DeleteMany(ctx context.Context, query builder.Query) error { - args := m.Called(ctx, query) - return args.Error(0) -} - -func (m *MockPoliciesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { - args := m.Called(ctx, query) - return args.Get(0).([]bson.ObjectID), args.Error(1) -} - -func (m *MockPoliciesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, query, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) List(ctx context.Context, query builder.Query) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, query) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Name() string { - return "mock_policies" -} - -func (m *MockPoliciesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockPoliciesDB) InsertMany(ctx context.Context, objects []*nstructures.PolicyAssignment) error { - args := m.Called(ctx, objects) - return args.Error(0) -} - -type MockRolesDB struct { - mock.Mock -} - -func (m *MockRolesDB) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { - args := m.Called(ctx, roleRef, organizationRef, accountRef) - return args.Error(0) -} - -func (m *MockRolesDB) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { - args := m.Called(ctx, roleRef) - return args.Error(0) -} - -// Template DB methods - implement as needed for testing -func (m *MockRolesDB) Create(ctx context.Context, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, id, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Update(ctx context.Context, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { - args := m.Called(ctx, objectRef, patch) - return args.Error(0) -} - -func (m *MockRolesDB) Delete(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockRolesDB) DeleteMany(ctx context.Context, query builder.Query) error { - args := m.Called(ctx, query) - return args.Error(0) -} - -func (m *MockRolesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { - args := m.Called(ctx, query) - return args.Get(0).([]bson.ObjectID), args.Error(1) -} - -func (m *MockRolesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, query, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) List(ctx context.Context, query builder.Query) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, query) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) Name() string { - return "mock_roles" -} - -func (m *MockRolesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockRolesDB) InsertMany(ctx context.Context, objects []*nstructures.RoleAssignment) error { - args := m.Called(ctx, objects) - return args.Error(0) -} - -// Test helper functions -func createTestObjectID() bson.ObjectID { - return bson.NewObjectID() -} - -func createTestRoleAssignment(roleRef, accountRef, organizationRef bson.ObjectID) nstructures.RoleAssignment { - return nstructures.RoleAssignment{ - Role: model.Role{ - AccountRef: accountRef, - DescriptionRef: roleRef, - OrganizationRef: organizationRef, - }, - } -} - -func createTestPolicyAssignment(roleRef bson.ObjectID, effect model.Effect, organizationRef, descriptionRef bson.ObjectID, objectRef *bson.ObjectID) nstructures.PolicyAssignment { - return nstructures.PolicyAssignment{ - Policy: model.Policy{ - OrganizationRef: organizationRef, - DescriptionRef: descriptionRef, - ObjectRef: objectRef, - Effect: model.ActionEffect{ - Action: model.ActionRead, - Effect: effect, - }, - }, - RoleRef: roleRef, - } -} - -func createTestEnforcer(pdb PoliciesDB, rdb RolesDB) *Enforcer { - logger := factory.NewLogger(true) - enforcer := &Enforcer{ - logger: logger.Named("test"), - pdb: pdb, - rdb: rdb, - } - return enforcer -} - -func TestEnforcer_Enforce(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - objectRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("Allow_SingleRole_SinglePolicy", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with ALLOW effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - // Create enforcer - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.True(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("Deny_SingleRole_SinglePolicy", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with DENY effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("DenyTakesPrecedence_MultipleRoles", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - role1Ref := createTestObjectID() - role2Ref := createTestObjectID() - - // Mock multiple role assignments - roleAssignment1 := createTestRoleAssignment(role1Ref, accountRef, organizationRef) - roleAssignment2 := createTestRoleAssignment(role2Ref, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment1, roleAssignment2}, nil) - - // First role has ALLOW policy - allowPolicy := createTestPolicyAssignment(role1Ref, model.EffectAllow, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, role1Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy}, nil) - - // Second role has DENY policy - should take precedence - denyPolicy := createTestPolicyAssignment(role2Ref, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, role2Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{denyPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - DENY should take precedence - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("NoRoles_ReturnsFalse", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - }) - - t.Run("EmptyRoles_ReturnsError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock empty roles list (not NoData error) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Contains(t, err.Error(), "No roles found for account") - mockRDB.AssertExpectations(t) - }) - - t.Run("DatabaseError_RolesDB", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error - dbError := merrors.Define("database connection failed") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - }) - - t.Run("DatabaseError_PoliciesDB", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock database error in policies - dbError := merrors.Define("policies database error") - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("NoPolicies_ReturnsFalse", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock no policies found - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("CorruptedPolicy_ReturnsError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock corrupted policy with invalid effect - corruptedPolicy := createTestPolicyAssignment(roleRef, "invalid_effect", organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{corruptedPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Contains(t, err.Error(), "Corrupted action effect data") - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) -} - -// Mock implementation for PermissionBoundStorable -type MockPermissionBoundStorable struct { - id bson.ObjectID - permissionRef bson.ObjectID - organizationRef bson.ObjectID -} - -func (m *MockPermissionBoundStorable) GetID() *bson.ObjectID { - return &m.id -} - -func (m *MockPermissionBoundStorable) GetPermissionRef() bson.ObjectID { - return m.permissionRef -} - -func (m *MockPermissionBoundStorable) GetOrganizationRef() bson.ObjectID { - return m.organizationRef -} - -func (m *MockPermissionBoundStorable) Collection() string { - return "test_objects" -} - -func (m *MockPermissionBoundStorable) SetID(objID bson.ObjectID) { - m.id = objID -} - -func (m *MockPermissionBoundStorable) Update() { - // Do nothing for mock -} - -func (m *MockPermissionBoundStorable) SetPermissionRef(permissionRef bson.ObjectID) { - m.permissionRef = permissionRef -} - -func (m *MockPermissionBoundStorable) SetOrganizationRef(organizationRef bson.ObjectID) { - m.organizationRef = organizationRef -} - -func (m *MockPermissionBoundStorable) IsArchived() bool { - return false // Default to not archived for testing -} - -func (m *MockPermissionBoundStorable) SetArchived(archived bool) { - // No-op for testing -} - -func TestEnforcer_EnforceBatch(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - roleRef := createTestObjectID() - - // Create test objects - object1 := &MockPermissionBoundStorable{ - id: createTestObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - object2 := &MockPermissionBoundStorable{ - id: createTestObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - - t.Run("BatchEnforce_MultipleObjects_SameVenue", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with ALLOW effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil) - mockPDB.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.Len(t, results, 2) - assert.True(t, results[object1.id]) - assert.True(t, results[object2.id]) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("BatchEnforce_NoRoles_AllObjectsDenied", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.Len(t, results, 2) - assert.False(t, results[object1.id]) - assert.False(t, results[object2.id]) - mockRDB.AssertExpectations(t) - }) - - t.Run("BatchEnforce_DatabaseError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error - dbError := merrors.Define("database connection failed") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.Nil(t, results) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - }) -} - -func TestEnforcer_GetRoles(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("GetRoles_Success", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 1) - assert.Equal(t, roleRef, roles[0].DescriptionRef) - mockRDB.AssertExpectations(t) - }) - - t.Run("GetRoles_NoRoles", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 0) - mockRDB.AssertExpectations(t) - }) -} - -func TestEnforcer_GetPermissions(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("GetPermissions_Success", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, createTestObjectID(), nil) - mockPDB.On("PoliciesForRole", ctx, roleRef).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, permissions, err := enforcer.GetPermissions(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 1) - assert.Len(t, permissions, 1) - assert.Equal(t, accountRef, permissions[0].AccountRef) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) -} - -// Security-focused test scenarios -func TestEnforcer_SecurityScenarios(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - objectRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("Security_DenyAlwaysWins", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock multiple policies: both ALLOW and DENY - allowPolicy := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) - denyPolicy := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy, denyPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - DENY should always win - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("Security_InvalidObjectID", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error for invalid ObjectID - dbError := merrors.Define("invalid ObjectID") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute with invalid ObjectID - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - should fail securely - require.Error(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - }) -} - -// Note: This test provides comprehensive coverage of the native enforcer including: -// 1. Basic enforcement logic with deny-takes-precedence -// 2. Batch operations for performance -// 3. Role and permission retrieval -// 4. Security scenarios and edge cases -// 5. Error handling and database failures -// 6. All critical security paths are tested diff --git a/backend/pkg/auth/internal/native/manager.go b/backend/pkg/auth/internal/native/manager.go deleted file mode 100644 index 523ab5d..0000000 --- a/backend/pkg/auth/internal/native/manager.go +++ /dev/null @@ -1,57 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -// NativeManager implements the auth.Manager interface by aggregating Role and Permission managers. -type NativeManager struct { - logger mlogger.Logger - roleManager management.Role - permManager management.Permission -} - -// NewManager creates a new CasbinManager with specified domains and role-domain mappings. -func NewManager( - l mlogger.Logger, - pdb policy.DB, - rdb role.DB, - enforcer *Enforcer, -) (*NativeManager, error) { - logger := l.Named("manager") - - var pdesc model.PolicyDescription - if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { - logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) - return nil, err - } - - return &NativeManager{ - logger: logger, - roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), - permManager: NewPermissionManager(logger, enforcer), - }, nil -} - -// Permission returns the Permission manager. -func (m *NativeManager) Permission() management.Permission { - return m.permManager -} - -// Role returns the Role manager. -func (m *NativeManager) Role() management.Role { - return m.roleManager -} - -// TransactionalGrants reports that native grants are plain database writes: -// they join the caller's transaction context and roll back with it. -func (m *NativeManager) TransactionalGrants() bool { - return true -} diff --git a/backend/pkg/auth/internal/native/nstructures/policies.go b/backend/pkg/auth/internal/native/nstructures/policies.go deleted file mode 100644 index 43c9aa5..0000000 --- a/backend/pkg/auth/internal/native/nstructures/policies.go +++ /dev/null @@ -1,17 +0,0 @@ -package nstructures - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PolicyAssignment struct { - storable.Base `bson:",inline" json:",inline"` - model.Policy `bson:"policy" json:"policy"` - RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` -} - -func (*PolicyAssignment) Collection() string { - return "permission_assignments" -} diff --git a/backend/pkg/auth/internal/native/nstructures/role.go b/backend/pkg/auth/internal/native/nstructures/role.go deleted file mode 100644 index ee2cc68..0000000 --- a/backend/pkg/auth/internal/native/nstructures/role.go +++ /dev/null @@ -1,15 +0,0 @@ -package nstructures - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type RoleAssignment struct { - storable.Base `bson:",inline" json:",inline"` - model.Role `bson:"role" json:"role"` -} - -func (*RoleAssignment) Collection() string { - return "role_assignments" -} diff --git a/backend/pkg/auth/internal/native/object_scope_test.go b/backend/pkg/auth/internal/native/object_scope_test.go deleted file mode 100644 index b04048e..0000000 --- a/backend/pkg/auth/internal/native/object_scope_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package native - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestEnforcer_EnforceHonorsObjectScope(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - organizationRef := bson.NewObjectID() - permissionRef := bson.NewObjectID() - roleRef := bson.NewObjectID() - objectA := bson.NewObjectID() - objectB := bson.NewObjectID() - wildcard := bson.NilObjectID - - tests := []struct { - name string - policies []nstructures.PolicyAssignment - objectRef bson.ObjectID - wantAccess bool - }{ - { - name: "matching object allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - }, - objectRef: objectA, - wantAccess: true, - }, - { - name: "different object does not inherit allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - }, - objectRef: objectB, - wantAccess: false, - }, - { - name: "nil object is wildcard", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "zero object is wildcard", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &wildcard), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "unrelated object deny does not override wildcard allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "matching object deny overrides wildcard allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), - }, - objectRef: objectA, - wantAccess: false, - }, - { - name: "wildcard deny overrides matching object allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, nil), - }, - objectRef: objectA, - wantAccess: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - roles := &MockRolesDB{} - policies := &MockPoliciesDB{} - role := createTestRoleAssignment(roleRef, accountRef, organizationRef) - roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) - policies.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return(test.policies, nil) - - allowed, err := createTestEnforcer(policies, roles).Enforce( - ctx, - permissionRef, - accountRef, - organizationRef, - test.objectRef, - model.ActionRead, - ) - - require.NoError(t, err) - require.Equal(t, test.wantAccess, allowed) - roles.AssertExpectations(t) - policies.AssertExpectations(t) - }) - } -} - -func TestEnforcer_EnforceBatchHonorsObjectScope(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - organizationRef := bson.NewObjectID() - permissionRef := bson.NewObjectID() - roleRef := bson.NewObjectID() - objectA := &MockPermissionBoundStorable{ - id: bson.NewObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - objectB := &MockPermissionBoundStorable{ - id: bson.NewObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - - tests := []struct { - name string - policies []nstructures.PolicyAssignment - want map[bson.ObjectID]bool - }{ - { - name: "specific allow applies only to matching object", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA.id), - }, - want: map[bson.ObjectID]bool{objectA.id: true, objectB.id: false}, - }, - { - name: "specific deny affects only matching object", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA.id), - }, - want: map[bson.ObjectID]bool{objectA.id: false, objectB.id: true}, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - roles := &MockRolesDB{} - policies := &MockPoliciesDB{} - role := createTestRoleAssignment(roleRef, accountRef, organizationRef) - roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) - policies.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return(test.policies, nil) - - result, err := createTestEnforcer(policies, roles).EnforceBatch( - ctx, - []model.PermissionBoundStorable{objectA, objectB}, - accountRef, - model.ActionRead, - ) - - require.NoError(t, err) - require.Equal(t, test.want, result) - roles.AssertExpectations(t) - policies.AssertExpectations(t) - }) - } -} diff --git a/backend/pkg/auth/internal/native/permission.go b/backend/pkg/auth/internal/native/permission.go deleted file mode 100644 index ed7fa45..0000000 --- a/backend/pkg/auth/internal/native/permission.go +++ /dev/null @@ -1,101 +0,0 @@ -package native - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// PermissionManager manages permissions using Casbin. -type PermissionManager struct { - logger mlogger.Logger - enforcer *Enforcer -} - -// GrantToRole adds a permission to a role in Casbin. -func (m *PermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := "any" - if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { - objRef = policy.ObjectRef.Hex() - } - - m.logger.Debug("Granting permission to role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), - ) - - assignment := nstructures.PolicyAssignment{ - Policy: policy.Policy, - RoleRef: policy.RoleDescriptionRef, - } - if err := m.enforcer.pdb.Create(ctx, &assignment); err != nil { - m.logger.Warn("Failed to grant policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) - return err - } - return nil -} - -// RevokeFromRole removes a permission from a role in Casbin. -func (m *PermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := "*" - if policy.ObjectRef != nil { - objRef = policy.ObjectRef.Hex() - } - m.logger.Debug("Revoking permission from role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), - ) - if err := m.enforcer.pdb.Remove(ctx, policy); err != nil { - m.logger.Warn("Failed to revoke policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) - return err - } - - return nil -} - -// GetPolicies retrieves all policies for a specific role. -func (m *PermissionManager) GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, -) ([]model.RolePolicy, error) { - m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) - - assinments, err := m.enforcer.pdb.PoliciesForRole(ctx, roleRef) - if errors.Is(err, merrors.ErrNoData) { - m.logger.Debug("No policies found", mzap.ObjRef("role_ref", roleRef)) - return []model.RolePolicy{}, nil - } - policies := make([]model.RolePolicy, len(assinments)) - for i, assinment := range assinments { - policies[i] = model.RolePolicy{ - Policy: assinment.Policy, - RoleDescriptionRef: assinment.RoleRef, - } - } - m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(policies))) - return policies, nil -} - -// Save persists changes to the Casbin policy store. -func (m *PermissionManager) Save() error { - m.logger.Info("Policies successfully saved") // do nothing - return nil -} - -func NewPermissionManager(logger mlogger.Logger, enforcer *Enforcer) *PermissionManager { - return &PermissionManager{ - logger: logger.Named("permission"), - enforcer: enforcer, - } -} diff --git a/backend/pkg/auth/internal/native/role.go b/backend/pkg/auth/internal/native/role.go deleted file mode 100644 index d7599aa..0000000 --- a/backend/pkg/auth/internal/native/role.go +++ /dev/null @@ -1,142 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// RoleManager manages roles using Casbin. -type RoleManager struct { - logger mlogger.Logger - enforcer *Enforcer - rdb role.DB - rolePermissionRef bson.ObjectID -} - -// NewRoleManager creates a new RoleManager. -func NewRoleManager(logger mlogger.Logger, enforcer *Enforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { - return &RoleManager{ - logger: logger.Named("role"), - enforcer: enforcer, - rdb: rdb, - rolePermissionRef: rolePermissionRef, - } -} - -// validateObjectIDs ensures that all provided ObjectIDs are non-zero. -func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { - for _, id := range ids { - if id.IsZero() { - return merrors.InvalidArgument("Object references cannot be zero", "objectRef") - } - } - return nil -} - -// fetchRolesFromPolicies retrieves and converts policies to roles. -func (rm *RoleManager) fetchRolesFromPolicies(roles []nstructures.RoleAssignment, organizationRef bson.ObjectID) []model.RoleDescription { - result := make([]model.RoleDescription, len(roles)) - for i, role := range roles { - result[i] = model.RoleDescription{ - Base: storable.Base{ID: *role.GetID()}, - OrganizationRef: organizationRef, - } - } - return result -} - -// Create creates a new role in an organization. -func (rm *RoleManager) Create(ctx context.Context, organizationRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { - if err := rm.validateObjectIDs(organizationRef); err != nil { - return nil, err - } - - role := &model.RoleDescription{ - OrganizationRef: organizationRef, - Describable: *description, - } - if err := rm.rdb.Create(ctx, role); err != nil { - rm.logger.Warn("Failed to create role", zap.Error(err), mzap.OrgRef(organizationRef)) - return nil, err - } - - rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(organizationRef)) - return role, nil -} - -// Assign assigns a role to a user in the given organization. -func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { - if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { - return err - } - assogment := nstructures.RoleAssignment{Role: *role} - err := rm.enforcer.rdb.Create(ctx, &assogment) - return rm.logPolicyResult("assign", err == nil, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) -} - -// Delete removes a role entirely and cleans up associated Casbin policies. -func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.rdb.Delete(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.enforcer.rdb.DeleteRole(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to remove role", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return err - } - - rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) - return nil -} - -// Revoke removes a role from a user. -func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, organizationRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef, accountRef, organizationRef); err != nil { - return err - } - - err := rm.enforcer.rdb.RemoveRole(ctx, roleRef, organizationRef, accountRef) - return rm.logPolicyResult("revoke", err == nil, err, roleRef, accountRef, organizationRef) -} - -// logPolicyResult logs results for Assign and Revoke. -func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, organizationRef bson.ObjectID) error { - if err != nil { - rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - msg := "Role " + action + "ed successfully" - if !result { - msg = "Role already " + action + "ed" - } - rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil -} - -// List retrieves all roles in an organization or all roles if organizationRef is zero. -func (rm *RoleManager) List(ctx context.Context, organizationRef bson.ObjectID) ([]model.RoleDescription, error) { - roles4Venues, err := rm.enforcer.rdb.RolesForVenue(ctx, organizationRef) - if err != nil { - rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(organizationRef)) - return nil, err - } - - roles := rm.fetchRolesFromPolicies(roles4Venues, organizationRef) - rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(organizationRef), zap.Int("count", len(roles))) - return roles, nil -} diff --git a/backend/pkg/auth/management/permission.go b/backend/pkg/auth/management/permission.go deleted file mode 100644 index 4721d20..0000000 --- a/backend/pkg/auth/management/permission.go +++ /dev/null @@ -1,27 +0,0 @@ -package management - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Permission interface { - // Grant a permission to a role with an optional object scope and specified effect. - // Use bson.NilObjectID for 'any' objectRef. - GrantToRole(ctx context.Context, policy *model.RolePolicy) error - - // Revoke a permission from a role with an optional object scope and specified effect. - // Use bson.NilObjectID for 'any' objectRef. - RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error - - // Retrieve all policies assigned to a specific role, including scope and effects. - GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, - ) ([]model.RolePolicy, error) - - // Persist any changes made to permissions. - Save() error -} diff --git a/backend/pkg/auth/management/role.go b/backend/pkg/auth/management/role.go deleted file mode 100644 index 8a1709b..0000000 --- a/backend/pkg/auth/management/role.go +++ /dev/null @@ -1,41 +0,0 @@ -package management - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Role interface { - // Create a new role in an organization (returns the created Role with its ID). - Create( - ctx context.Context, - orgRef bson.ObjectID, - description *model.Describable, - ) (*model.RoleDescription, error) - - // Delete a role entirely. This will cascade and remove all associated - Delete( - ctx context.Context, - roleRef bson.ObjectID, - ) error - - // Assign a role to a user in a specific organization. - Assign( - ctx context.Context, - role *model.Role, - ) error - - // Revoke a role from a user in a specific organization. - Revoke( - ctx context.Context, - roleRef, accountRef, orgRef bson.ObjectID, - ) error - - // List all roles in an organization or globally if orgRef is bson.NilObjectID. - List( - ctx context.Context, - orgRef bson.ObjectID, - ) ([]model.RoleDescription, error) -} diff --git a/backend/pkg/auth/manager.go b/backend/pkg/auth/manager.go deleted file mode 100644 index 7b678a8..0000000 --- a/backend/pkg/auth/manager.go +++ /dev/null @@ -1,15 +0,0 @@ -package auth - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" -) - -// Manager provides access to domain-aware Permission and Role managers. -type Manager interface { - // Permission returns a manager that handles permission grants/revokes - // for a specific resource type. (You might add domainRef here if desired.) - Permission() management.Permission - - // Role returns the domain-aware Role manager. - Role() management.Role -} diff --git a/backend/pkg/auth/patchguard.go b/backend/pkg/auth/patchguard.go deleted file mode 100644 index 360e86b..0000000 --- a/backend/pkg/auth/patchguard.go +++ /dev/null @@ -1,30 +0,0 @@ -package auth - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// denyImmutablePatch returns ErrInvalidArg if patch touches any field whose -// mutation would silently undermine future access decisions. -func denyImmutablePatch(patch builder.Patch) error { - if patch == nil { - return nil - } - for _, op := range patch.Build() { - fields, ok := op.Value.(bson.D) - if !ok { - continue - } - for _, f := range fields { - if f.Key == "owner" || strings.HasPrefix(f.Key, "owner.") || - f.Key == "organizationRef" || f.Key == "permissionRef" { - return merrors.InvalidArgumentf("patch field %q is immutable", f.Key) - } - } - } - return nil -} diff --git a/backend/pkg/auth/provider.go b/backend/pkg/auth/provider.go deleted file mode 100644 index c775f5c..0000000 --- a/backend/pkg/auth/provider.go +++ /dev/null @@ -1,14 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -type Provider interface { - Enforcer() Enforcer - Manager() Manager - GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) -} diff --git a/backend/pkg/auth/scoped.go b/backend/pkg/auth/scoped.go deleted file mode 100644 index 043bd48..0000000 --- a/backend/pkg/auth/scoped.go +++ /dev/null @@ -1,71 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ScopedAccessParams bundles everything needed to authorize an action against a -// resource that follows the personal-vs-org ownership model. -// -// It is deliberately a plain struct (not an interface over a model) so it works -// both for locally-stored objects and for objects whose ownership is learned -// from a downstream service response. -type ScopedAccessParams struct { - // Service is the resource type, used only to build the access-denied error. - Service mservice.Type - // PolicyRef is the org policy consulted when the object is org-owned. - PolicyRef bson.ObjectID - // CallerRef is the subject taken from the auth token. Never from the request body. - CallerRef bson.ObjectID - // OrgRef is the organization the object belongs to. For org-owned objects - // caller membership in it is checked before policy enforcement. Personal - // objects are deliberately exempt from the membership check so the owner - // keeps access after leaving the organization; membership for personal - // objects is enforced once, at create time (see ScopedDBImp.Create). - OrgRef bson.ObjectID - // ObjectRef is the concrete object being accessed. Passing it enables - // object-level checks in the Enforcer; use bson.NilObjectID for scope-only. - ObjectRef bson.ObjectID - // OwnerKind / OwnerRef describe the object's actual ownership, read FROM the - // object — not derived from the request. - OwnerKind model.OwnerKind - OwnerRef bson.ObjectID - // Action is the operation being authorized. - Action model.Action -} - -// enforceOwnershipAccess decides access based on ownership and policy only. -// It does NOT check organization membership — the caller must verify membership -// before invoking this. Use ScopedAuthorizer.Authorize for the full -// membership-aware access check. -// -// - personal (OwnerKindAccount): allowed iff OwnerRef == CallerRef. -// - org (OwnerKindOrganization): allowed iff the Enforcer grants Action within OrgRef. -// - anything else: denied by default. -func enforceOwnershipAccess(ctx context.Context, enf Enforcer, p ScopedAccessParams) error { - switch p.OwnerKind { - case model.OwnerKindAccount: - if p.OwnerRef == p.CallerRef { - return nil - } - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - - case model.OwnerKindOrganization: - ok, err := enf.Enforce(ctx, p.PolicyRef, p.CallerRef, p.OrgRef, p.ObjectRef, p.Action) - if err != nil { - return err - } - if !ok { - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } - return nil - - default: - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } -} diff --git a/backend/pkg/auth/scoped_test.go b/backend/pkg/auth/scoped_test.go deleted file mode 100644 index c9ee79d..0000000 --- a/backend/pkg/auth/scoped_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// fakeEnforcer records the last call and returns canned results, so the scoped -// access rules can be exercised without a real policy backend. -type fakeEnforcer struct { - allow bool - err error - called bool - - gotPolicyRef bson.ObjectID - gotAccount bson.ObjectID - gotOrg bson.ObjectID - gotObject bson.ObjectID - gotAction model.Action -} - -func (f *fakeEnforcer) Enforce(_ context.Context, permissionRef, accountRef, orgRef, objectRef bson.ObjectID, action model.Action) (bool, error) { - f.called = true - f.gotPolicyRef, f.gotAccount, f.gotOrg, f.gotObject, f.gotAction = permissionRef, accountRef, orgRef, objectRef, action - return f.allow, f.err -} - -func (f *fakeEnforcer) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) { - return map[bson.ObjectID]bool{}, nil -} - -func (f *fakeEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { - return nil, nil -} - -func (f *fakeEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { - return nil, nil, nil -} - -func TestEnforceOwnershipAccess(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - org := bson.NewObjectID() - object := bson.NewObjectID() - policy := bson.NewObjectID() - - base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { - return ScopedAccessParams{ - Service: mservice.Recipients, - PolicyRef: policy, - CallerRef: caller, - OrgRef: org, - ObjectRef: object, - OwnerKind: kind, - OwnerRef: owner, - Action: model.ActionRead, - } - } - - t.Run("personal owner is allowed without policy", func(t *testing.T) { - enf := &fakeEnforcer{} - if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } - }) - - t.Run("personal non-owner is denied without policy", func(t *testing.T) { - enf := &fakeEnforcer{} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, other)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } - }) - - t.Run("org owner consults policy and passes object ref", func(t *testing.T) { - enf := &fakeEnforcer{allow: true} - if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } - if enf.gotObject != object || enf.gotOrg != org || enf.gotAccount != caller || enf.gotPolicyRef != policy { - t.Fatalf("enforcer received unexpected args: %+v", enf) - } - }) - - t.Run("org owner denied when policy refuses", func(t *testing.T) { - enf := &fakeEnforcer{allow: false} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("org policy error is propagated, not masked as denial", func(t *testing.T) { - boom := merrors.Define("backend down") - enf := &fakeEnforcer{err: boom} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("internal error must not be reported as access denied") - } - }) - - t.Run("unknown owner kind is denied by default", func(t *testing.T) { - enf := &fakeEnforcer{allow: true} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKind("garbage"), caller)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("unknown kind must not consult the enforcer") - } - }) -} diff --git a/backend/pkg/auth/scopeddb.go b/backend/pkg/auth/scopeddb.go deleted file mode 100644 index 860c14a..0000000 --- a/backend/pkg/auth/scopeddb.go +++ /dev/null @@ -1,129 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ownerLoader is the minimal DB contract that ScopedDB needs: fetch a resource -// by ID and populate the caller-supplied result. *template.DBImp[T] satisfies -// this interface without any adapter code. -type ownerLoader[T any] interface { - Get(ctx context.Context, objectRef bson.ObjectID, result T) error -} - -// ScopedDB[T] provides loadAndEnforce semantics for resources that implement -// model.OwnerBound. It loads the resource from the database (so ownership is -// always read from the authoritative source, never from request fields) and -// then delegates access control to ScopedAuthorizer, which checks membership -// before ownership/policy enforcement. -// -// Use ProtectedDB for resources that carry no ownerKind/ownerRef and rely -// solely on org-level policy checks. -type ScopedDB[T model.OwnerBound] struct { - // DB is the raw repository accessor. *template.DBImp[T] satisfies this. - DB ownerLoader[T] - // Logger receives Debug/Warn messages from the enforcement path. - Logger mlogger.Logger - // Authorizer performs the full membership-aware access check. - Authorizer *ScopedAuthorizer - // OrgPolicyRef is the built-in policy checked when ownerKind==organization. - OrgPolicyRef bson.ObjectID - // Collection identifies the resource type in access-denied errors and logs. - Collection mservice.Type - // New allocates a zero-value T ready to be filled by DB.Get. - New func() T -} - -// NewScopedDB constructs a ScopedDB and validates required dependencies. -// Logger defaults to zap.NewNop() if nil. -func NewScopedDB[T model.OwnerBound]( - db ownerLoader[T], - authorizer *ScopedAuthorizer, - orgPolicyRef bson.ObjectID, - collection mservice.Type, - newFn func() T, - logger mlogger.Logger, -) *ScopedDB[T] { - if db == nil { - panic("auth.NewScopedDB: DB must not be nil") - } - if authorizer == nil { - panic("auth.NewScopedDB: Authorizer must not be nil") - } - if newFn == nil { - panic("auth.NewScopedDB: New must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedDB[T]{ - DB: db, - Logger: logger, - Authorizer: authorizer, - OrgPolicyRef: orgPolicyRef, - Collection: collection, - New: newFn, - } -} - -// LoadAndEnforce fetches the resource identified by objectRef, reads the -// ownerKind and ownerRef fields from the database record, and enforces access -// via ScopedAuthorizer (membership check → ownership/policy check). It never -// trusts ownership data from the caller. -// -// Logging contract: -// - Debug on access-check start and after ownership is loaded from DB. -// - Debug when access is denied (ErrAccessDenied) — intentional, not an error. -// - Warn when the Enforcer itself returns a non-nil error (unexpected failure). -// - No log on success. -func (s *ScopedDB[T]) LoadAndEnforce(ctx context.Context, callerRef, objectRef bson.ObjectID, action model.Action) (T, error) { - var zero T - - s.Logger.Debug("access check started", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("collection", s.Collection), zap.String("action", string(action))) - - obj := s.New() - if err := s.DB.Get(ctx, objectRef, obj); err != nil { - return zero, err - } - - s.Logger.Debug("ownership loaded from db", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("ownerKind", string(obj.GetOwnerKind()))) - - err := s.Authorizer.Authorize(ctx, ScopedAccessParams{ - Service: s.Collection, - PolicyRef: s.OrgPolicyRef, - CallerRef: callerRef, - OrgRef: obj.GetOrganizationRef(), - ObjectRef: objectRef, - OwnerKind: obj.GetOwnerKind(), - OwnerRef: obj.GetOwnerRef(), - Action: action, - }) - if err == nil { - return obj, nil - } - - if errors.Is(err, merrors.ErrAccessDenied) { - s.Logger.Debug("access denied", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("ownerKind", string(obj.GetOwnerKind())), - zap.String("action", string(action))) - } else { - s.Logger.Warn("enforcer returned error during scoped access check", - zap.Error(err), mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("action", string(action))) - } - return zero, err -} diff --git a/backend/pkg/auth/scopeddb_test.go b/backend/pkg/auth/scopeddb_test.go deleted file mode 100644 index 240c6d1..0000000 --- a/backend/pkg/auth/scopeddb_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// testOwned is a minimal model.OwnerBound used exclusively by ScopedDB tests. -type testOwned struct { - id bson.ObjectID - orgRef bson.ObjectID - kind model.OwnerKind - ownerRef bson.ObjectID -} - -func (o *testOwned) GetID() *bson.ObjectID { return &o.id } -func (o *testOwned) SetID(id bson.ObjectID) { o.id = id } -func (o *testOwned) Update() {} -func (o *testOwned) Collection() string { return "test_owned" } -func (o *testOwned) GetOrganizationRef() bson.ObjectID { return o.orgRef } -func (o *testOwned) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } -func (o *testOwned) GetOwnerKind() model.OwnerKind { return o.kind } -func (o *testOwned) GetOwnerRef() bson.ObjectID { return o.ownerRef } - -var _ model.OwnerBound = (*testOwned)(nil) - -// fakeOwnerLoader returns the pre-built object or the configured error. -type fakeOwnerLoader struct { - obj *testOwned - err error -} - -func (f *fakeOwnerLoader) Get(_ context.Context, _ bson.ObjectID, result *testOwned) error { - if f.err != nil { - return f.err - } - *result = *f.obj - return nil -} - -// fakeMembership controls whether IsMember returns true/false/error. -type fakeMembership struct { - member bool - err error - called bool -} - -func (f *fakeMembership) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { - f.called = true - return f.member, f.err -} - -func newTestScopedDB(loader *fakeOwnerLoader, membership *fakeMembership, enf Enforcer) *ScopedDB[*testOwned] { - authorizer := &ScopedAuthorizer{ - Logger: zap.NewNop(), - Membership: membership, - Enforcer: enf, - } - return &ScopedDB[*testOwned]{ - DB: loader, - Logger: zap.NewNop(), - Authorizer: authorizer, - OrgPolicyRef: bson.NewObjectID(), - Collection: mservice.Type("test_owned"), - New: func() *testOwned { return &testOwned{} }, - } -} - -func TestScopedDB_PersonalOwner_Allowed(t *testing.T) { - caller := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: caller, // owner == caller - } - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } -} - -func TestScopedDB_PersonalOwner_Denied(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: other, // owner != caller - } - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_Allowed(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{allow: true} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_Denied(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{allow: false} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_EnforcerError_Propagated(t *testing.T) { - boom := merrors.Define("enforcer backend down") - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{err: boom} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error propagated, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("enforcer internal error must not be masked as access denied") - } -} - -func TestScopedDB_DBError_Propagated(t *testing.T) { - dbErr := merrors.Define("mongo unavailable") - db := newTestScopedDB(&fakeOwnerLoader{err: dbErr}, &fakeMembership{member: true}, &fakeEnforcer{}) - - _, err := db.LoadAndEnforce(context.Background(), bson.NewObjectID(), bson.NewObjectID(), model.ActionRead) - if !errors.Is(err, dbErr) { - t.Fatalf("expected DB error propagated, got %v", err) - } -} - -// TestScopedDB_PersonalOwner_NonMember_Denied is the main regression case: -// even when ownerRef == callerRef, a non-member of the organization must be -// denied before ownership is checked, so the tenant boundary is enforced. -// TestScopedDB_PersonalOwner_NonMember_Allowed: personal resource, caller is the -// owner but is NOT a member of the org. Membership is not consulted for -// account-owned resources — ownership alone is sufficient. -func TestScopedDB_PersonalOwner_NonMember_Allowed(t *testing.T) { - caller := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: caller, // owner == caller, membership is irrelevant - } - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if enf.called { - t.Fatal("enforcer must not be called for account-owned resource") - } -} - -// TestScopedDB_OrgOwner_NonMember_Denied: org-owned object, caller is not a -// member — must be denied before the policy enforcer is consulted. -func TestScopedDB_OrgOwner_NonMember_Denied(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} // would allow if reached - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org-owned object, got %v", err) - } - if enf.called { - t.Fatal("enforcer must not be called when membership check fails") - } -} diff --git a/backend/pkg/db/account/account.go b/backend/pkg/db/account/account.go index 335b1f0..369b04f 100644 --- a/backend/pkg/db/account/account.go +++ b/backend/pkg/db/account/account.go @@ -5,12 +5,10 @@ import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/template" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" ) // DB is the interface which must be implemented by all db drivers type DB interface { template.DB[*model.Account] GetByEmail(ctx context.Context, email string) (*model.Account, error) - GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) } diff --git a/backend/pkg/db/config.go b/backend/pkg/db/config.go index 015d0e5..52d626d 100644 --- a/backend/pkg/db/config.go +++ b/backend/pkg/db/config.go @@ -1,13 +1,7 @@ package db import ( - "strings" - - mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/go-viper/mapstructure/v2" - "go.uber.org/zap" ) type DBDriver string @@ -17,87 +11,3 @@ const ( ) type Config = model.DriverConfig[DBDriver] - -// PermissionsDatabaseConfig is the explicit public contract for services that -// read authorization metadata from the central permissions database. -type PermissionsDatabaseConfig Config - -// AsDatabaseConfig converts the permissions DB contract to the existing DB -// config type without duplicating driver parsing or connection behavior. -func (c *PermissionsDatabaseConfig) AsDatabaseConfig() *Config { - if c == nil { - return nil - } - converted := Config(*c) - return &converted -} - -// ResolvedDatabaseName identifies the database selected by a DB config. -type ResolvedDatabaseName struct { - Name string - Source string -} - -// ResolveDatabaseName resolves only the database name portion of a DB config. -// It intentionally mirrors the existing env-over-literal behavior and leaves -// low-level connection validation to the normal DB connection path. -func ResolveDatabaseName(config *Config) ResolvedDatabaseName { - if config == nil { - return ResolvedDatabaseName{} - } - return resolveDatabaseName(config) -} - -// ResolvePermissionsDatabaseName resolves only the database name portion of a -// permissions DB config. -func ResolvePermissionsDatabaseName(config *PermissionsDatabaseConfig) ResolvedDatabaseName { - return ResolveDatabaseName(config.AsDatabaseConfig()) -} - -// ValidateSplitDomainPermissionsDatabase verifies the generic split-domain -// invariant for services that must keep authorization metadata separate from -// domain storage. -func ValidateSplitDomainPermissionsDatabase(service string, domain *Config, permissions *PermissionsDatabaseConfig) (ResolvedDatabaseName, ResolvedDatabaseName, error) { - domainName := ResolveDatabaseName(domain) - permissionsName := ResolvePermissionsDatabaseName(permissions) - - if permissions == nil { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database configuration is required", "permissions_database") - } - if strings.TrimSpace(domainName.Name) == "" { - return domainName, permissionsName, merrors.InvalidArgument(service+" domain database name is required for split-domain validation", "database") - } - if strings.TrimSpace(permissionsName.Name) == "" { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions database name is required for split-domain validation", "permissions_database") - } - if domainName.Name == permissionsName.Name { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database must resolve to a different database than the domain database", "permissions_database") - } - - return domainName, permissionsName, nil -} - -func resolveDatabaseName(config *Config) ResolvedDatabaseName { - if config == nil || config.Driver != Mongo { - return ResolvedDatabaseName{} - } - var mongoConfig mongoimpl.Config - if err := mapstructure.Decode(config.Settings, &mongoConfig); err != nil { - return ResolvedDatabaseName{} - } - dbSettings := mongoimpl.Config2DBSettings(zap.NewNop(), &mongoConfig) - source := "" - if mongoConfig.DatabaseEnv != nil { - source = "env:" + *mongoConfig.DatabaseEnv - } else if mongoConfig.Database != nil { - source = "settings.database" - } - if source != "" { - return ResolvedDatabaseName{ - Name: dbSettings.Database, - Source: source, - } - } - - return ResolvedDatabaseName{} -} diff --git a/backend/pkg/db/factory.go b/backend/pkg/db/factory.go index a3d42e8..5a49298 100644 --- a/backend/pkg/db/factory.go +++ b/backend/pkg/db/factory.go @@ -1,12 +1,8 @@ package db import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" @@ -16,15 +12,9 @@ import ( type Factory interface { NewAccountDB() (account.DB, error) NewAccountDBWithCollection(collection string) (account.DB, error) - NewInvitationsDB() (invitation.DB, error) - - NewRolesDB() (role.DB, error) - NewPoliciesDB() (policy.DB, error) TransactionFactory() transaction.Factory - Permissions() auth.Provider - CloseConnection() } @@ -38,16 +28,3 @@ func NewConnection(logger mlogger.Logger, config *Config) (Factory, error) { } return nil, merrors.InvalidArgument("unknown database driver: "+string(config.Driver), "config.driver") } - -// NewCentralPermissionsConnection opens the explicitly configured central -// permissions database. It intentionally has no fallback to a service domain DB. -func NewCentralPermissionsConnection(logger mlogger.Logger, config *PermissionsDatabaseConfig) (Factory, error) { - if config == nil { - return nil, merrors.InvalidArgument("permissions_database configuration is required", "permissions_database") - } - conn, err := NewConnection(logger, config.AsDatabaseConfig()) - if err != nil { - return nil, merrors.Wrap(err, "permissions_database") - } - return conn, nil -} diff --git a/backend/pkg/db/internal/mongo/accountdb/user.go b/backend/pkg/db/internal/mongo/accountdb/user.go index 434f01f..76f9697 100644 --- a/backend/pkg/db/internal/mongo/accountdb/user.go +++ b/backend/pkg/db/internal/mongo/accountdb/user.go @@ -4,17 +4,9 @@ import ( "context" "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" ) -func (db *AccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) { - filter := repository.Query().Comparison(repository.IDField(), builder.In, refs) - return mutil.GetObjects[model.Account](ctx, db.Logger, filter, nil, db.Repository) -} - func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { var account model.Account return &account, db.FindOne(ctx, repository.Filter("login", email), &account) diff --git a/backend/pkg/db/internal/mongo/db.go b/backend/pkg/db/internal/mongo/db.go index b57113a..a536c5a 100644 --- a/backend/pkg/db/internal/mongo/db.go +++ b/backend/pkg/db/internal/mongo/db.go @@ -7,21 +7,12 @@ import ( "os" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/accountdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/invitationdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/rolesdb" "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/transactionimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/config" "github.com/go-viper/mapstructure/v2" "go.mongodb.org/mongo-driver/v2/mongo" @@ -31,29 +22,28 @@ import ( // Config represents configuration type Config struct { - Port *string `mapstructure:"port"` - PortEnv *string `mapstructure:"port_env"` - User *string `mapstructure:"user"` - UserEnv *string `mapstructure:"user_env"` - PasswordEnv string `mapstructure:"password_env"` - Database *string `mapstructure:"database"` - DatabaseEnv *string `mapstructure:"database_env"` - Host *string `mapstructure:"host"` - HostEnv *string `mapstructure:"host_env"` - Hosts []string `mapstructure:"hosts,omitempty"` - HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"` - HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"` - PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"` - PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"` - URI *string `mapstructure:"uri,omitempty"` - URIEnv *string `mapstructure:"uri_env,omitempty"` - AuthSource *string `mapstructure:"auth_source,omitempty"` - AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"` - AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"` - AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"` - ReplicaSet *string `mapstructure:"replica_set,omitempty"` - ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"` - Enforcer *auth.Config `mapstructure:"enforcer"` + Port *string `mapstructure:"port"` + PortEnv *string `mapstructure:"port_env"` + User *string `mapstructure:"user"` + UserEnv *string `mapstructure:"user_env"` + PasswordEnv string `mapstructure:"password_env"` + Database *string `mapstructure:"database"` + DatabaseEnv *string `mapstructure:"database_env"` + Host *string `mapstructure:"host"` + HostEnv *string `mapstructure:"host_env"` + Hosts []string `mapstructure:"hosts,omitempty"` + HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"` + HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"` + PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"` + PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"` + URI *string `mapstructure:"uri,omitempty"` + URIEnv *string `mapstructure:"uri_env,omitempty"` + AuthSource *string `mapstructure:"auth_source,omitempty"` + AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"` + AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"` + AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"` + ReplicaSet *string `mapstructure:"replica_set,omitempty"` + ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"` } type DBSettings struct { @@ -69,19 +59,6 @@ type DBSettings struct { URI string } -func newProtectedDB[T any]( - db *DB, - create func(ctx context.Context, logger mlogger.Logger, enforcer auth.Enforcer, pdb policy.DB, client *mongo.Database) (T, error), -) (T, error) { - pdb, err := db.NewPoliciesDB() - if err != nil { - db.logger.Warn("Failed to create policies database", zap.Error(err)) - var zero T - return zero, err - } - return create(context.Background(), db.logger, db.Enforcer(), pdb, db.db()) -} - func Config2DBSettings(logger mlogger.Logger, config *Config) *DBSettings { p := new(DBSettings) p.Port = mutil.GetConfigValue(logger, "port", "port_env", config.Port, config.PortEnv) @@ -160,12 +137,9 @@ func ConnectClient(logger mlogger.Logger, settings model.SettingsT) (*mongo.Clie // DB represents the structure of the database type DB struct { - logger mlogger.Logger - config *DBSettings - client *mongo.Client - enforcer auth.Enforcer - manager auth.Manager - pdb policy.DB + logger mlogger.Logger + config *DBSettings + client *mongo.Client } func (db *DB) db() *mongo.Database { @@ -184,18 +158,6 @@ func (db *DB) NewAccountDBWithCollection(collection string) (account.DB, error) return accountdb.CreateWithCollection(db.logger, db.db(), collection) } -func (db *DB) NewInvitationsDB() (invitation.DB, error) { - return newProtectedDB(db, invitationdb.Create) -} - -func (db *DB) NewPoliciesDB() (policy.DB, error) { - return db.pdb, nil -} - -func (db *DB) NewRolesDB() (role.DB, error) { - return rolesdb.Create(db.logger, db.db()) -} - func (db *DB) TransactionFactory() transaction.Factory { return transactionimp.CreateFactory(db.client) } @@ -264,23 +226,6 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host return hosts } -func (db *DB) Permissions() auth.Provider { - return db -} - -func (db *DB) Manager() auth.Manager { - return db.manager -} - -func (db *DB) Enforcer() auth.Enforcer { - return db.enforcer -} - -func (db *DB) GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) { - var policyDescription model.PolicyDescription - return &policyDescription, db.pdb.FindOne(ctx, repository.Filter("resourceTypes", resource), &policyDescription) -} - func (db *DB) CloseConnection() { if err := db.client.Disconnect(context.Background()); err != nil { db.logger.Warn("Failed to close connection", zap.Error(err)) @@ -290,7 +235,7 @@ func (db *DB) CloseConnection() { // NewConnection creates a new database connection func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) { - client, config, dbSettings, err := ConnectClient(logger, settings) + client, _, dbSettings, err := ConnectClient(logger, settings) if err != nil { return nil, err } @@ -301,28 +246,5 @@ func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) client: client, } - cleanup := func(ctx context.Context) { - if err := client.Disconnect(ctx); err != nil { - logger.Warn("Failed to close MongoDB connection", zap.Error(err)) - } - } - - rdb, err := db.NewRolesDB() - if err != nil { - db.logger.Warn("Failed to create roles database", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - if db.pdb, err = policiesdb.Create(db.logger, db.db()); err != nil { - db.logger.Warn("Failed to create policies database", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - if db.enforcer, db.manager, err = auth.CreateAuth(logger, db.client, db.db(), db.pdb, rdb, config.Enforcer); err != nil { - db.logger.Warn("Failed to create permissions enforcer", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - return db, nil } diff --git a/backend/pkg/db/internal/mongo/invitationdb/accept.go b/backend/pkg/db/internal/mongo/invitationdb/accept.go deleted file mode 100644 index b493ea9..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/accept.go +++ /dev/null @@ -1,12 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) Accept(ctx context.Context, invitationRef bson.ObjectID) error { - return db.updateStatus(ctx, invitationRef, model.InvitationAccepted) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/archived.go b/backend/pkg/db/internal/mongo/invitationdb/archived.go deleted file mode 100644 index 0b95b25..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/archived.go +++ /dev/null @@ -1,49 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// SetArchived sets the archived status of an invitation -// Invitation supports archiving through PermissionBound embedding ArchivableBase -func (db *InvitationDB) SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error { - db.DBImp.Logger.Debug("Setting invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade)) - res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, invitationRef, model.ActionUpdate) - if err != nil { - db.DBImp.Logger.Warn("Failed to enforce archivation permission", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - if !res { - db.DBImp.Logger.Debug("Permission denied for archivation", mzap.ObjRef("invitation_ref", invitationRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionUpdate), invitationRef) - } - - // Get the invitation first - var invitation model.Invitation - if err := db.Get(ctx, accountRef, invitationRef, &invitation); err != nil { - db.DBImp.Logger.Warn("Error retrieving invitation for archival", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - // Update the invitation's archived status - invitation.SetArchived(archived) - if err := db.Update(ctx, accountRef, &invitation); err != nil { - db.DBImp.Logger.Warn("Error updating invitation archived status", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - // Note: Currently no cascade dependencies for invitations - // If cascade is enabled, we could add logic here for any future dependencies - if cascade { - db.DBImp.Logger.Debug("Cascade archiving requested but no dependencies to archive for invitation", mzap.ObjRef("invitation_ref", invitationRef)) - } - - db.DBImp.Logger.Debug("Successfully set invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived)) - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/cascade.go b/backend/pkg/db/internal/mongo/invitationdb/cascade.go deleted file mode 100644 index 08cce22..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/cascade.go +++ /dev/null @@ -1,24 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// DeleteCascade deletes an invitation -// Invitations don't have cascade dependencies, so this is a simple deletion -func (db *InvitationDB) DeleteCascade(ctx context.Context, accountRef, invitationRef bson.ObjectID) error { - db.DBImp.Logger.Debug("Starting invitation cascade deletion", mzap.ObjRef("invitation_ref", invitationRef)) - - // Delete the invitation itself (no dependencies to cascade delete) - if err := db.Delete(ctx, accountRef, invitationRef); err != nil { - db.DBImp.Logger.Error("Error deleting invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully deleted invitation", mzap.ObjRef("invitation_ref", invitationRef)) - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db.go b/backend/pkg/db/internal/mongo/invitationdb/db.go deleted file mode 100644 index 3293103..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db.go +++ /dev/null @@ -1,48 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type InvitationDB struct { - auth.ProtectedDBImp[*model.Invitation] -} - -func Create( - ctx context.Context, - logger mlogger.Logger, - enforcer auth.Enforcer, - pdb policy.DB, - db *mongo.Database, -) (*InvitationDB, error) { - p, err := auth.CreateDBImp[*model.Invitation](ctx, logger, pdb, enforcer, mservice.Invitations, db) - if err != nil { - return nil, err - } - - if err := p.DBImp.Repository.CreateIndex(pendingInvitationEmailIndexDefinition()); err != nil { - p.DBImp.Logger.Error("Failed to create unique pending invitation email index", zap.Error(err)) - return nil, err - } - - // ttl index - ttl := int32(0) // zero ttl means expiration on date preset when inserting data - if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, - TTL: &ttl, - }); err != nil { - p.DBImp.Logger.Error("Failed to create ttl index in the invitations", zap.Error(err)) - return nil, err - } - - return &InvitationDB{ProtectedDBImp: *p}, nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go deleted file mode 100644 index 6d59c91..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build integration -// +build integration - -package invitationdb - -import ( - "context" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/mongodb" - "github.com/testcontainers/testcontainers-go/wait" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -func TestPendingInvitationEmailIndexBehavior(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - mongoContainer, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)), - ) - require.NoError(t, err) - defer func() { - termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer termCancel() - _ = mongoContainer.Terminate(termCtx) - }() - - mongoURI, err := mongoContainer.ConnectionString(ctx) - require.NoError(t, err) - - client, err := mongo.Connect(options.Client().ApplyURI(mongoURI)) - require.NoError(t, err) - defer func() { _ = client.Disconnect(context.Background()) }() - - database := client.Database("test_invitation_pending_email_index") - defer func() { _ = database.Drop(context.Background()) }() - - repo := repository.CreateMongoRepository(database, "invitations") - require.NoError(t, repo.CreateIndex(pendingInvitationEmailIndexDefinition())) - assertPendingEmailIndexDefinition(t, ctx, database.Collection("invitations")) - - orgRef := bson.NewObjectID() - email := "invitee@example.com" - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationCreated), nil)) - err = repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationSent), nil) - require.ErrorIs(t, err, merrors.ErrDataConflict, "duplicate pending invitation must conflict") - - for _, status := range []model.InvitationStatus{model.InvitationRevoked, model.InvitationDeclined, model.InvitationAccepted} { - statusEmail := string(status) + "@example.com" - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, status), nil)) - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, model.InvitationCreated), nil)) - } - - otherOrgRef := bson.NewObjectID() - require.NoError(t, repo.Insert(ctx, testInvitation(otherOrgRef, email, model.InvitationCreated), nil)) -} - -func testInvitation(orgRef bson.ObjectID, email string, status model.InvitationStatus) *model.Invitation { - inv := &model.Invitation{ - OrganizationRef: orgRef, - Status: status, - ExpiresAt: time.Now().UTC().Add(time.Hour), - } - inv.SetID(bson.NewObjectID()) - inv.SetOrganizationRef(orgRef) - inv.Content.Email = email - return inv -} - -func assertPendingEmailIndexDefinition(t *testing.T, ctx context.Context, collection *mongo.Collection) { - t.Helper() - cursor, err := collection.Indexes().List(ctx) - require.NoError(t, err) - defer func() { _ = cursor.Close(ctx) }() - - for cursor.Next(ctx) { - var index bson.M - require.NoError(t, cursor.Decode(&index)) - if index["name"] != uniquePendingInvitationEmailIndexName { - continue - } - - require.Equal(t, true, index["unique"]) - require.Equal(t, bson.D{ - {Key: "organizationRef", Value: int32(1)}, - {Key: "description.email", Value: int32(1)}, - }, index["key"]) - require.Equal(t, bson.D{ - {Key: "status", Value: bson.D{{Key: "$in", Value: bson.A{"created", "sent"}}}}, - {Key: "description.email", Value: bson.D{{Key: "$type", Value: "string"}}}, - }, index["partialFilterExpression"]) - return - } - require.NoError(t, cursor.Err()) - t.Fatalf("index %q was not created", uniquePendingInvitationEmailIndexName) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_test.go deleted file mode 100644 index 726b8cb..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package invitationdb - -import ( - "reflect" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPendingInvitationEmailIndexDefinition(t *testing.T) { - def := pendingInvitationEmailIndexDefinition() - - expectedKeys := []ri.Key{ - {Field: "organizationRef", Sort: ri.Asc}, - {Field: "description.email", Sort: ri.Asc}, - } - if !reflect.DeepEqual(def.Keys, expectedKeys) { - t.Fatalf("keys mismatch: got %#v want %#v", def.Keys, expectedKeys) - } - if !def.Unique { - t.Fatal("index must be unique") - } - if def.Name != uniquePendingInvitationEmailIndexName { - t.Fatalf("index name mismatch: got %q want %q", def.Name, uniquePendingInvitationEmailIndexName) - } - - expectedFilter := repository.Query(). - In(repository.Field("status"), "created", "sent"). - Comparison(repository.Field("description.email"), builder.Type, "string"). - BuildQuery() - if !reflect.DeepEqual(def.PartialFilter.BuildQuery(), expectedFilter) { - t.Fatalf("partial filter mismatch: got %#v want %#v", def.PartialFilter.BuildQuery(), expectedFilter) - } -} - -func TestPendingInvitationEmailIndexUsesModelPendingStatuses(t *testing.T) { - def := pendingInvitationEmailIndexDefinition() - filter := def.PartialFilter.BuildQuery() - statuses := statusInValues(t, filter) - - expected := model.PendingInvitationStatusValues() - if !reflect.DeepEqual(statuses, expected) { - t.Fatalf("index statuses drifted from model: got %#v want %#v", statuses, expected) - } -} - -func statusInValues(t *testing.T, filter bson.D) []string { - t.Helper() - for _, item := range filter { - if item.Key != "status" { - continue - } - comparison, ok := item.Value.(bson.M) - if !ok { - t.Fatalf("status filter has unexpected type %T", item.Value) - } - rawValues, ok := comparison["$in"] - if !ok { - t.Fatal("status filter must use $in") - } - values, ok := rawValues.([]any) - if !ok { - t.Fatalf("status $in has unexpected type %T", rawValues) - } - statuses := make([]string, 0, len(values)) - for _, value := range values { - status, ok := value.(string) - if !ok { - t.Fatalf("status value has unexpected type %T", value) - } - statuses = append(statuses, status) - } - return statuses - } - t.Fatal("status filter not found") - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/decline.go b/backend/pkg/db/internal/mongo/invitationdb/decline.go deleted file mode 100644 index 520bbdf..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/decline.go +++ /dev/null @@ -1,12 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) Decline(ctx context.Context, invitationRef bson.ObjectID) error { - return db.updateStatus(ctx, invitationRef, model.InvitationDeclined) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/getpublic.go b/backend/pkg/db/internal/mongo/invitationdb/getpublic.go deleted file mode 100644 index 12d91f9..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/getpublic.go +++ /dev/null @@ -1,121 +0,0 @@ -package invitationdb - -import ( - "context" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func (db *InvitationDB) GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) { - roleField := repository.Field("role") - orgField := repository.Field("organization") - accField := repository.Field("account") - empField := repository.Field("employee") - regField := repository.Field("registrationAcc") - descEmailField := repository.Field("description").Dot("email") - pipeline := repository.Pipeline(). - // 0) Filter to exactly the invitation(s) you want - Match(repository.IDFilter(invitationRef).And(repository.Filter("status", model.InvitationCreated))). - // 1) Lookup the role document - Lookup( - mservice.Roles, - repository.Field("roleRef"), - repository.IDField(), - roleField, - ). - Unwind(repository.Ref(roleField)). - // 2) Lookup the organization document - Lookup( - mservice.Organizations, - repository.Field("organizationRef"), - repository.IDField(), - orgField, - ). - Unwind(repository.Ref(orgField)). - // 3) Lookup the account document - Lookup( - mservice.Accounts, - repository.Field("inviterRef"), - repository.IDField(), - accField, - ). - Unwind(repository.Ref(accField)). - /* 4) do we already have an account whose login == invitation.description ? */ - Lookup( - mservice.Accounts, - descEmailField, // local field (invitation.description.email) - repository.Field("login"), // foreign field (account.login) - regField, // array: 0-length or ≥1 - ). - // 5) Projection - Project( - repository.SimpleAlias( - empField.Dot("description"), - repository.Ref(accField), - ), - repository.SimpleAlias( - empField.Dot("avatarUrl"), - repository.Ref(accField.Dot("avatarUrl")), - ), - repository.SimpleAlias( - orgField.Dot("description"), - repository.Ref(orgField), - ), - repository.SimpleAlias( - orgField.Dot("logoUrl"), - repository.Ref(orgField.Dot("logoUrl")), - ), - repository.SimpleAlias( - roleField, - repository.Ref(roleField), - ), - repository.SimpleAlias( - repository.Field("invitation"), // ← left-hand side - repository.Ref(repository.Field("description")), // ← right-hand side (“$description”) - ), - repository.SimpleAlias( - repository.Field("storable"), // ← left-hand side - repository.RootRef(), // ← right-hand side (“$description”) - ), - repository.ProjectionExpr( - repository.Field("registrationRequired"), - repository.Eq( - repository.Size(repository.Value(repository.Ref(regField).Build())), - repository.Literal(0), - ), - ), - ) - - var res model.PublicInvitation - haveResult := false - decoder := func(cur *mongo.Cursor) error { - if haveResult { - // should never get here - db.DBImp.Logger.Warn("Unexpected extra invitation", mzap.ObjRef("invitation_ref", invitationRef)) - return merrors.Internal("Unexpected extra invitation found by reference") - } - if e := cur.Decode(&res); e != nil { - db.DBImp.Logger.Warn("Failed to decode entity", zap.Error(e), zap.Any("data", cur.Current.String())) - return e - } - haveResult = true - return nil - } - if err := db.DBImp.Repository.Aggregate(ctx, pipeline, decoder); err != nil { - db.DBImp.Logger.Warn("Failed to execute aggregation pipeline", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return nil, err - } - if !haveResult { - db.DBImp.Logger.Warn("No results fetched", mzap.ObjRef("invitation_ref", invitationRef)) - return nil, merrors.NoData(fmt.Sprintf("Invitation %s not found", invitationRef.Hex())) - } - return &res, nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/indexes.go b/backend/pkg/db/internal/mongo/invitationdb/indexes.go deleted file mode 100644 index df40161..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/indexes.go +++ /dev/null @@ -1,30 +0,0 @@ -package invitationdb - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -const uniquePendingInvitationEmailIndexName = "unique_pending_invitation_email_per_org" - -func pendingInvitationEmailIndexDefinition() *ri.Definition { - statusValues := model.PendingInvitationStatusValues() - statuses := make([]any, 0, len(statusValues)) - for _, status := range statusValues { - statuses = append(statuses, status) - } - - return &ri.Definition{ - Keys: []ri.Key{ - {Field: repository.OrgField().Build(), Sort: ri.Asc}, - {Field: "description.email", Sort: ri.Asc}, - }, - Unique: true, - Name: uniquePendingInvitationEmailIndexName, - PartialFilter: repository.Query(). - In(repository.Field("status"), statuses...). - Comparison(repository.Field("description.email"), builder.Type, "string"), - } -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/list.go b/backend/pkg/db/internal/mongo/invitationdb/list.go deleted file mode 100644 index 8b5cffe..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/list.go +++ /dev/null @@ -1,28 +0,0 @@ -package invitationdb - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mauth "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db/auth" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) { - res, err := mauth.GetProtectedObjects[model.Invitation]( - ctx, - db.DBImp.Logger, - accountRef, organizationRef, model.ActionRead, - repository.OrgFilter(organizationRef), - cursor, - db.Enforcer, - db.DBImp.Repository, - ) - if errors.Is(err, merrors.ErrNoData) { - return []model.Invitation{}, nil - } - return res, err -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go b/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go deleted file mode 100644 index 62e5b28..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go +++ /dev/null @@ -1,26 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func (db *InvitationDB) updateStatus(ctx context.Context, invitationRef bson.ObjectID, newStatus model.InvitationStatus) error { - // db.DBImp.Up - var inv model.Invitation - if err := db.DBImp.FindOne(ctx, repository.IDFilter(invitationRef), &inv); err != nil { - db.DBImp.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) - return err - } - inv.Status = newStatus - if err := db.DBImp.Update(ctx, &inv); err != nil { - db.DBImp.Logger.Warn("Failed to update invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) - return err - } - return nil -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/all.go b/backend/pkg/db/internal/mongo/policiesdb/all.go deleted file mode 100644 index c471c39..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/all.go +++ /dev/null @@ -1,20 +0,0 @@ -package policiesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *PoliciesDB) All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) { - // all documents - filter := repository.Query().Or( - repository.Filter(storable.OrganizationRefField, nil), - repository.OrgFilter(organizationRef), - ) - return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/builtin.go b/backend/pkg/db/internal/mongo/policiesdb/builtin.go deleted file mode 100644 index 1c03a5d..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/builtin.go +++ /dev/null @@ -1,13 +0,0 @@ -package policiesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -func (db *PoliciesDB) GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error { - return db.FindOne(ctx, repository.Filter("resourceTypes", resourceType), policy) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db.go b/backend/pkg/db/internal/mongo/policiesdb/db.go deleted file mode 100644 index 484676f..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/db.go +++ /dev/null @@ -1,21 +0,0 @@ -package policiesdb - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type PoliciesDB struct { - template.DBImp[*model.PolicyDescription] -} - -func Create(logger mlogger.Logger, db *mongo.Database) (*PoliciesDB, error) { - p := &PoliciesDB{ - DBImp: *template.Create[*model.PolicyDescription](logger, mservice.Policies, db), - } - - return p, nil -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db_test.go b/backend/pkg/db/internal/mongo/policiesdb/db_test.go deleted file mode 100644 index 6c4baf8..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/db_test.go +++ /dev/null @@ -1,355 +0,0 @@ -//go:build integration -// +build integration - -package policiesdb_test - -import ( - "context" - "errors" - "testing" - "time" - - // Your internal packages - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - - // Model package (contains PolicyDescription + Describable) - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - // Testcontainers - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/mongodb" - "github.com/testcontainers/testcontainers-go/wait" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" - "go.uber.org/zap" -) - -// Helper to terminate container -func terminate(t *testing.T, ctx context.Context, container *mongodb.MongoDBContainer) { - err := container.Terminate(ctx) - require.NoError(t, err, "failed to terminate MongoDB container") -} - -// Helper to disconnect client -func disconnect(t *testing.T, ctx context.Context, client *mongo.Client) { - err := client.Disconnect(context.Background()) - require.NoError(t, err, "failed to disconnect from MongoDB") -} - -// Helper to drop the Policies collection -func cleanupCollection(t *testing.T, ctx context.Context, db *mongo.Database) { - // The actual collection name is typically the value returned by - // (&model.PolicyDescription{}).Collection(), or something similar. - // Make sure it matches what your code uses (often "policies" or "policyDescription"). - err := db.Collection((&model.PolicyDescription{}).Collection()).Drop(ctx) - require.NoError(t, err, "failed to drop collection between sub-tests") -} - -func TestPoliciesDB(t *testing.T) { - // Create context with reasonable timeout - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - // Start MongoDB test container - mongoC, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), - ) - require.NoError(t, err, "failed to start MongoDB container") - defer terminate(t, ctx, mongoC) - - // Get connection URI - mongoURI, err := mongoC.ConnectionString(ctx) - require.NoError(t, err, "failed to get connection string") - - // Connect client - clientOpts := options.Client().ApplyURI(mongoURI) - client, err := mongo.Connect(ctx, clientOpts) - require.NoError(t, err, "failed to connect to MongoDB") - defer disconnect(t, ctx, client) - - // Create test DB - db := client.Database("testdb") - - // Use a no-op logger (or real logger if you prefer) - logger := zap.NewNop() - - // Create an instance of PoliciesDB - pdb, err := policiesdb.Create(logger, db) - require.NoError(t, err, "unexpected error creating PoliciesDB") - - // --------------------------------------------------------- - // Each sub-test below starts by dropping the collection. - // --------------------------------------------------------- - - t.Run("CreateAndGet", func(t *testing.T) { - cleanupCollection(t, ctx, db) // ensure no leftover data - - desc := "Test policy description" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "TestPolicy", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - result := &model.PolicyDescription{} - err := pdb.Get(ctx, policy.ID, result) - require.NoError(t, err) - - assert.Equal(t, policy.ID, result.ID) - assert.Equal(t, "TestPolicy", result.Name) - assert.NotNil(t, result.Description) - assert.Equal(t, "Test policy description", *result.Description) - }) - - t.Run("Get_NotFound", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - // Attempt to get a non-existent ID - nonExistentID := bson.NewObjectID() - result := &model.PolicyDescription{} - err := pdb.Get(ctx, nonExistentID, result) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrNoData)) - }) - - t.Run("Update", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - originalDesc := "Original description" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "OriginalName", - Description: &originalDesc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - newDesc := "Updated description" - policy.Name = "UpdatedName" - policy.Description = &newDesc - - err := pdb.Update(ctx, policy) - require.NoError(t, err) - - updated := &model.PolicyDescription{} - err = pdb.Get(ctx, policy.ID, updated) - require.NoError(t, err) - - assert.Equal(t, "UpdatedName", updated.Name) - assert.NotNil(t, updated.Description) - assert.Equal(t, "Updated description", *updated.Description) - }) - - t.Run("Delete", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc := "To be deleted" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "WillDelete", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - err := pdb.Delete(ctx, policy.ID) - require.NoError(t, err) - - deleted := &model.PolicyDescription{} - err = pdb.Get(ctx, policy.ID, deleted) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrNoData)) - }) - - t.Run("DeleteMany", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc1 := "Will be deleted 1" - desc2 := "Will be deleted 2" - pol1 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "BatchDelete1", - Description: &desc1, - }, - } - pol2 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "BatchDelete2", - Description: &desc2, - }, - } - require.NoError(t, pdb.Create(ctx, pol1)) - require.NoError(t, pdb.Create(ctx, pol2)) - - q := repository.Query().RegEx(repository.Field("description"), "^Will be deleted", "") - err := pdb.DeleteMany(ctx, q) - require.NoError(t, err) - - res1 := &model.PolicyDescription{} - err1 := pdb.Get(ctx, pol1.ID, res1) - assert.Error(t, err1) - assert.True(t, errors.Is(err1, merrors.ErrNoData)) - - res2 := &model.PolicyDescription{} - err2 := pdb.Get(ctx, pol2.ID, res2) - assert.Error(t, err2) - assert.True(t, errors.Is(err2, merrors.ErrNoData)) - }) - - t.Run("FindOne", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc := "Unique find test" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "FindOneTest", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - // Match by name == "FindOneTest" - q := repository.Query().Comparison(repository.Field("name"), builder.Eq, "FindOneTest") - - found := &model.PolicyDescription{} - err := pdb.FindOne(ctx, q, found) - require.NoError(t, err) - - assert.Equal(t, policy.ID, found.ID) - assert.Equal(t, "FindOneTest", found.Name) - assert.NotNil(t, found.Description) - assert.Equal(t, "Unique find test", *found.Description) - }) - - t.Run("All", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - // Insert some policies (orgA, orgB, nil org) - orgA := bson.NewObjectID() - orgB := bson.NewObjectID() - - descA := "Org A policy" - policyA := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyA", - Description: &descA, - }, - OrganizationRef: &orgA, // belongs to orgA - } - descB := "Org B policy" - policyB := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyB", - Description: &descB, - }, - OrganizationRef: &orgB, // belongs to orgB - } - descNil := "No org policy" - policyNil := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyNil", - Description: &descNil, - }, - // nil => built-in - } - require.NoError(t, pdb.Create(ctx, policyA)) - require.NoError(t, pdb.Create(ctx, policyB)) - require.NoError(t, pdb.Create(ctx, policyNil)) - - // Suppose the requirement is: "All" returns - // - policies for the requested org - // - plus built-in (nil) ones - resultsA, err := pdb.All(ctx, orgA) - require.NoError(t, err) - require.Len(t, resultsA, 2) // orgA + built-in - - var idsA []bson.ObjectID - for _, r := range resultsA { - idsA = append(idsA, r.ID) - } - assert.Contains(t, idsA, policyA.ID) - assert.Contains(t, idsA, policyNil.ID) - assert.NotContains(t, idsA, policyB.ID) - - resultsB, err := pdb.All(ctx, orgB) - require.NoError(t, err) - require.Len(t, resultsB, 2) // orgB + built-in - - var idsB []bson.ObjectID - for _, r := range resultsB { - idsB = append(idsB, r.ID) - } - assert.Contains(t, idsB, policyB.ID) - assert.Contains(t, idsB, policyNil.ID) - assert.NotContains(t, idsB, policyA.ID) - }) - - t.Run("Policies", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc1 := "PolicyOne" - pol1 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyOne", - Description: &desc1, - }, - } - desc2 := "PolicyTwo" - pol2 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyTwo", - Description: &desc2, - }, - } - desc3 := "PolicyThree" - pol3 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyThree", - Description: &desc3, - }, - } - require.NoError(t, pdb.Create(ctx, pol1)) - require.NoError(t, pdb.Create(ctx, pol2)) - require.NoError(t, pdb.Create(ctx, pol3)) - - // 1) Request pol1, pol2 - results12, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol2.ID}) - require.NoError(t, err) - require.Len(t, results12, 2) - // IDs might be out of order, so we do a set-like check - var set12 []bson.ObjectID - for _, r := range results12 { - set12 = append(set12, r.ID) - } - assert.Contains(t, set12, pol1.ID) - assert.Contains(t, set12, pol2.ID) - - // 2) Request pol1, pol3, plus a random ID - fakeID := bson.NewObjectID() - results13Fake, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol3.ID, fakeID}) - require.NoError(t, err) - require.Len(t, results13Fake, 2) // pol1 + pol3 only - var set13Fake []bson.ObjectID - for _, r := range results13Fake { - set13Fake = append(set13Fake, r.ID) - } - assert.Contains(t, set13Fake, pol1.ID) - assert.Contains(t, set13Fake, pol3.ID) - - // 3) Request with empty slice => expect no results - resultsEmpty, err := pdb.Policies(ctx, []bson.ObjectID{}) - require.NoError(t, err) - assert.Len(t, resultsEmpty, 0) - }) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/policies.go b/backend/pkg/db/internal/mongo/policiesdb/policies.go deleted file mode 100644 index abc5893..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/policies.go +++ /dev/null @@ -1,18 +0,0 @@ -package policiesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *PoliciesDB) Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) { - if len(refs) == 0 { - return []model.PolicyDescription{}, nil - } - filter := repository.Query().In(repository.IDField(), refs) - return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository.go b/backend/pkg/db/internal/mongo/repositoryimp/repository.go index 2c6ac60..18d5eec 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository.go +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository.go @@ -10,7 +10,6 @@ import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -227,57 +226,6 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b return ids, nil } -func (r *MongoRepository) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { - return listProjectedStorables(ctx, r.collection, query, bson.M{ - storable.IDField: 1, - storable.PermissionRefField: 1, - storable.OrganizationRefField: 1, - }, func(doc *model.PermissionBound) model.PermissionBoundStorable { - return doc - }) -} - -func (r *MongoRepository) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) { - return listProjectedStorables(ctx, r.collection, query, bson.M{ - storable.IDField: 1, - model.AccountRefField: 1, - model.OrganizationRefField: 1, - }, func(doc *model.AccountBoundBase) model.AccountBoundStorable { - return doc - }) -} - -func listProjectedStorables[T any, R any]( - ctx context.Context, - collection *mongo.Collection, - query builder.Query, - projection bson.M, - wrap func(*T) R, -) ([]R, error) { - filter := query.BuildQuery() - cursor, err := collection.Find(ctx, filter, options.Find().SetProjection(projection)) - if err != nil { - return nil, err - } - defer func() { - _ = cursor.Close(ctx) - }() - - result := make([]R, 0) - for cursor.Next(ctx) { - doc := new(T) - if err := cursor.Decode(doc); err != nil { - return nil, err - } - result = append(result, wrap(doc)) - } - if err := cursor.Err(); err != nil { - return nil, err - } - - return result, nil -} - func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error { _, err := r.collection.DeleteOne(ctx, idFilter(id)) return err diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go index e90e6fb..4adf08d 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go @@ -446,72 +446,6 @@ func TestMongoRepository_Name(t *testing.T) { }) } -func TestMongoRepository_ListPermissionBound(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - mongoContainer, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), - ) - require.NoError(t, err, "failed to start MongoDB container") - defer terminate(ctx, t, mongoContainer) - - mongoURI, err := mongoContainer.ConnectionString(ctx) - require.NoError(t, err, "failed to get MongoDB connection string") - - clientOptions := options.Client().ApplyURI(mongoURI) - client, err := mongo.Connect(clientOptions) - require.NoError(t, err, "failed to connect to MongoDB") - defer disconnect(ctx, t, client) - - db := client.Database("testdb") - repository := repositoryimp.NewMongoRepository(db, "testcollection") - - t.Run("ListPermissionBound_WithData", func(t *testing.T) { - // Insert test objects with permission bound data - orgID := bson.NewObjectID() - - // Insert documents directly with permission bound fields - _, err := db.Collection("testcollection").InsertMany(ctx, []interface{}{ - bson.M{ - "_id": bson.NewObjectID(), - "organizationRef": orgID, - "permissionRef": bson.NewObjectID(), - }, - bson.M{ - "_id": bson.NewObjectID(), - "organizationRef": orgID, - "permissionRef": bson.NewObjectID(), - }, - }) - require.NoError(t, err) - - // Query for permission bound objects - query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, orgID) - - results, err := repository.ListPermissionBound(ctx, query) - require.NoError(t, err) - assert.Len(t, results, 2) - - for _, result := range results { - assert.Equal(t, orgID, result.GetOrganizationRef()) - assert.NotNil(t, result.GetPermissionRef()) - } - }) - - t.Run("ListPermissionBound_EmptyResult", func(t *testing.T) { - nonExistentOrgID := bson.NewObjectID() - query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, nonExistentOrgID) - - results, err := repository.ListPermissionBound(ctx, query) - require.NoError(t, err) - assert.Empty(t, results) - }) -} - func TestMongoRepository_UpdateTimestamp(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/backend/pkg/db/internal/mongo/rolesdb/db.go b/backend/pkg/db/internal/mongo/rolesdb/db.go deleted file mode 100644 index 4ef1f4b..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/db.go +++ /dev/null @@ -1,21 +0,0 @@ -package rolesdb - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type RolesDB struct { - template.DBImp[*model.RoleDescription] -} - -func Create(logger mlogger.Logger, db *mongo.Database) (*RolesDB, error) { - p := &RolesDB{ - DBImp: *template.Create[*model.RoleDescription](logger, mservice.Roles, db), - } - - return p, nil -} diff --git a/backend/pkg/db/internal/mongo/rolesdb/list.go b/backend/pkg/db/internal/mongo/rolesdb/list.go deleted file mode 100644 index 9f56adb..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/list.go +++ /dev/null @@ -1,15 +0,0 @@ -package rolesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *RolesDB) List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) { - filter := repository.OrgFilter(organizationRef) - return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, cursor, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/rolesdb/roles.go b/backend/pkg/db/internal/mongo/rolesdb/roles.go deleted file mode 100644 index 89a289f..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/roles.go +++ /dev/null @@ -1,15 +0,0 @@ -package rolesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *RolesDB) Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) { - filter := repository.Query().In(repository.IDField(), refs) - return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/transactionimp/transaction.go b/backend/pkg/db/internal/mongo/transactionimp/transaction.go index 4996ee5..73d09f6 100644 --- a/backend/pkg/db/internal/mongo/transactionimp/transaction.go +++ b/backend/pkg/db/internal/mongo/transactionimp/transaction.go @@ -12,6 +12,10 @@ type MongoTransaction struct { } func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + // Reuse an outer customer workflow transaction when a token store joins it. + if transaction.ActiveMongoTransaction(ctx) { + return cb(ctx) + } session, err := mt.client.StartSession() if err != nil { return nil, err diff --git a/backend/pkg/db/invitation/invitation.go b/backend/pkg/db/invitation/invitation.go deleted file mode 100644 index 1fbc43b..0000000 --- a/backend/pkg/db/invitation/invitation.go +++ /dev/null @@ -1,19 +0,0 @@ -package invitation - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type DB interface { - auth.ProtectedDB[*model.Invitation] - GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) - Accept(ctx context.Context, invitationRef bson.ObjectID) error - Decline(ctx context.Context, invitationRef bson.ObjectID) error - List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) - DeleteCascade(ctx context.Context, accountRef, statusRef bson.ObjectID) error - SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error -} diff --git a/backend/pkg/db/permissions_config_test.go b/backend/pkg/db/permissions_config_test.go deleted file mode 100644 index 95140dc..0000000 --- a/backend/pkg/db/permissions_config_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package db - -import ( - "errors" - "strings" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -func TestPermissionsDatabaseConfigAsDatabaseConfig(t *testing.T) { - cfg := &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_MONGO_DATABASE", - }, - } - - converted := cfg.AsDatabaseConfig() - if converted == nil { - t.Fatal("expected converted config") - } - if converted.Driver != Mongo { - t.Fatalf("expected driver %q, got %q", Mongo, converted.Driver) - } - if got := converted.Settings["database_env"]; got != "PERMISSIONS_MONGO_DATABASE" { - t.Fatalf("expected database_env to be preserved, got %v", got) - } -} - -func TestNewCentralPermissionsConnectionRequiresConfig(t *testing.T) { - _, err := NewCentralPermissionsConnection(zap.NewNop(), nil) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestNewConnectionRequiresConfig(t *testing.T) { - _, err := NewConnection(zap.NewNop(), nil) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestNewCentralPermissionsConnectionDelegatesToDBConnection(t *testing.T) { - _, err := NewCentralPermissionsConnection(zap.NewNop(), &PermissionsDatabaseConfig{ - Driver: DBDriver("bogus"), - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "permissions_database") { - t.Fatalf("expected permissions_database context, got %v", err) - } - if !strings.Contains(err.Error(), "unknown database driver") { - t.Fatalf("expected delegated database driver error, got %v", err) - } -} - -func TestValidateSplitDomainPermissionsDatabase(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - t.Setenv("PERMISSIONS_DB", "sendico") - - domain, permissions, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_DB", - }, - }, - ) - if err != nil { - t.Fatalf("expected valid split-domain config, got %v", err) - } - if domain.Name != "domain" || permissions.Name != "sendico" { - t.Fatalf("unexpected resolved names: domain=%+v permissions=%+v", domain, permissions) - } -} - -func TestValidateSplitDomainPermissionsDatabaseRejectsMissingPermissionsConfig(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - - _, _, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - nil, - ) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestValidateSplitDomainPermissionsDatabaseRejectsSameResolvedDatabase(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - t.Setenv("PERMISSIONS_DB", "domain") - - _, _, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_DB", - }, - }, - ) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} diff --git a/backend/pkg/db/policy/policy.go b/backend/pkg/db/policy/policy.go deleted file mode 100644 index 095a4a6..0000000 --- a/backend/pkg/db/policy/policy.go +++ /dev/null @@ -1,17 +0,0 @@ -package policy - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type DB interface { - template.DB[*model.PolicyDescription] - All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) - Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) - GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error -} diff --git a/backend/pkg/db/repository/abfilter.go b/backend/pkg/db/repository/abfilter.go deleted file mode 100644 index 3501db5..0000000 --- a/backend/pkg/db/repository/abfilter.go +++ /dev/null @@ -1,78 +0,0 @@ -package repository - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// AccountBoundFilter provides factory methods for creating account-bound filters -type AccountBoundFilter struct{} - -// NewAccountBoundFilter creates a new AccountBoundFilter instance -func NewAccountBoundFilter() *AccountBoundFilter { - return &AccountBoundFilter{} -} - -// WithoutOrg creates a filter for account-bound objects without organization filter -// This filter finds objects where: -// - accountRef matches the provided accountRef, OR -// - accountRef is nil/null, OR -// - accountRef field doesn't exist -func (f *AccountBoundFilter) WithoutOrg(accountRef bson.ObjectID) builder.Query { - return Query().Or( - AccountFilter(accountRef), - Filter(model.AccountRefField, nil), - Exists(AccountField(), false), - ) -} - -// WithOrg creates a filter for account-bound objects with organization filter -// This filter finds objects where: -// - accountRef matches the provided accountRef, OR -// - accountRef is nil/null, OR -// - accountRef field doesn't exist -// AND combines with organization filter -func (f *AccountBoundFilter) WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { - return Query().And( - OrgFilter(organizationRef), - f.WithoutOrg(accountRef), - ) -} - -// WithQuery creates a filter for account-bound objects with additional query and organization filter -func (f *AccountBoundFilter) WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - accountQuery := f.WithOrg(accountRef, organizationRef) - return additionalQuery.And(accountQuery) -} - -// WithQueryNoOrg creates a filter for account-bound objects with additional query but no org filter -func (f *AccountBoundFilter) WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - accountQuery := f.WithoutOrg(accountRef) - return additionalQuery.And(accountQuery) -} - -// Global instance for convenience -var DefaultAccountBoundFilter = NewAccountBoundFilter() - -// Convenience functions that use the global factory instance - -// WithOrg is a convenience function that uses the default factory -func WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { - return DefaultAccountBoundFilter.WithOrg(accountRef, organizationRef) -} - -// WithoutOrg is a convenience function that uses the default factory -func WithoutOrg(accountRef bson.ObjectID) builder.Query { - return DefaultAccountBoundFilter.WithoutOrg(accountRef) -} - -// WithQuery is a convenience function that uses the default factory -func WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - return DefaultAccountBoundFilter.WithQuery(accountRef, organizationRef, additionalQuery) -} - -// WithQueryNoOrg is a convenience function that uses the default factory -func WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - return DefaultAccountBoundFilter.WithQueryNoOrg(accountRef, additionalQuery) -} diff --git a/backend/pkg/db/repository/builders.go b/backend/pkg/db/repository/builders.go index 54543cf..ccf15ec 100644 --- a/backend/pkg/db/repository/builders.go +++ b/backend/pkg/db/repository/builders.go @@ -68,22 +68,6 @@ func IsArchivedFilter(isArchived bool) builder.Query { return Query().Filter(IsArchivedField(), isArchived) } -func OrgField() builder.Field { - return Field(storable.OrganizationRefField) -} - -func OrgFilter(ref bson.ObjectID) builder.Query { - return Query().Filter(OrgField(), ref) -} - -func ProjectField() builder.Field { - return Field("projectRef") -} - -func ProjectFilter(ref bson.ObjectID) builder.Query { - return Query().Filter(ProjectField(), ref) -} - func AccountField() builder.Field { return Field(model.AccountRefField) } diff --git a/backend/pkg/db/repository/filter_factory_test.go b/backend/pkg/db/repository/filter_factory_test.go deleted file mode 100644 index 9ba32e8..0000000 --- a/backend/pkg/db/repository/filter_factory_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestAccountBoundFilter_WithOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - query := factory.WithOrg(accountRef, orgRef) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithoutOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - - query := factory.WithoutOrg(accountRef) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithQuery(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - query := factory.WithQuery(accountRef, orgRef, additionalQuery) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithQueryNoOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - query := factory.WithQueryNoOrg(accountRef, additionalQuery) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestDefaultAccountBoundFilter(t *testing.T) { - // Test that the default factory is not nil - assert.NotNil(t, DefaultAccountBoundFilter) - - // Test that it's the correct type - assert.IsType(t, &AccountBoundFilter{}, DefaultAccountBoundFilter) -} - -func TestConvenienceFunctions(t *testing.T) { - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - // Test convenience functions - query1 := WithOrg(accountRef, orgRef) - assert.NotNil(t, query1) - - query2 := WithoutOrg(accountRef) - assert.NotNil(t, query2) - - query3 := WithQuery(accountRef, orgRef, additionalQuery) - assert.NotNil(t, query3) - - query4 := WithQueryNoOrg(accountRef, additionalQuery) - assert.NotNil(t, query4) -} - -func TestFilterFactoryConsistency(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - // Test that factory methods and convenience functions produce the same result - query1 := factory.WithOrg(accountRef, orgRef) - query2 := WithOrg(accountRef, orgRef) - - // Both should be valid queries - assert.NotNil(t, query1) - assert.NotNil(t, query2) -} diff --git a/backend/pkg/db/repository/repository.go b/backend/pkg/db/repository/repository.go index e2719b3..00bab1d 100644 --- a/backend/pkg/db/repository/repository.go +++ b/backend/pkg/db/repository/repository.go @@ -9,7 +9,6 @@ import ( ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) @@ -50,8 +49,6 @@ type Repository interface { DropIndex(ctx context.Context, name string) error ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) - ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) - ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) Collection() string } diff --git a/backend/pkg/db/role/role.go b/backend/pkg/db/role/role.go deleted file mode 100644 index 2d5009d..0000000 --- a/backend/pkg/db/role/role.go +++ /dev/null @@ -1,15 +0,0 @@ -package role - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type DB interface { - template.DB[*model.RoleDescription] - Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) - List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) -} diff --git a/backend/pkg/db/storable/id.go b/backend/pkg/db/storable/id.go index 58d0637..8259e78 100644 --- a/backend/pkg/db/storable/id.go +++ b/backend/pkg/db/storable/id.go @@ -7,11 +7,9 @@ import ( ) const ( - IDField = "_id" - PermissionRefField = "permissionRef" - OrganizationRefField = "organizationRef" - IsArchivedField = "isArchived" - UpdatedAtField = "updatedAt" + IDField = "_id" + IsArchivedField = "isArchived" + UpdatedAtField = "updatedAt" ) type Base struct { diff --git a/backend/pkg/db/template/template.go b/backend/pkg/db/template/template.go index 5aff65f..e1ad219 100644 --- a/backend/pkg/db/template/template.go +++ b/backend/pkg/db/template/template.go @@ -8,7 +8,6 @@ import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" @@ -72,10 +71,6 @@ func (db *DBImp[T]) ListIDs(ctx context.Context, query builder.Query) ([]bson.Ob return db.Repository.ListIDs(ctx, query) } -func (db *DBImp[T]) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { - return db.Repository.ListPermissionBound(ctx, query) -} - func (db *DBImp[T]) SetDeleter(deleter CascadeDeleterT) { db.Logger.Debug("Custom cascade deletion method installed") db.cdeleter = deleter diff --git a/backend/pkg/db/template/tseries.go b/backend/pkg/db/template/tseries.go deleted file mode 100644 index 304be17..0000000 --- a/backend/pkg/db/template/tseries.go +++ /dev/null @@ -1,14 +0,0 @@ -package template - -import ( - "context" - - tspoint "git.sendico.io/tech/sxchnge/bff/pkg/db/tseries/point" -) - -type TimeSeries[T tspoint.TimePoint] interface { - // Insert adds a single point into the series. - Insert(ctx context.Context, point T) error - // InsertMany adds multiple points in one bulk operation. - InsertMany(ctx context.Context, points []T) error -} diff --git a/backend/pkg/db/tseries/point/interface.go b/backend/pkg/db/tseries/point/interface.go deleted file mode 100644 index efb9483..0000000 --- a/backend/pkg/db/tseries/point/interface.go +++ /dev/null @@ -1,14 +0,0 @@ -package tspoint - -import ( - "time" -) - -type TimePoint interface { - GetTimeStamp() time.Time -} - -type TimePointWithMeta[T any] interface { - GetTimeStamp() time.Time - GetMeta() T -} diff --git a/backend/pkg/decimal/money.go b/backend/pkg/decimal/money.go deleted file mode 100644 index ee24707..0000000 --- a/backend/pkg/decimal/money.go +++ /dev/null @@ -1,129 +0,0 @@ -package decimal - -import ( - "math/big" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// Money represents a monetary amount with currency and precision handling -type Money struct { - Amount *big.Rat - Currency string - Scale uint32 // decimal places for this currency -} - -// NewMoney creates a new Money value from a decimal string -func NewMoney(amount, currency string, scale uint32) (*Money, error) { - rat, err := RatFromString(amount) - if err != nil { - return nil, err - } - return &Money{ - Amount: rat, - Currency: currency, - Scale: scale, - }, nil -} - -// Add adds two money values (must have same currency) -func (m *Money) Add(other *Money) (*Money, error) { - if m.Currency != other.Currency { - return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return &Money{ - Amount: AddRat(m.Amount, other.Amount), - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Sub subtracts two money values (must have same currency) -func (m *Money) Sub(other *Money) (*Money, error) { - if m.Currency != other.Currency { - return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return &Money{ - Amount: SubRat(m.Amount, other.Amount), - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Mul multiplies money by a rational number (for fees, percentages, etc.) -func (m *Money) Mul(factor *big.Rat) *Money { - return &Money{ - Amount: MulRat(m.Amount, factor), - Currency: m.Currency, - Scale: m.Scale, - } -} - -// Div divides money by a rational number -func (m *Money) Div(divisor *big.Rat) (*Money, error) { - result, err := DivRat(m.Amount, divisor) - if err != nil { - return nil, err - } - return &Money{ - Amount: result, - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Negate returns the negative of this money value -func (m *Money) Negate() *Money { - return &Money{ - Amount: NegRat(m.Amount), - Currency: m.Currency, - Scale: m.Scale, - } -} - -// Round rounds the money to its scale using the specified rounding mode -func (m *Money) Round(mode RoundingMode) (*Money, error) { - rounded, err := RoundRatToScale(m.Amount, m.Scale, mode) - if err != nil { - return nil, err - } - return &Money{ - Amount: rounded, - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// String returns the formatted money value -func (m *Money) String() string { - return FormatRat(m.Amount, m.Scale) + " " + m.Currency -} - -// StringAmount returns just the formatted amount -func (m *Money) StringAmount() string { - return FormatRat(m.Amount, m.Scale) -} - -// IsZero checks if the money amount is zero -func (m *Money) IsZero() bool { - return IsZero(m.Amount) -} - -// IsPositive checks if the money amount is positive -func (m *Money) IsPositive() bool { - return IsPositive(m.Amount) -} - -// IsNegative checks if the money amount is negative -func (m *Money) IsNegative() bool { - return IsNegative(m.Amount) -} - -// Cmp compares two money values (must have same currency) -// Returns -1 if m < other, 0 if m == other, 1 if m > other -func (m *Money) Cmp(other *Money) (int, error) { - if m.Currency != other.Currency { - return 0, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return CmpRat(m.Amount, other.Amount), nil -} diff --git a/backend/pkg/decimal/rational.go b/backend/pkg/decimal/rational.go deleted file mode 100644 index 81a44f8..0000000 --- a/backend/pkg/decimal/rational.go +++ /dev/null @@ -1,161 +0,0 @@ -package decimal - -import ( - "math/big" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// RatFromString parses a decimal string into a big.Rat -// Supports standard decimal notation like "123.45", "-0.001", etc. -func RatFromString(value string) (*big.Rat, error) { - if strings.TrimSpace(value) == "" { - return nil, merrors.InvalidArgument("decimal: empty value") - } - r := new(big.Rat) - if _, ok := r.SetString(value); !ok { - return nil, merrors.InvalidArgument("decimal: invalid decimal value: " + value) - } - return r, nil -} - -// MulRat multiplies two rational numbers -func MulRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Mul(a, b) -} - -// DivRat divides two rational numbers -func DivRat(a, b *big.Rat) (*big.Rat, error) { - if b.Sign() == 0 { - return nil, merrors.InvalidArgument("decimal: division by zero") - } - return new(big.Rat).Quo(a, b), nil -} - -// AddRat adds two rational numbers -func AddRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Add(a, b) -} - -// SubRat subtracts two rational numbers (a - b) -func SubRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Sub(a, b) -} - -// NegRat negates a rational number -func NegRat(a *big.Rat) *big.Rat { - return new(big.Rat).Neg(a) -} - -// RoundRatToScale rounds a rational number to a specific number of decimal places -// using the specified rounding mode -func RoundRatToScale(value *big.Rat, scale uint32, mode RoundingMode) (*big.Rat, error) { - scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - numerator := new(big.Int).Mul(new(big.Int).Set(value.Num()), scaleFactor) - denominator := value.Denom() - - quotient := new(big.Int) - remainder := new(big.Int) - quotient.QuoRem(numerator, denominator, remainder) - - // No remainder, already exact - if remainder.Sign() == 0 { - return new(big.Rat).SetFrac(quotient, scaleFactor), nil - } - - sign := quotient.Sign() - absQuotient := new(big.Int).Abs(new(big.Int).Set(quotient)) - absRemainder := new(big.Int).Abs(remainder) - absDenominator := new(big.Int).Abs(denominator) - - doubledRemainder := new(big.Int).Mul(absRemainder, big.NewInt(2)) - cmp := doubledRemainder.Cmp(absDenominator) - - shouldIncrement := false - - switch mode { - case RoundingModeDown: - shouldIncrement = false - case RoundingModeHalfUp: - if cmp >= 0 { - shouldIncrement = true - } - case RoundingModeHalfEven, RoundingModeUnspecified: - if cmp > 0 { - shouldIncrement = true - } else if cmp == 0 { - // Tie: round to even - if absQuotient.Bit(0) == 1 { - shouldIncrement = true - } - } - default: - // Default to HALF_EVEN - if cmp > 0 { - shouldIncrement = true - } else if cmp == 0 { - if absQuotient.Bit(0) == 1 { - shouldIncrement = true - } - } - } - - if shouldIncrement { - if sign < 0 { - absQuotient.Add(absQuotient, big.NewInt(1)) - quotient = absQuotient.Neg(absQuotient) - } else { - absQuotient.Add(absQuotient, big.NewInt(1)) - quotient = absQuotient - } - } - - return new(big.Rat).SetFrac(quotient, scaleFactor), nil -} - -// FormatRat formats a rational number as a decimal string with the specified scale -func FormatRat(r *big.Rat, scale uint32) string { - sign := "" - if r.Sign() < 0 { - sign = "-" - } - - absRat := new(big.Rat).Abs(r) - scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - numerator := new(big.Int).Mul(absRat.Num(), scaleFactor) - numerator.Quo(numerator, absRat.Denom()) - - intStr := numerator.String() - if scale == 0 { - return sign + intStr - } - - if len(intStr) <= int(scale) { - intStr = strings.Repeat("0", int(scale)-len(intStr)+1) + intStr - } - - pointPos := len(intStr) - int(scale) - return sign + intStr[:pointPos] + "." + intStr[pointPos:] -} - -// CmpRat compares two rational numbers -// Returns -1 if a < b, 0 if a == b, 1 if a > b -func CmpRat(a, b *big.Rat) int { - return a.Cmp(b) -} - -// IsZero checks if a rational number is zero -func IsZero(r *big.Rat) bool { - return r.Sign() == 0 -} - -// IsPositive checks if a rational number is positive -func IsPositive(r *big.Rat) bool { - return r.Sign() > 0 -} - -// IsNegative checks if a rational number is negative -func IsNegative(r *big.Rat) bool { - return r.Sign() < 0 -} diff --git a/backend/pkg/decimal/rounding.go b/backend/pkg/decimal/rounding.go deleted file mode 100644 index 65b345d..0000000 --- a/backend/pkg/decimal/rounding.go +++ /dev/null @@ -1,29 +0,0 @@ -package decimal - -// RoundingMode specifies how to round decimal values -type RoundingMode int - -const ( - // RoundingModeUnspecified defaults to HALF_EVEN - RoundingModeUnspecified RoundingMode = iota - // RoundingModeHalfEven rounds to nearest, ties to even (banker's rounding) - RoundingModeHalfEven - // RoundingModeHalfUp rounds to nearest, ties away from zero - RoundingModeHalfUp - // RoundingModeDown rounds toward zero (truncate) - RoundingModeDown -) - -// String returns the string representation of the rounding mode -func (r RoundingMode) String() string { - switch r { - case RoundingModeHalfEven: - return "HALF_EVEN" - case RoundingModeHalfUp: - return "HALF_UP" - case RoundingModeDown: - return "DOWN" - default: - return "HALF_EVEN" - } -} diff --git a/backend/pkg/merrors/accounting.go b/backend/pkg/merrors/accounting.go deleted file mode 100644 index e5f34a8..0000000 --- a/backend/pkg/merrors/accounting.go +++ /dev/null @@ -1,25 +0,0 @@ -package merrors - -var ErrAccountingInvalidPosting = Define("accounting.invalid_posting") - -var ErrAccountingUnbalancedPosting = Define("accounting.unbalanced_posting") - -var ErrAccountingAssetMismatch = Define("accounting.asset_mismatch") - -var ErrAccountingAccountNotFound = Define("accounting.account_not_found") - -var ErrAccountingAccountInactive = Define("accounting.account_inactive") - -var ErrAccountingInvalidAmount = Define("accounting.invalid_amount") - -var ErrAccountingInvalidIdempotency = Define("accounting.invalid_idempotency") - -var ErrAccountingIdempotencyConflict = Define("accounting.idempotency_conflict") - -var ErrAccountingStorageFailed = Define("accounting.storage_failed") - -var ErrAccountingDuplicateKey = Define("accounting.duplicate_key") - -var ErrAccountingOptimisticConflict = Define("accounting.optimistic_conflict") - -var ErrAccountingInvalidStorageState = Define("accounting.invalid_storage_state") diff --git a/backend/pkg/merrors/aml.go b/backend/pkg/merrors/aml.go deleted file mode 100644 index c544dfc..0000000 --- a/backend/pkg/merrors/aml.go +++ /dev/null @@ -1,20 +0,0 @@ -package merrors - -import "strings" - -const ReasonAMLSourceIdempotencyConflict = "AML_SOURCE_IDEMPOTENCY_CONFLICT" - -var ErrAMLSourceIdempotencyConflict = Define("aml.source_idempotency_conflict") - -func AMLSourceIdempotencyConflict(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = ErrAMLSourceIdempotencyConflict.Error() - } - return classifiedError{ - base: ErrDataConflict, - code: ErrAMLSourceIdempotencyConflict, - msg: msg, - reason: ReasonAMLSourceIdempotencyConflict, - } -} diff --git a/backend/pkg/merrors/aml_test.go b/backend/pkg/merrors/aml_test.go deleted file mode 100644 index bf03151..0000000 --- a/backend/pkg/merrors/aml_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestAMLSourceIdempotencyConflictReasonRoundTrip(t *testing.T) { - err := AMLSourceIdempotencyConflict("key is bound to another source mutation") - if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrAMLSourceIdempotencyConflict) { - t.Fatalf("AML source idempotency conflict classification mismatch: %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonAMLSourceIdempotencyConflict { - t.Fatalf("AML source idempotency reason mismatch: reason=%q ok=%t", reason, ok) - } - restored, ok := FromReason(reason, err.Error()) - if !ok || !errors.Is(restored, ErrAMLSourceIdempotencyConflict) { - t.Fatalf("AML source idempotency reason did not round trip: %v", restored) - } -} diff --git a/backend/pkg/merrors/chain_execution.go b/backend/pkg/merrors/chain_execution.go deleted file mode 100644 index 6dd96e0..0000000 --- a/backend/pkg/merrors/chain_execution.go +++ /dev/null @@ -1,21 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonChainExecutionSigningUnavailable = "chain_execution.signing_unavailable" - ReasonChainExecutionUnsupportedCapability = "chain_execution.unsupported_capability" - ReasonChainExecutionInsufficientBalance = "chain_execution.insufficient_balance" - ReasonChainExecutionInsufficientNativeResources = "chain_execution.insufficient_native_resources" - ReasonChainExecutionDestinationIneligible = "chain_execution.destination_ineligible" -) - -// ChainExecutionPrecondition creates a typed, reason-bearing permanent -// preparation failure. Callers can classify it with both errors.Is and Reason. -func ChainExecutionPrecondition(reason, message string) error { - return classifiedError{ - base: ErrFailedPrecondition, - msg: strings.TrimSpace(message), - reason: strings.TrimSpace(reason), - } -} diff --git a/backend/pkg/merrors/chain_execution_test.go b/backend/pkg/merrors/chain_execution_test.go deleted file mode 100644 index 85831a6..0000000 --- a/backend/pkg/merrors/chain_execution_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestChainExecutionPreconditionCarriesClassAndReason(t *testing.T) { - err := ChainExecutionPrecondition(ReasonChainExecutionInsufficientBalance, "source balance is insufficient") - if !errors.Is(err, ErrFailedPrecondition) { - t.Fatal("expected failed-precondition classification") - } - reason, ok := Reason(err) - if !ok || reason != ReasonChainExecutionInsufficientBalance { - t.Fatalf("unexpected reason: %q", reason) - } -} diff --git a/backend/pkg/merrors/documents.go b/backend/pkg/merrors/documents.go deleted file mode 100644 index f94d13e..0000000 --- a/backend/pkg/merrors/documents.go +++ /dev/null @@ -1,44 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonDocumentsReportOwned = "DOCUMENTS_REPORT_OWNED" - ReasonDocumentsQuotationExpired = "DOCUMENTS_QUOTATION_EXPIRED" -) - -var ErrDocumentsReportOwned = Define("documents.report_owned") - -var ErrDocumentsQuotationExpired = Define("documents.quotation_expired") - -// DocumentsReportOwned reports that an active or settled payout owns a -// report, so callers must not mutate its document lifecycle. -func DocumentsReportOwned(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "report is owned by a payout" - } - return classifiedError{ - base: ErrDataConflict, - code: ErrDocumentsReportOwned, - msg: msg, - reason: ReasonDocumentsReportOwned, - publicMessage: true, - } -} - -// DocumentsQuotationExpired reports that a payout registry still references -// a quotation which can no longer be executed. A fresh quotation is required. -func DocumentsQuotationExpired(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "the payout quotation has expired; request a new quotation" - } - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrDocumentsQuotationExpired, - msg: msg, - reason: ReasonDocumentsQuotationExpired, - publicMessage: true, - } -} diff --git a/backend/pkg/merrors/documents_test.go b/backend/pkg/merrors/documents_test.go deleted file mode 100644 index 6b048c6..0000000 --- a/backend/pkg/merrors/documents_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestDocumentsReportOwnedReasonRoundTrip(t *testing.T) { - err := DocumentsReportOwned("report has an active payout") - if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrDocumentsReportOwned) { - t.Fatalf("DocumentsReportOwned() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonDocumentsReportOwned { - t.Fatalf("Reason() = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, "report has an active payout") - if !ok || !errors.Is(restored, ErrDataConflict) || !errors.Is(restored, ErrDocumentsReportOwned) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} - -func TestDocumentsQuotationExpiredReasonRoundTrip(t *testing.T) { - err := DocumentsQuotationExpired("quotation expired") - if !errors.Is(err, ErrFailedPrecondition) || !errors.Is(err, ErrDocumentsQuotationExpired) { - t.Fatalf("DocumentsQuotationExpired() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonDocumentsQuotationExpired { - t.Fatalf("Reason() = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, "quotation expired") - if !ok || !errors.Is(restored, ErrFailedPrecondition) || - !errors.Is(restored, ErrDocumentsQuotationExpired) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} diff --git a/backend/pkg/merrors/errors.go b/backend/pkg/merrors/errors.go index dceb396..186b203 100644 --- a/backend/pkg/merrors/errors.go +++ b/backend/pkg/merrors/errors.go @@ -223,12 +223,6 @@ func Unauthorizedf(format string, args ...any) error { return Unauthorized(fmt.Sprintf(format, args...)) } -var ErrNoMessagingTopic = Define("messagingTopicError") - -func NoMessagingTopic(topic string) error { - return Wrapf(ErrNoMessagingTopic, "messaging topic '%s' not found", topic) -} - func InvalidArgumentWrap(err error, msg string, argumentNames ...string) error { return wrapError(ErrInvalidArg, invalidArgumentMessage(msg, argumentNames...), err) } diff --git a/backend/pkg/merrors/ledger.go b/backend/pkg/merrors/ledger.go deleted file mode 100644 index bfaa6f9..0000000 --- a/backend/pkg/merrors/ledger.go +++ /dev/null @@ -1,65 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonLedgerIdempotencyConflict = "LEDGER_IDEMPOTENCY_CONFLICT" - ReasonLedgerCommandIdentityMismatch = "LEDGER_COMMAND_IDENTITY_MISMATCH" - ReasonLedgerAccessViolation = "LEDGER_ACCESS_VIOLATION" - ReasonAccountNotEligible = "ACCOUNT_NOT_ELIGIBLE" -) - -var ErrLedgerIdempotencyConflict = Define("ledger.idempotency_conflict") - -var ErrLedgerCommandIdentityMismatch = Define("ledger.command_identity_mismatch") - -var ErrLedgerAccessViolation = Define("ledger.access_violation") - -var ErrAccountNotEligible = Define("account_not_eligible") - -func LedgerIdempotencyConflict(msg string) error { - return classifiedError{ - base: ErrDataConflict, - code: ErrLedgerIdempotencyConflict, - msg: ledgerErrorMessage(msg, ErrLedgerIdempotencyConflict), - reason: ReasonLedgerIdempotencyConflict, - } -} - -func LedgerCommandIdentityMismatch(msg string) error { - return classifiedError{ - base: ErrDataConflict, - code: ErrLedgerCommandIdentityMismatch, - msg: ledgerErrorMessage(msg, ErrLedgerCommandIdentityMismatch), - reason: ReasonLedgerCommandIdentityMismatch, - } -} - -func LedgerAccessViolation(msg string) error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrLedgerAccessViolation, - msg: ledgerErrorMessage(msg, ErrLedgerAccessViolation), - reason: ReasonLedgerAccessViolation, - } -} - -func AccountNotEligible(msg string) error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrAccountNotEligible, - msg: ledgerErrorMessage(msg, ErrAccountNotEligible), - reason: ReasonAccountNotEligible, - } -} - -func ledgerErrorMessage(msg string, fallback error) string { - msg = strings.TrimSpace(msg) - if msg != "" { - return msg - } - if fallback == nil { - return "" - } - return fallback.Error() -} diff --git a/backend/pkg/merrors/ledger_test.go b/backend/pkg/merrors/ledger_test.go deleted file mode 100644 index 05287d7..0000000 --- a/backend/pkg/merrors/ledger_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestLedgerReasonRoundTrip(t *testing.T) { - err := LedgerIdempotencyConflict("conflicting command") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonLedgerIdempotencyConflict, reason) - - restored, ok := FromReason(reason, "conflicting command") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrLedgerIdempotencyConflict)) - require.True(t, errors.Is(restored, ErrDataConflict)) -} - -func TestAccountNotEligibleReasonRoundTrip(t *testing.T) { - err := AccountNotEligible("account is frozen") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonAccountNotEligible, reason) - require.True(t, errors.Is(err, ErrAccountNotEligible)) - require.True(t, errors.Is(err, ErrFailedPrecondition)) - - restored, ok := FromReason(reason, "account is frozen") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrAccountNotEligible)) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) -} - -func TestLedgerAccessViolationReasonRoundTrip(t *testing.T) { - err := LedgerAccessViolation("from_account: account does not belong to organization") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonLedgerAccessViolation, reason) - require.True(t, errors.Is(err, ErrLedgerAccessViolation)) - require.True(t, errors.Is(err, ErrFailedPrecondition)) - - restored, ok := FromReason(reason, "from_account: account does not belong to organization") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrLedgerAccessViolation)) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) -} diff --git a/backend/pkg/merrors/limits.go b/backend/pkg/merrors/limits.go deleted file mode 100644 index 8e16a8f..0000000 --- a/backend/pkg/merrors/limits.go +++ /dev/null @@ -1,19 +0,0 @@ -package merrors - -var ErrLimitsRuntimeInvalidRecord = Define("limits.runtime.invalid_record") - -var ErrLimitsRuntimeCASConflict = Define("limits.runtime.cas_conflict") - -var ErrLimitsRuntimeDuplicateStateKey = Define("limits.runtime.duplicate_state_key") - -var ErrLimitsRuntimeDuplicateStateRef = Define("limits.runtime.duplicate_state_ref") - -var ErrLimitsRuntimeDuplicateReservationRef = Define("limits.runtime.duplicate_reservation_ref") - -var ErrLimitsRuntimeDuplicateReservationOperation = Define("limits.runtime.duplicate_reservation_operation") - -var ErrLimitsRuntimeIdentityConflict = Define("limits.runtime.identity_conflict") - -var ErrLimitsRuntimeDataConflict = Define("limits.runtime.data_conflict") - -var ErrLimitsRuntimeRepositoryRequired = Define("limits.runtime.repository_required") diff --git a/backend/pkg/merrors/organization.go b/backend/pkg/merrors/organization.go deleted file mode 100644 index 591ada7..0000000 --- a/backend/pkg/merrors/organization.go +++ /dev/null @@ -1,66 +0,0 @@ -package merrors - -import "strings" - -// Reasons for organization provisioning outcomes. They travel to callers in -// gRPC error details so every outcome stays distinguishable regardless of the -// status code it shares with others. -const ( - ReasonOrganizationProvisioningInProgress = "organization.provisioning_in_progress" - ReasonOrganizationAlreadyActive = "organization.already_active" - ReasonOrganizationProvisioningBlocked = "organization.provisioning_blocked" - ReasonOrganizationOwnerMismatch = "organization.owner_mismatch" - ReasonOrganizationNotOperatorProvisioned = "organization.not_operator_provisioned" -) - -// ErrOrganizationProvisioningInProgress means another attempt currently holds -// the provisioning lease of the organization. -var ErrOrganizationProvisioningInProgress = Define(ReasonOrganizationProvisioningInProgress) - -// ErrOrganizationAlreadyActive means the organization finished provisioning already. -var ErrOrganizationAlreadyActive = Define(ReasonOrganizationAlreadyActive) - -// ErrOrganizationProvisioningBlocked means a failed signup compensated the -// organization and it requires manual recovery before it can be activated. -var ErrOrganizationProvisioningBlocked = Define(ReasonOrganizationProvisioningBlocked) - -// ErrOrganizationOwnerMismatch means the organization is being provisioned for -// a different account than the one supplied. -var ErrOrganizationOwnerMismatch = Define(ReasonOrganizationOwnerMismatch) - -// ErrOrganizationNotOperatorProvisioned means the organization was created through signup: -// organizations created through general signup are never finished by someone in backoffice. -var ErrOrganizationNotOperatorProvisioned = Define(ReasonOrganizationNotOperatorProvisioned) - -func OrganizationProvisioningInProgress(msg string) error { - return organizationError(ErrOrganizationProvisioningInProgress, ReasonOrganizationProvisioningInProgress, msg) -} - -func OrganizationAlreadyActive(msg string) error { - return organizationError(ErrOrganizationAlreadyActive, ReasonOrganizationAlreadyActive, msg) -} - -func OrganizationProvisioningBlocked(msg string) error { - return organizationError(ErrOrganizationProvisioningBlocked, ReasonOrganizationProvisioningBlocked, msg) -} - -func OrganizationOwnerMismatch(msg string) error { - return organizationError(ErrOrganizationOwnerMismatch, ReasonOrganizationOwnerMismatch, msg) -} - -func OrganizationNotOperatorProvisioned(msg string) error { - return organizationError(ErrOrganizationNotOperatorProvisioned, ReasonOrganizationNotOperatorProvisioned, msg) -} - -func organizationError(code error, reason, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" && code != nil { - msg = code.Error() - } - return classifiedError{ - base: ErrDataConflict, - code: code, - msg: msg, - reason: reason, - } -} diff --git a/backend/pkg/merrors/paymaster_control.go b/backend/pkg/merrors/paymaster_control.go deleted file mode 100644 index db12bec..0000000 --- a/backend/pkg/merrors/paymaster_control.go +++ /dev/null @@ -1,9 +0,0 @@ -package merrors - -var ErrPaymasterPaused = Define("paymaster_paused") - -var ErrControlWalletInsufficientNativeBalance = Define("control_wallet_insufficient_native_balance") - -var ErrPaymasterControlDurableWalletFailure = Define("paymaster_control_durable_wallet_failure") - -var ErrPaymasterControlKeyActivityMismatch = Define("paymaster_control_key_activity_mismatch") diff --git a/backend/pkg/merrors/payment_execution.go b/backend/pkg/merrors/payment_execution.go deleted file mode 100644 index 86b0b6a..0000000 --- a/backend/pkg/merrors/payment_execution.go +++ /dev/null @@ -1,47 +0,0 @@ -package merrors - -import "strings" - -const ReasonPaymentExecutionNotAccepted = "PAYMENT_EXECUTION_NOT_ACCEPTED" - -var ErrPaymentExecutionNotAccepted = Define("payment.execution_not_accepted") - -type paymentExecutionNotAcceptedError struct { - classifiedError - cause error -} - -func (e paymentExecutionNotAcceptedError) Unwrap() []error { - causes := e.classifiedError.Unwrap() - if e.cause != nil { - causes = append(causes, e.cause) - } - return causes -} - -// PaymentExecutionNotAccepted reports that the orchestrator proved an -// execution request did not create or reuse any payment. Callers may release -// request-side reservations only for this outcome; ordinary transport and -// internal errors have an ambiguous ownership result. -func PaymentExecutionNotAccepted(msg string) error { - return PaymentExecutionNotAcceptedWrap(nil, msg) -} - -// PaymentExecutionNotAcceptedWrap adds the definitive rejection -// classification while retaining the underlying cause for diagnostics. -func PaymentExecutionNotAcceptedWrap(cause error, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "payment execution was not accepted before a payment was created" - } - return paymentExecutionNotAcceptedError{ - classifiedError: classifiedError{ - base: ErrFailedPrecondition, - code: ErrPaymentExecutionNotAccepted, - msg: msg, - reason: ReasonPaymentExecutionNotAccepted, - publicMessage: true, - }, - cause: cause, - } -} diff --git a/backend/pkg/merrors/payment_execution_test.go b/backend/pkg/merrors/payment_execution_test.go deleted file mode 100644 index 82c5ae9..0000000 --- a/backend/pkg/merrors/payment_execution_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestPaymentExecutionNotAcceptedPreservesClassificationAndCause(t *testing.T) { - cause := ServiceUnavailable("orchestrator unavailable") - err := PaymentExecutionNotAcceptedWrap(cause, "execution was rejected") - - if !errors.Is(err, ErrFailedPrecondition) || - !errors.Is(err, ErrPaymentExecutionNotAccepted) || - !errors.Is(err, cause) { - t.Fatalf("PaymentExecutionNotAcceptedWrap() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonPaymentExecutionNotAccepted { - t.Fatalf("PaymentExecutionNotAcceptedWrap() reason = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, err.Error()) - if !ok || !errors.Is(restored, ErrPaymentExecutionNotAccepted) || - !errors.Is(restored, ErrFailedPrecondition) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} diff --git a/backend/pkg/merrors/quotation.go b/backend/pkg/merrors/quotation.go deleted file mode 100644 index b65c158..0000000 --- a/backend/pkg/merrors/quotation.go +++ /dev/null @@ -1,32 +0,0 @@ -package merrors - -const ( - QuotationFeeConfigurationMissingCode = "quotation_fee_configuration_missing" - QuotationFeeConfigurationMissingDetails = "quotation fee configuration is missing" - QuotationReplayMissingCode = "quotation_replay_missing" - QuotationReplayMissingDetails = "the expected payment quotation is no longer available" -) - -var ErrQuotationFeeConfigurationMissing = Define(QuotationFeeConfigurationMissingCode) - -var ErrQuotationReplayMissing = Define(QuotationReplayMissingCode) - -func QuotationFeeConfigurationMissing() error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrQuotationFeeConfigurationMissing, - msg: QuotationFeeConfigurationMissingDetails, - reason: QuotationFeeConfigurationMissingCode, - publicMessage: true, - } -} - -func QuotationReplayMissing() error { - return classifiedError{ - base: ErrNoData, - code: ErrQuotationReplayMissing, - msg: QuotationReplayMissingDetails, - reason: QuotationReplayMissingCode, - publicMessage: true, - } -} diff --git a/backend/pkg/merrors/quotation_test.go b/backend/pkg/merrors/quotation_test.go deleted file mode 100644 index 584af1e..0000000 --- a/backend/pkg/merrors/quotation_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestQuotationFeeConfigurationMissingReasonRoundTrip(t *testing.T) { - err := QuotationFeeConfigurationMissing() - - require.True(t, errors.Is(err, ErrFailedPrecondition)) - require.True(t, errors.Is(err, ErrQuotationFeeConfigurationMissing)) - - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, QuotationFeeConfigurationMissingCode, reason) - require.Equal(t, QuotationFeeConfigurationMissingDetails, err.Error()) - - restored, ok := FromReason(reason, "ignored message") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) - require.True(t, errors.Is(restored, ErrQuotationFeeConfigurationMissing)) - require.Equal(t, QuotationFeeConfigurationMissingDetails, restored.Error()) -} - -func TestQuotationReplayMissingReasonRoundTrip(t *testing.T) { - err := QuotationReplayMissing() - - require.True(t, errors.Is(err, ErrNoData)) - require.True(t, errors.Is(err, ErrQuotationReplayMissing)) - - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, QuotationReplayMissingCode, reason) - require.Equal(t, QuotationReplayMissingDetails, err.Error()) - - restored, ok := FromReason(reason, "ignored message") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrNoData)) - require.True(t, errors.Is(restored, ErrQuotationReplayMissing)) - require.Equal(t, QuotationReplayMissingDetails, restored.Error()) -} diff --git a/backend/pkg/merrors/reason.go b/backend/pkg/merrors/reason.go index 9205bac..240d668 100644 --- a/backend/pkg/merrors/reason.go +++ b/backend/pkg/merrors/reason.go @@ -15,91 +15,5 @@ func Reason(err error) (string, bool) { return reason, true } } - switch { - case errors.Is(err, ErrAMLSourceIdempotencyConflict): - return ReasonAMLSourceIdempotencyConflict, true - case errors.Is(err, ErrLedgerIdempotencyConflict): - return ReasonLedgerIdempotencyConflict, true - case errors.Is(err, ErrLedgerCommandIdentityMismatch): - return ReasonLedgerCommandIdentityMismatch, true - case errors.Is(err, ErrLedgerAccessViolation): - return ReasonLedgerAccessViolation, true - case errors.Is(err, ErrAccountNotEligible): - return ReasonAccountNotEligible, true - case errors.Is(err, ErrQuotationFeeConfigurationMissing): - return QuotationFeeConfigurationMissingCode, true - case errors.Is(err, ErrQuotationReplayMissing): - return QuotationReplayMissingCode, true - default: - return "", false - } -} - -func FromReason(reason string, msg string) (error, bool) { - switch strings.TrimSpace(reason) { - case ReasonRouteNotFound: - return RouteNotFound(msg), true - case ReasonRouteAmbiguous: - return RouteAmbiguous(msg), true - case ReasonRouteDisabled: - return RouteDisabled(msg), true - case ReasonRouteInvalidSource: - return RouteInvalidSource(msg), true - case ReasonRouteDestinationNotFound: - return RouteDestinationNotFound(msg), true - case ReasonRouteDestinationInactive: - return RouteDestinationInactive(msg), true - case ReasonRouteDestinationCurrencyMismatch: - return RouteDestinationCurrencyMismatch(msg), true - case ReasonRouteDestinationOrgMismatch: - return RouteDestinationOrgMismatch(msg), true - case ReasonRouteDestinationAmbiguous: - return RouteDestinationAmbiguous(msg), true - case ReasonRouteDestinationOwnerMismatch: - return RouteDestinationOwnerMismatch(msg), true - case ReasonRouteActiveSourceConflict: - return RouteActiveSourceConflict(msg), true - case ReasonRouteDestinationTypeMismatch: - return RouteDestinationTypeMismatch(msg), true - case ReasonRouteDestinationRoleMismatch: - return RouteDestinationRoleMismatch(msg), true - case ReasonRouteIdempotencyConflict: - return RouteIdempotencyConflict(msg), true - case ReasonRouteVersionConflict: - return RouteVersionConflict(msg), true - case ReasonRouteInvalidState: - return RouteInvalidState(msg), true - case ReasonAMLSourceIdempotencyConflict: - return AMLSourceIdempotencyConflict(msg), true - case ReasonLedgerIdempotencyConflict: - return LedgerIdempotencyConflict(msg), true - case ReasonLedgerCommandIdentityMismatch: - return LedgerCommandIdentityMismatch(msg), true - case ReasonLedgerAccessViolation: - return LedgerAccessViolation(msg), true - case ReasonAccountNotEligible: - return AccountNotEligible(msg), true - case ReasonDocumentsReportOwned: - return DocumentsReportOwned(msg), true - case ReasonDocumentsQuotationExpired: - return DocumentsQuotationExpired(msg), true - case ReasonPaymentExecutionNotAccepted: - return PaymentExecutionNotAccepted(msg), true - case QuotationFeeConfigurationMissingCode: - return QuotationFeeConfigurationMissing(), true - case QuotationReplayMissingCode: - return QuotationReplayMissing(), true - case ReasonOrganizationProvisioningInProgress: - return OrganizationProvisioningInProgress(msg), true - case ReasonOrganizationAlreadyActive: - return OrganizationAlreadyActive(msg), true - case ReasonOrganizationProvisioningBlocked: - return OrganizationProvisioningBlocked(msg), true - case ReasonOrganizationOwnerMismatch: - return OrganizationOwnerMismatch(msg), true - case ReasonOrganizationNotOperatorProvisioned: - return OrganizationNotOperatorProvisioned(msg), true - default: - return nil, false - } + return "", false } diff --git a/backend/pkg/merrors/routes.go b/backend/pkg/merrors/routes.go deleted file mode 100644 index 546018c..0000000 --- a/backend/pkg/merrors/routes.go +++ /dev/null @@ -1,127 +0,0 @@ -package merrors - -import "strings" - -const routeErrorCodePrefix = "ROUTE_ERROR_CODE_" - -type routeMetadataError struct { - classifiedError - routeCode string -} - -func (e routeMetadataError) ErrorInfoMetadata() map[string]string { - return map[string]string{"route_error_code": e.routeCode} -} - -const ( - ReasonRouteNotFound = "ROUTE_NOT_FOUND" - ReasonRouteAmbiguous = "ROUTE_AMBIGUOUS" - ReasonRouteDisabled = "ROUTE_DISABLED" - ReasonRouteInvalidSource = "ROUTE_INVALID_SOURCE" - ReasonRouteDestinationNotFound = "ROUTE_DESTINATION_NOT_FOUND" - ReasonRouteDestinationInactive = "ROUTE_DESTINATION_INACTIVE" - ReasonRouteDestinationCurrencyMismatch = "ROUTE_DESTINATION_CURRENCY_MISMATCH" - ReasonRouteDestinationOrgMismatch = "ROUTE_DESTINATION_ORG_MISMATCH" - ReasonRouteDestinationAmbiguous = "ROUTE_DESTINATION_AMBIGUOUS" - ReasonRouteDestinationOwnerMismatch = "ROUTE_DESTINATION_OWNER_MISMATCH" - ReasonRouteActiveSourceConflict = "ROUTE_ACTIVE_SOURCE_CONFLICT" - ReasonRouteDestinationTypeMismatch = "ROUTE_DESTINATION_TYPE_MISMATCH" - ReasonRouteDestinationRoleMismatch = "ROUTE_DESTINATION_ROLE_MISMATCH" - ReasonRouteIdempotencyConflict = "ROUTE_IDEMPOTENCY_CONFLICT" - ReasonRouteVersionConflict = "ROUTE_VERSION_CONFLICT" - ReasonRouteInvalidState = "ROUTE_INVALID_STATE" -) - -var ( - ErrRouteNotFound = Define("route.not_found") - ErrRouteAmbiguous = Define("route.ambiguous") - ErrRouteDisabled = Define("route.disabled") - ErrRouteInvalidSource = Define("route.invalid_source") - ErrRouteDestinationNotFound = Define("route.destination_not_found") - ErrRouteDestinationInactive = Define("route.destination_inactive") - ErrRouteDestinationCurrencyMismatch = Define("route.destination_currency_mismatch") - ErrRouteDestinationOrgMismatch = Define("route.destination_org_mismatch") - ErrRouteDestinationAmbiguous = Define("route.destination_ambiguous") - ErrRouteDestinationOwnerMismatch = Define("route.destination_owner_mismatch") - ErrRouteActiveSourceConflict = Define("route.active_source_conflict") - ErrRouteDestinationTypeMismatch = Define("route.destination_type_mismatch") - ErrRouteDestinationRoleMismatch = Define("route.destination_role_mismatch") - ErrRouteIdempotencyConflict = Define("route.idempotency_conflict") - ErrRouteVersionConflict = Define("route.version_conflict") - ErrRouteInvalidState = Define("route.invalid_state") -) - -func RouteNotFound(msg string) error { - return newRouteClassifiedError(ErrNoData, ErrRouteNotFound, ReasonRouteNotFound, msg) -} - -func RouteAmbiguous(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteAmbiguous, ReasonRouteAmbiguous, msg) -} - -func RouteDisabled(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDisabled, ReasonRouteDisabled, msg) -} - -func RouteInvalidSource(msg string) error { - return newRouteClassifiedError(ErrInvalidArg, ErrRouteInvalidSource, ReasonRouteInvalidSource, msg) -} - -func RouteDestinationNotFound(msg string) error { - return newRouteClassifiedError(ErrNoData, ErrRouteDestinationNotFound, ReasonRouteDestinationNotFound, msg) -} - -func RouteDestinationInactive(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationInactive, ReasonRouteDestinationInactive, msg) -} - -func RouteDestinationCurrencyMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationCurrencyMismatch, ReasonRouteDestinationCurrencyMismatch, msg) -} - -func RouteDestinationOrgMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOrgMismatch, ReasonRouteDestinationOrgMismatch, msg) -} - -func RouteDestinationAmbiguous(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationAmbiguous, ReasonRouteDestinationAmbiguous, msg) -} - -func RouteDestinationOwnerMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOwnerMismatch, ReasonRouteDestinationOwnerMismatch, msg) -} - -func RouteActiveSourceConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteActiveSourceConflict, ReasonRouteActiveSourceConflict, msg) -} - -func RouteDestinationTypeMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationTypeMismatch, ReasonRouteDestinationTypeMismatch, msg) -} - -func RouteDestinationRoleMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationRoleMismatch, ReasonRouteDestinationRoleMismatch, msg) -} - -func RouteIdempotencyConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteIdempotencyConflict, ReasonRouteIdempotencyConflict, msg) -} - -func RouteVersionConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteVersionConflict, ReasonRouteVersionConflict, msg) -} - -func RouteInvalidState(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteInvalidState, ReasonRouteInvalidState, msg) -} - -func newRouteClassifiedError(base, code error, reason, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" && code != nil { - msg = code.Error() - } - return routeMetadataError{ - classifiedError: classifiedError{base: base, code: code, msg: msg, reason: reason, publicMessage: true}, - routeCode: routeErrorCodePrefix + reason, - } -} diff --git a/backend/pkg/merrors/routes_test.go b/backend/pkg/merrors/routes_test.go deleted file mode 100644 index 40f2fbd..0000000 --- a/backend/pkg/merrors/routes_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRouteReasonsRoundTrip(t *testing.T) { - tests := []struct { - name string - create func(string) error - code error - base error - reason string - }{ - {name: "not found", create: RouteNotFound, code: ErrRouteNotFound, base: ErrNoData, reason: ReasonRouteNotFound}, - {name: "ambiguous", create: RouteAmbiguous, code: ErrRouteAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteAmbiguous}, - {name: "disabled", create: RouteDisabled, code: ErrRouteDisabled, base: ErrFailedPrecondition, reason: ReasonRouteDisabled}, - {name: "invalid source", create: RouteInvalidSource, code: ErrRouteInvalidSource, base: ErrInvalidArg, reason: ReasonRouteInvalidSource}, - {name: "destination not found", create: RouteDestinationNotFound, code: ErrRouteDestinationNotFound, base: ErrNoData, reason: ReasonRouteDestinationNotFound}, - {name: "destination inactive", create: RouteDestinationInactive, code: ErrRouteDestinationInactive, base: ErrFailedPrecondition, reason: ReasonRouteDestinationInactive}, - {name: "destination currency mismatch", create: RouteDestinationCurrencyMismatch, code: ErrRouteDestinationCurrencyMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationCurrencyMismatch}, - {name: "destination organization mismatch", create: RouteDestinationOrgMismatch, code: ErrRouteDestinationOrgMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOrgMismatch}, - {name: "destination ambiguous", create: RouteDestinationAmbiguous, code: ErrRouteDestinationAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteDestinationAmbiguous}, - {name: "destination owner mismatch", create: RouteDestinationOwnerMismatch, code: ErrRouteDestinationOwnerMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOwnerMismatch}, - {name: "active source conflict", create: RouteActiveSourceConflict, code: ErrRouteActiveSourceConflict, base: ErrDataConflict, reason: ReasonRouteActiveSourceConflict}, - {name: "destination type mismatch", create: RouteDestinationTypeMismatch, code: ErrRouteDestinationTypeMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationTypeMismatch}, - {name: "destination role mismatch", create: RouteDestinationRoleMismatch, code: ErrRouteDestinationRoleMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationRoleMismatch}, - {name: "idempotency conflict", create: RouteIdempotencyConflict, code: ErrRouteIdempotencyConflict, base: ErrDataConflict, reason: ReasonRouteIdempotencyConflict}, - {name: "version conflict", create: RouteVersionConflict, code: ErrRouteVersionConflict, base: ErrDataConflict, reason: ReasonRouteVersionConflict}, - {name: "invalid state", create: RouteInvalidState, code: ErrRouteInvalidState, base: ErrFailedPrecondition, reason: ReasonRouteInvalidState}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.create("route error") - require.Equal(t, "route error", err.Error()) - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, tt.reason, reason) - require.True(t, errors.Is(err, tt.code)) - require.True(t, errors.Is(err, tt.base)) - - restored, ok := FromReason(reason, "route error") - require.True(t, ok) - require.True(t, errors.Is(restored, tt.code)) - require.True(t, errors.Is(restored, tt.base)) - }) - } -} diff --git a/backend/pkg/merrors/signup_email_policy.go b/backend/pkg/merrors/signup_email_policy.go deleted file mode 100644 index 7fdc705..0000000 --- a/backend/pkg/merrors/signup_email_policy.go +++ /dev/null @@ -1,78 +0,0 @@ -package merrors - -import ( - "errors" - "strings" -) - -const ( - SignupEmailPolicyCode = "signup_email_rejected" - - SignupEmailPolicyReasonInvalidEmail = "invalid_email" - SignupEmailPolicyReasonDisposableDomain = "disposable_domain" - SignupEmailPolicyReasonFreeDomain = "free_domain" -) - -var ErrSignupEmailPolicyRejected = Define(SignupEmailPolicyCode) - -type SignupEmailPolicyError struct { - reason string - domain string -} - -func NormalizeSignupEmailPolicyReason(reason string) string { - switch strings.TrimSpace(reason) { - case SignupEmailPolicyReasonInvalidEmail: - return SignupEmailPolicyReasonInvalidEmail - case SignupEmailPolicyReasonDisposableDomain: - return SignupEmailPolicyReasonDisposableDomain - case SignupEmailPolicyReasonFreeDomain: - return SignupEmailPolicyReasonFreeDomain - default: - return SignupEmailPolicyReasonInvalidEmail - } -} - -func IsSignupEmailPolicyReason(reason string) bool { - return strings.TrimSpace(reason) == NormalizeSignupEmailPolicyReason(reason) -} - -func (e *SignupEmailPolicyError) Error() string { - if e == nil || e.reason == "" { - return SignupEmailPolicyCode - } - return SignupEmailPolicyCode + ": " + e.reason -} - -func (e *SignupEmailPolicyError) Unwrap() error { - return ErrSignupEmailPolicyRejected -} - -func (e *SignupEmailPolicyError) Reason() string { - if e == nil { - return "" - } - return strings.TrimSpace(e.reason) -} - -func (e *SignupEmailPolicyError) Domain() string { - if e == nil { - return "" - } - return strings.TrimSpace(e.domain) -} - -func SignupEmailPolicyRejected(reason, domain string) error { - return &SignupEmailPolicyError{ - reason: NormalizeSignupEmailPolicyReason(reason), - domain: strings.TrimSpace(domain), - } -} - -func AsSignupEmailPolicyError(err error) (*SignupEmailPolicyError, bool) { - var policyErr *SignupEmailPolicyError - if errors.As(err, &policyErr) && policyErr != nil { - return policyErr, true - } - return nil, false -} diff --git a/backend/pkg/merrors/signup_email_policy_test.go b/backend/pkg/merrors/signup_email_policy_test.go deleted file mode 100644 index 40b44ba..0000000 --- a/backend/pkg/merrors/signup_email_policy_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestSignupEmailPolicyError(t *testing.T) { - err := SignupEmailPolicyRejected(SignupEmailPolicyReasonFreeDomain, "gmail.com") - policyErr, ok := AsSignupEmailPolicyError(err) - if !ok { - t.Fatalf("expected signup email policy error") - } - if policyErr.Reason() != SignupEmailPolicyReasonFreeDomain { - t.Fatalf("reason mismatch: got=%q", policyErr.Reason()) - } - if policyErr.Domain() != "gmail.com" { - t.Fatalf("domain mismatch: got=%q", policyErr.Domain()) - } - if !errors.Is(err, ErrSignupEmailPolicyRejected) { - t.Fatalf("expected signup email policy sentinel") - } - - reason, ok := Reason(err) - if !ok { - t.Fatalf("expected generic reason") - } - if reason != SignupEmailPolicyReasonFreeDomain { - t.Fatalf("generic reason mismatch: got=%q", reason) - } -} - -func TestSignupEmailPolicyReasonNormalization(t *testing.T) { - for _, reason := range []string{ - SignupEmailPolicyReasonInvalidEmail, - SignupEmailPolicyReasonDisposableDomain, - SignupEmailPolicyReasonFreeDomain, - } { - if !IsSignupEmailPolicyReason(reason) { - t.Fatalf("expected supported reason: %q", reason) - } - } - - err := SignupEmailPolicyRejected("unexpected_reason", "") - policyErr, ok := AsSignupEmailPolicyError(err) - if !ok { - t.Fatalf("expected signup email policy error") - } - if policyErr.Reason() != SignupEmailPolicyReasonInvalidEmail { - t.Fatalf("unexpected reasons must not leak: got=%q", policyErr.Reason()) - } -} diff --git a/backend/pkg/merrors/treasury.go b/backend/pkg/merrors/treasury.go deleted file mode 100644 index d4c4d53..0000000 --- a/backend/pkg/merrors/treasury.go +++ /dev/null @@ -1,15 +0,0 @@ -package merrors - -var ErrTreasuryInvalidArgument = Define("treasury.monitoring.invalid_argument") - -var ErrTreasuryConfiguration = Define("treasury.monitoring.configuration_error") - -var ErrTreasuryNotFound = Define("treasury.monitoring.not_found") - -var ErrTreasuryOutOfOrder = Define("treasury.monitoring.out_of_order") - -var ErrTreasuryNegativeBalance = Define("treasury.monitoring.negative_balance") - -var ErrTreasuryAlreadyReversed = Define("treasury.monitoring.movement_already_reversed") - -var ErrTreasuryProjectionMismatch = Define("treasury.monitoring.projection_mismatch") diff --git a/backend/pkg/model/account.go b/backend/pkg/model/account.go index e4ab43e..a7d331a 100644 --- a/backend/pkg/model/account.go +++ b/backend/pkg/model/account.go @@ -3,7 +3,6 @@ package model import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" "golang.org/x/crypto/bcrypt" ) @@ -21,14 +20,8 @@ type AccountBase struct { storable.Base `bson:",inline" json:",inline"` ArchivableBase `bson:",inline" json:",inline"` Describable `bson:",inline" json:",inline"` - LastName string `bson:"lastName" json:"lastName"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` - Metadata *AccountMetadata `bson:"metadata,omitempty" json:"-"` - - // INN is the account owner's taxpayer identifier. It is the canonical source - // of the INN for personal (account-owned) payouts, flowing into - // customer.tax_identifier. - INN string `bson:"inn,omitempty" json:"inn,omitempty"` + LastName string `bson:"lastName" json:"lastName"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` } func (*AccountBase) Collection() string { @@ -51,7 +44,6 @@ func (a *Account) Copy() *Account { return nil } copied := *a - copied.Metadata = a.Metadata.Copy() return &copied } @@ -74,37 +66,4 @@ func (a *Account) MatchPassword(password string) bool { return err == nil } -func AnonymousUserName(orgRef bson.ObjectID) string { - return "anonymous@" + orgRef.Hex() -} - -func AccountIsAnonymous(account *UserDataBase, orgRef bson.ObjectID) bool { - if account == nil { - return false - } - return AnonymousUserName(orgRef) == account.Login -} - -type AccountBound interface { - GetAccountRef() bson.ObjectID -} - -type AccountBoundStorable interface { - storable.Storable - OrganizationBound - GetAccountRef() *bson.ObjectID -} - -const ( - AccountRefField = "accountRef" -) - -type AccountBoundBase struct { - storable.Base `bson:",inline" json:",inline"` - OrganizationBoundBase `bson:",inline" json:",inline"` - AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"` -} - -func (a *AccountBoundBase) GetAccountRef() *bson.ObjectID { - return a.AccountRef -} +const AccountRefField = "accountRef" diff --git a/backend/pkg/model/account_metadata.go b/backend/pkg/model/account_metadata.go deleted file mode 100644 index 5f0e82c..0000000 --- a/backend/pkg/model/account_metadata.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "time" - -type AccountMetadata struct { - PersonalLedgerProvisioning *LedgerProvisioningMetadata `bson:"personalLedgerProvisioning,omitempty" json:"-"` -} - -type LedgerProvisioningMetadata struct { - Source string `bson:"source,omitempty" json:"-"` - InvitationRef string `bson:"invitationRef,omitempty" json:"-"` - TargetCurrencies []string `bson:"targetCurrencies,omitempty" json:"-"` - Created []string `bson:"created,omitempty" json:"-"` - AlreadyExisting []string `bson:"alreadyExisting,omitempty" json:"-"` - Failed []LedgerProvisioningFailure `bson:"failed,omitempty" json:"-"` - Outcome string `bson:"outcome,omitempty" json:"-"` - LastErrorClass string `bson:"lastErrorClass,omitempty" json:"-"` - LastAttemptAt time.Time `bson:"lastAttemptAt,omitempty" json:"-"` -} - -type LedgerProvisioningFailure struct { - Currency string `bson:"currency,omitempty" json:"-"` - ErrorClass string `bson:"errorClass,omitempty" json:"-"` -} - -func (m *AccountMetadata) Copy() *AccountMetadata { - if m == nil { - return nil - } - copied := *m - copied.PersonalLedgerProvisioning = m.PersonalLedgerProvisioning.Copy() - return &copied -} - -func (m *LedgerProvisioningMetadata) Copy() *LedgerProvisioningMetadata { - if m == nil { - return nil - } - copied := *m - copied.TargetCurrencies = append([]string(nil), m.TargetCurrencies...) - copied.Created = append([]string(nil), m.Created...) - copied.AlreadyExisting = append([]string(nil), m.AlreadyExisting...) - copied.Failed = append([]LedgerProvisioningFailure(nil), m.Failed...) - return &copied -} diff --git a/backend/pkg/model/account_role/account_role.go b/backend/pkg/model/account_role/account_role.go deleted file mode 100644 index 02de143..0000000 --- a/backend/pkg/model/account_role/account_role.go +++ /dev/null @@ -1,57 +0,0 @@ -package account_role - -import ( - "strings" -) - -type AccountRole string - -const ( - AccountRoleOperating AccountRole = "operating" - AccountRoleHold AccountRole = "hold" - AccountRoleTransit AccountRole = "transit" - AccountRoleSettlement AccountRole = "settlement" - AccountRoleClearing AccountRole = "clearing" - AccountRolePending AccountRole = "pending" - AccountRoleReserve AccountRole = "reserve" - AccountRoleLiquidity AccountRole = "liquidity" - AccountRoleFee AccountRole = "fee" - AccountRoleChargeback AccountRole = "chargeback" - AccountRoleAdjustment AccountRole = "adjustment" -) - -const ( - MetadataKeyFromRole = "from_role" - MetadataKeyToRole = "to_role" -) - -func Parse(value string) (AccountRole, bool) { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "ACCOUNT_ROLE_OPERATING", "OPERATING": - return AccountRoleOperating, true - case "ACCOUNT_ROLE_HOLD", "HOLD": - return AccountRoleHold, true - case "ACCOUNT_ROLE_TRANSIT", "TRANSIT": - return AccountRoleTransit, true - case "ACCOUNT_ROLE_SETTLEMENT", "SETTLEMENT": - return AccountRoleSettlement, true - case "ACCOUNT_ROLE_CLEARING", "CLEARING": - return AccountRoleClearing, true - case "ACCOUNT_ROLE_PENDING", "PENDING": - return AccountRolePending, true - case "ACCOUNT_ROLE_RESERVE", "RESERVE": - return AccountRoleReserve, true - case "ACCOUNT_ROLE_LIQUIDITY", "LIQUIDITY": - return AccountRoleLiquidity, true - case "ACCOUNT_ROLE_FEE", "FEE": - return AccountRoleFee, true - case "ACCOUNT_ROLE_CHARGEBACK", "CHARGEBACK": - return AccountRoleChargeback, true - case "ACCOUNT_ROLE_ADJUSTMENT", "ADJUSTMENT": - return AccountRoleAdjustment, true - case "ACCOUNT_ROLE_UNSPECIFIED", "UNSPECIFIED", "": - return "", true - default: - return "", false - } -} diff --git a/backend/pkg/model/account_test.go b/backend/pkg/model/account_test.go deleted file mode 100644 index 0aff70c..0000000 --- a/backend/pkg/model/account_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package model - -import ( - "testing" - "time" -) - -func TestAccountCopyDeepCopiesMetadata(t *testing.T) { - account := &Account{ - AccountPublic: AccountPublic{ - AccountBase: AccountBase{ - Metadata: &AccountMetadata{ - PersonalLedgerProvisioning: &LedgerProvisioningMetadata{ - Source: "invitation", - InvitationRef: "invitation-ref", - TargetCurrencies: []string{"RUB", "USDT"}, - Created: []string{"RUB"}, - AlreadyExisting: []string{"USDT"}, - Failed: []LedgerProvisioningFailure{{Currency: "EUR", ErrorClass: "timeout"}}, - Outcome: "partial_failure", - LastErrorClass: "timeout", - LastAttemptAt: time.Now().UTC(), - }, - }, - }, - UserDataBase: UserDataBase{Login: "invitee@example.com"}, - }, - Password: "secret", - Status: AccountActive, - } - - copied := account.Copy() - copied.Metadata.PersonalLedgerProvisioning.TargetCurrencies[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.Created[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.AlreadyExisting[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.Failed[0].ErrorClass = "ledger_unavailable" - - metadata := account.Metadata.PersonalLedgerProvisioning - if metadata.TargetCurrencies[0] != "RUB" { - t.Fatalf("expected original target currency to remain RUB, got %q", metadata.TargetCurrencies[0]) - } - if metadata.Created[0] != "RUB" { - t.Fatalf("expected original created currency to remain RUB, got %q", metadata.Created[0]) - } - if metadata.AlreadyExisting[0] != "USDT" { - t.Fatalf("expected original existing currency to remain USDT, got %q", metadata.AlreadyExisting[0]) - } - if metadata.Failed[0].ErrorClass != "timeout" { - t.Fatalf("expected original failure class to remain timeout, got %q", metadata.Failed[0].ErrorClass) - } -} diff --git a/backend/pkg/model/auth.go b/backend/pkg/model/auth.go deleted file mode 100644 index 650d568..0000000 --- a/backend/pkg/model/auth.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Action represents a permissible action on a resource. -type Action string - -// Common actions for resources. -const ( - ActionCreate Action = "create" // Create a resource - ActionRead Action = "read" // Read or view a resource - ActionUpdate Action = "update" // Update or modify a resource - ActionDelete Action = "delete" // Delete a resource -) - -// Effect determines whether an action is allowed or denied. -type Effect string - -const ( - EffectAllow Effect = "allow" // Permit the action - EffectDeny Effect = "deny" // Deny the action -) - -// RoleDescription provides metadata about a role. -type RoleDescription struct { - storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents - Describable `bson:",inline" json:",inline"` // Name and description fields - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the role -} - -// Collection specifies the MongoDB collection for RoleDescription. -func (*RoleDescription) Collection() string { - return mservice.Roles -} - -// Role represents a role assignment for an account within an organization. -type Role struct { - AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned to the role - DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the role's description - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization where the role is applicable -} - -// ActionEffect represents a combination of an action and its effect (allow/deny). -type ActionEffect struct { - Action Action `bson:"action" json:"action"` // The action to perform (e.g., read, write) - Effect Effect `bson:"effect" json:"effect"` // Whether the action is allowed or denied -} - -// Policy defines access control rules for a role within an organization. -type Policy struct { - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the policy - DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the policy's metadata - ObjectRef *bson.ObjectID `bson:"objectRef,omitempty" json:"objectRef,omitempty"` // Target object (NilObjectID for all objects) - Effect ActionEffect `bson:"effect" json:"effect"` // Action and effect for the policy -} - -// RolePolicy defines access control rules for a role within an organization. -type RolePolicy struct { - Policy `bson:",inline" json:",inline"` - RoleDescriptionRef bson.ObjectID `bson:"roleDescriptionRef" json:"roleDescriptionRef"` // Reference to the associated role -} - -// PolicyDescription provides metadata for policies. -type PolicyDescription struct { - storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents - Describable `bson:",inline" json:",inline"` // Name and description fields - ResourceTypes *[]mservice.Type `bson:"resourceTypes,omitempty" json:"resourceTypes,omitempty"` // nil for custom policies, non-nil for built-in permissisons - OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // nil for built-in policies, non-nil for custom -} - -// Collection specifies the MongoDB collection for PolicyDescription. -func (*PolicyDescription) Collection() string { - return mservice.Policies -} - -// Permission ties a policy to a specific account. -type Permission struct { - RolePolicy `bson:",inline" json:",inline"` // Embedded policy definition - AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned the permission -} diff --git a/backend/pkg/model/card.go b/backend/pkg/model/card.go deleted file mode 100644 index 3d7a196..0000000 --- a/backend/pkg/model/card.go +++ /dev/null @@ -1,169 +0,0 @@ -package model - -import ( - "fmt" - "math" - "strconv" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type CardPaymentData struct { - Pan string `bson:"pan" json:"pan"` - FirstName string `bson:"firstName" json:"firstName"` - LastName string `bson:"lastName" json:"lastName"` - ExpMonth *int `bson:"exp_month" json:"exp_month"` - ExpYear *int `bson:"exp_year" json:"exp_year"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - Country string `bson:"country,omitempty" json:"country,omitempty"` -} - -func (d *CardPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.Pan = paymentDataString(raw["pan"]) - d.FirstName = firstPaymentDataString(raw, "firstName", "first_name") - d.LastName = firstPaymentDataString(raw, "lastName", "last_name") - d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") - d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") - d.Network = paymentDataString(raw["network"]) - d.Country = paymentDataString(raw["country"]) - return nil -} - -func (m *PaymentMethod) AsCard() (*CardPaymentData, error) { - if m.Type != PaymentTypeCard { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not card", m.Type), "type") - } - var d CardPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} - -func firstPaymentDataString(raw map[string]any, keys ...string) string { - for _, key := range keys { - if value := paymentDataString(raw[key]); value != "" { - return value - } - } - return "" -} - -// paymentDataDocument reads a nested payment data document as a map, whatever -// document shape the BSON decoder produced: a generic decode yields bson.D, -// while a typed or hand-built payload yields a map. -func paymentDataDocument(value any) map[string]any { - switch document := value.(type) { - case map[string]any: - return document - case bson.M: - return document - case bson.D: - out := make(map[string]any, len(document)) - for _, element := range document { - out[element.Key] = element.Value - } - return out - default: - return nil - } -} - -func firstPaymentDataInt(raw map[string]any, keys ...string) *int { - for _, key := range keys { - if value := paymentDataInt(raw[key]); value != nil { - return value - } - } - return nil -} - -func paymentDataInt(value any) *int { - switch v := value.(type) { - case nil: - return nil - case int: - return &v - case int32: - out := int(v) - return &out - case int64: - out := int(v) - return &out - case uint: - out := int(v) - return &out - case uint32: - out := int(v) - return &out - case uint64: - return paymentDataIntFromUint64(v) - case float32: - out := int(v) - return &out - case float64: - out := int(v) - return &out - case string: - clean := strings.TrimSpace(v) - if clean == "" { - return nil - } - parsed, err := strconv.Atoi(clean) - if err != nil { - return nil - } - return &parsed - default: - clean := strings.TrimSpace(fmt.Sprint(v)) - if clean == "" { - return nil - } - parsed, err := strconv.Atoi(clean) - if err != nil { - return nil - } - return &parsed - } -} - -func paymentDataIntFromUint64(value uint64) *int { - if value > uint64(math.MaxInt) { - return nil - } - out := int(value) - return &out -} - -func paymentDataString(value any) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case int: - return fmt.Sprint(v) - case int32: - return fmt.Sprint(v) - case int64: - return fmt.Sprint(v) - case uint: - return fmt.Sprint(v) - case uint32: - return fmt.Sprint(v) - case uint64: - return fmt.Sprint(v) - case float32: - return strconv.FormatFloat(float64(v), 'f', -1, 32) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - default: - return strings.TrimSpace(fmt.Sprint(v)) - } -} diff --git a/backend/pkg/model/card_test.go b/backend/pkg/model/card_test.go deleted file mode 100644 index 2ca8c0a..0000000 --- a/backend/pkg/model/card_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package model - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPaymentMethodAsCardReadsStoredSnakeCaseExpiry(t *testing.T) { - data, err := bson.Marshal(bson.M{ - "pan": "2200001100000001", - "firstName": "S", - "lastName": "S", - "exp_month": 12.0, - "exp_year": 2031.0, - "country": nil, - }) - if err != nil { - t.Fatalf("marshal card data: %v", err) - } - - card, err := (&PaymentMethod{ - Type: PaymentTypeCard, - Data: data, - }).AsCard() - if err != nil { - t.Fatalf("AsCard returned error: %v", err) - } - - if got, want := card.ExpMonth, 12; got == nil || *got != want { - t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := card.ExpYear, 2031; got == nil || *got != want { - t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) - } -} - -func TestCardPaymentDataMarshalUsesStoredSnakeCaseExpiry(t *testing.T) { - expMonth := 12 - expYear := 2031 - raw, err := bson.Marshal(CardPaymentData{ - Pan: "2200001100000001", - FirstName: "S", - LastName: "S", - ExpMonth: &expMonth, - ExpYear: &expYear, - }) - if err != nil { - t.Fatalf("marshal card data: %v", err) - } - - var data bson.M - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal card data: %v", err) - } - if got, want := paymentDataInt(data["exp_month"]), 12; got == nil || *got != want { - t.Fatalf("stored exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := paymentDataInt(data["exp_year"]), 2031; got == nil || *got != want { - t.Fatalf("stored exp_year mismatch: got=%v want=%d", got, want) - } - if _, ok := data["expMonth"]; ok { - t.Fatalf("card data must not store camelCase expMonth: %#v", data) - } - if _, ok := data["expYear"]; ok { - t.Fatalf("card data must not store camelCase expYear: %#v", data) - } -} diff --git a/backend/pkg/model/chains.go b/backend/pkg/model/chains.go deleted file mode 100644 index b0e8b27..0000000 --- a/backend/pkg/model/chains.go +++ /dev/null @@ -1,97 +0,0 @@ -package model - -import "strings" - -// ChainNetwork identifies a blockchain network for routing and discovery. -type ChainNetwork string - -const ( - ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one" - ChainNetworkArbitrumSepolia ChainNetwork = "arbitrum_sepolia" - ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet" - ChainNetworkTronMainnet ChainNetwork = "tron_mainnet" - ChainNetworkTronNile ChainNetwork = "tron_nile" - ChainNetworkUnspecified ChainNetwork = "" -) - -// AllChainNetworks returns all valid chain networks. -func AllChainNetworks() []ChainNetwork { - return []ChainNetwork{ - ChainNetworkArbitrumOne, - ChainNetworkArbitrumSepolia, - ChainNetworkEthereumMainnet, - ChainNetworkTronMainnet, - ChainNetworkTronNile, - } -} - -// ParseChainNetwork parses a string into a ChainNetwork. -// Returns the network and true if valid, or empty and false if invalid. -func ParseChainNetwork(value string) (ChainNetwork, bool) { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "arbitrum_one", "arbitrum", "arb": - return ChainNetworkArbitrumOne, true - case "arbitrum_sepolia": - return ChainNetworkArbitrumSepolia, true - case "ethereum_mainnet", "ethereum", "eth": - return ChainNetworkEthereumMainnet, true - case "tron_mainnet", "tron": - return ChainNetworkTronMainnet, true - case "tron_nile", "nile": - return ChainNetworkTronNile, true - case "unspecified", "": - return ChainNetworkUnspecified, true - default: - return ChainNetworkUnspecified, false - } -} - -// String returns the string representation of the network. -func (n ChainNetwork) String() string { - return string(n) -} - -// IsValid returns true if the network is a known valid network. -func (n ChainNetwork) IsValid() bool { - switch n { - case ChainNetworkArbitrumOne, - ChainNetworkArbitrumSepolia, - ChainNetworkEthereumMainnet, - ChainNetworkTronMainnet, - ChainNetworkTronNile: - return true - default: - return false - } -} - -// IsTron returns true if the network is a TRON network. -func (n ChainNetwork) IsTron() bool { - switch n { - case ChainNetworkTronMainnet, ChainNetworkTronNile: - return true - default: - return false - } -} - -// IsEVM returns true if the network is an EVM-compatible network. -func (n ChainNetwork) IsEVM() bool { - switch n { - case ChainNetworkArbitrumOne, ChainNetworkArbitrumSepolia, ChainNetworkEthereumMainnet: - return true - default: - return false - } -} - -// IsTestnet returns true if the network is a testnet. -func (n ChainNetwork) IsTestnet() bool { - switch n { - case ChainNetworkTronNile, ChainNetworkArbitrumSepolia: - return true - default: - } - return false -} diff --git a/backend/pkg/model/chains_test.go b/backend/pkg/model/chains_test.go deleted file mode 100644 index 84c4a9b..0000000 --- a/backend/pkg/model/chains_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package model - -import "testing" - -func TestChainNetworkArbitrumSepoliaSupport(t *testing.T) { - network, ok := ParseChainNetwork("arbitrum_sepolia") - if !ok { - t.Fatal("ParseChainNetwork(arbitrum_sepolia) returned ok=false") - } - if network != ChainNetworkArbitrumSepolia { - t.Fatalf("ParseChainNetwork(arbitrum_sepolia) = %q, want %q", network, ChainNetworkArbitrumSepolia) - } - if !network.IsValid() { - t.Fatal("ChainNetworkArbitrumSepolia should be valid") - } - if !network.IsEVM() { - t.Fatal("ChainNetworkArbitrumSepolia should be EVM") - } - if !network.IsTestnet() { - t.Fatal("ChainNetworkArbitrumSepolia should be testnet") - } - - found := false - for _, candidate := range AllChainNetworks() { - if candidate == ChainNetworkArbitrumSepolia { - found = true - break - } - } - if !found { - t.Fatal("AllChainNetworks() is missing ChainNetworkArbitrumSepolia") - } -} diff --git a/backend/pkg/model/chainwalletroute.go b/backend/pkg/model/chainwalletroute.go deleted file mode 100644 index 27d84d9..0000000 --- a/backend/pkg/model/chainwalletroute.go +++ /dev/null @@ -1,29 +0,0 @@ -package model - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -// ChainWalletRoute stores authoritative wallet-to-gateway routing metadata. -type ChainWalletRoute struct { - storable.Base `bson:",inline" json:",inline"` - - OrganizationRef string `bson:"organizationRef" json:"organizationRef"` - WalletRef string `bson:"walletRef" json:"walletRef"` - Network string `bson:"network" json:"network"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` -} - -func (*ChainWalletRoute) Collection() string { - return mservice.WalletRoutes -} - -func (r *ChainWalletRoute) Normalize() { - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.WalletRef = strings.TrimSpace(r.WalletRef) - r.Network = strings.ToLower(strings.TrimSpace(r.Network)) - r.GatewayID = strings.TrimSpace(r.GatewayID) -} diff --git a/backend/pkg/model/cryptoaddress.go b/backend/pkg/model/cryptoaddress.go deleted file mode 100644 index 41c1c42..0000000 --- a/backend/pkg/model/cryptoaddress.go +++ /dev/null @@ -1,127 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type CryptoAddressPaymentData struct { - Currency Currency `bson:"currency" json:"currency"` - Address string `bson:"address" json:"address"` - Network string `bson:"network" json:"network"` - DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"` -} - -type cryptoAddressAssetJSON struct { - Chain string `json:"chain,omitempty"` - TokenSymbol string `json:"token_symbol,omitempty"` - ContractAddress string `json:"contract_address,omitempty"` -} - -func (d CryptoAddressPaymentData) MarshalJSON() ([]byte, error) { - var payload struct { - Asset *cryptoAddressAssetJSON `json:"asset,omitempty"` - Address string `json:"address"` - Memo *string `json:"memo,omitempty"` - } - payload.Address = strings.TrimSpace(d.Address) - if strings.TrimSpace(d.Network) != "" || strings.TrimSpace(string(d.Currency)) != "" { - payload.Asset = &cryptoAddressAssetJSON{ - Chain: strings.TrimSpace(d.Network), - TokenSymbol: strings.TrimSpace(string(d.Currency)), - } - } - if memo := strings.TrimSpace(strPtr(d.DestinationTag)); memo != "" { - payload.Memo = &memo - } - return json.Marshal(payload) -} - -func (d *CryptoAddressPaymentData) UnmarshalJSON(data []byte) error { - var raw struct { - Asset *cryptoAddressAssetJSON `json:"asset"` - Address string `json:"address"` - Memo string `json:"memo"` - Currency string `json:"currency"` - Network string `json:"network"` - DestinationTag *string `json:"destinationTag"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - currency := strings.TrimSpace(raw.Currency) - network := strings.TrimSpace(raw.Network) - if raw.Asset != nil { - if assetCurrency := strings.TrimSpace(raw.Asset.TokenSymbol); assetCurrency != "" { - currency = assetCurrency - } - if assetNetwork := strings.TrimSpace(raw.Asset.Chain); assetNetwork != "" { - network = assetNetwork - } - } - - d.Currency = Currency(strings.ToUpper(currency)) - d.Address = strings.TrimSpace(raw.Address) - d.Network = strings.ToUpper(network) - d.DestinationTag = clonePaymentDataStringPtr(raw.Memo) - if d.DestinationTag == nil && raw.DestinationTag != nil { - d.DestinationTag = clonePaymentDataStringPtr(*raw.DestinationTag) - } - return nil -} - -func (d *CryptoAddressPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - currency := firstPaymentDataString(raw, "currency") - network := firstPaymentDataString(raw, "network") - if asset := paymentDataDocument(raw["asset"]); asset != nil { - if assetCurrency := firstPaymentDataString(asset, "token_symbol", "tokenSymbol"); assetCurrency != "" { - currency = assetCurrency - } - if assetNetwork := firstPaymentDataString(asset, "chain"); assetNetwork != "" { - network = assetNetwork - } - } - - d.Currency = Currency(strings.ToUpper(strings.TrimSpace(currency))) - d.Address = firstPaymentDataString(raw, "address") - d.Network = strings.ToUpper(strings.TrimSpace(network)) - d.DestinationTag = clonePaymentDataStringPtr(firstPaymentDataString(raw, "destinationTag", "destination_tag", "memo")) - return nil -} - -func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) { - if m.Type != PaymentTypeCryptoAddress { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cryptoAddress", m.Type), "type") - } - - var d CryptoAddressPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} - -func clonePaymentDataStringPtr(value string) *string { - out := strings.TrimSpace(value) - if out == "" { - return nil - } - return &out -} - -func strPtr(value *string) string { - if value == nil { - return "" - } - return *value -} diff --git a/backend/pkg/model/cryptoaddress_test.go b/backend/pkg/model/cryptoaddress_test.go deleted file mode 100644 index 71601d1..0000000 --- a/backend/pkg/model/cryptoaddress_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestCryptoAddressPaymentDataReadsAssetFromGenericDocument(t *testing.T) { - // The intermediate BSON document produced by PATCH decodes a nested asset - // as bson.D before the payment service record is encoded. - var payload any - if err := json.Unmarshal([]byte(`{"asset":{"chain":"tron_mainnet","token_symbol":"usdt"},"address":"TXabc","memo":"tag-1"}`), &payload); err != nil { - t.Fatalf("unmarshal payload: %v", err) - } - raw, err := bson.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDT"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "TRON_MAINNET"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } - if got, want := data.Address, "TXabc"; got != want { - t.Fatalf("address mismatch: got=%q want=%q", got, want) - } - if got, want := strPtr(data.DestinationTag), "tag-1"; got != want { - t.Fatalf("destination tag mismatch: got=%q want=%q", got, want) - } -} - -func TestCryptoAddressPaymentDataReadsAssetFromMapDocument(t *testing.T) { - raw, err := bson.Marshal(bson.M{ - "asset": bson.M{"chain": "arbitrum_one", "tokenSymbol": "usdc"}, - "address": "0xabc", - }) - if err != nil { - t.Fatalf("marshal crypto data: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDC"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "ARBITRUM_ONE"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } -} - -func TestCryptoAddressPaymentDataReadsStoredFlatShape(t *testing.T) { - raw, err := bson.Marshal(CryptoAddressPaymentData{ - Currency: "USDT", - Address: "TXabc", - Network: "TRON_MAINNET", - }) - if err != nil { - t.Fatalf("marshal crypto data: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDT"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "TRON_MAINNET"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } -} diff --git a/backend/pkg/model/ctoken.go b/backend/pkg/model/ctoken.go deleted file mode 100644 index ff5191d..0000000 --- a/backend/pkg/model/ctoken.go +++ /dev/null @@ -1,47 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// TokenPaymentData represents a network or gateway-issued card token. -type TokenPaymentData struct { - Token string `bson:"token" json:"token"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - Last4 string `bson:"last4,omitempty" json:"masked_pan,omitempty"` - ExpMonth *int `bson:"exp_month,omitempty" json:"exp_month,omitempty"` - ExpYear *int `bson:"exp_year,omitempty" json:"exp_year,omitempty"` - CardholderName string `bson:"cardholderName,omitempty" json:"cardholderName,omitempty"` - Country string `bson:"country,omitempty" json:"country,omitempty"` -} - -func (d *TokenPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.Token = paymentDataString(raw["token"]) - d.Network = paymentDataString(raw["network"]) - d.Last4 = firstPaymentDataString(raw, "last4", "last_4", "masked_pan") - d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") - d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") - d.CardholderName = firstPaymentDataString(raw, "cardholderName", "cardholder_name") - d.Country = paymentDataString(raw["country"]) - return nil -} - -func (m *PaymentMethod) AsCardToken() (*TokenPaymentData, error) { - if m.Type != PaymentTypeCardToken { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cardToken", m.Type), "type") - } - - var d TokenPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/ctoken_test.go b/backend/pkg/model/ctoken_test.go deleted file mode 100644 index 51f9066..0000000 --- a/backend/pkg/model/ctoken_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package model - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestTokenPaymentDataUnmarshalReadsSnakeCaseExpiryAsInt(t *testing.T) { - raw, err := bson.Marshal(bson.M{ - "token": "card-token", - "last4": "1234", - "exp_month": "01", - "exp_year": 2031.0, - }) - if err != nil { - t.Fatalf("marshal card token data: %v", err) - } - - var data TokenPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal card token data: %v", err) - } - if got, want := data.ExpMonth, 1; got == nil || *got != want { - t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := data.ExpYear, 2031; got == nil || *got != want { - t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) - } -} diff --git a/backend/pkg/model/currency.go b/backend/pkg/model/currency.go deleted file mode 100644 index d706321..0000000 --- a/backend/pkg/model/currency.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "sort" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" -) - -type Currency string - -const ( - CurrencyEUR Currency = "EUR" // Euro - CurrencyUSD Currency = "USD" // US Dollar - CurrencyRUB Currency = "RUB" // Russian Ruble - CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia - CurrencyPLN Currency = "PLN" // Polish Złoty - CurrencyCZK Currency = "CZK" // Czech Koruna - CurrencyUSDT Currency = "USDT" // Tether USD -) - -const MaxCurrencyScale uint32 = moneybase.MaxScale - -func NormalizeCurrency(value string) Currency { - return Currency(strings.ToUpper(strings.TrimSpace(value))) -} - -func NormalizeExplicitCurrencyScales(values map[Currency]uint32) (map[Currency]uint32, error) { - if len(values) == 0 { - return nil, merrors.InvalidArgument("currency scales are required") - } - out := make(map[Currency]uint32, len(values)) - for rawCurrency, scale := range values { - currency := NormalizeCurrency(string(rawCurrency)) - if currency == "" { - return nil, merrors.InvalidArgument("currency scale key is required") - } - if scale > MaxCurrencyScale { - return nil, merrors.InvalidArgumentf("currency scale for %s exceeds %d", currency, MaxCurrencyScale) - } - out[currency] = scale - } - return out, nil -} - -func CurrencyScale(scales map[Currency]uint32, currency string) (uint32, error) { - normalized := NormalizeCurrency(currency) - if normalized == "" { - return 0, merrors.InvalidArgument("currency is required") - } - if scales == nil { - return 0, merrors.InvalidArgument("currency scale map is required") - } - scale, ok := scales[normalized] - if !ok { - return 0, merrors.InvalidArgument("currency scale for " + string(normalized) + " is not configured") - } - return scale, nil -} - -func CurrencyScaleKeys(scales map[Currency]uint32) []string { - if scales == nil { - return nil - } - keys := make([]string, 0, len(scales)) - for currency := range scales { - keys = append(keys, string(currency)) - } - sort.Strings(keys) - return keys -} - -type Amount struct { - Total float64 `bson:"total" json:"total"` // Total amount billed - Currency Currency `bson:"currency" json:"currency"` // Currency for the invoice -} diff --git a/backend/pkg/model/currency_test.go b/backend/pkg/model/currency_test.go deleted file mode 100644 index 7904986..0000000 --- a/backend/pkg/model/currency_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package model - -import ( - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" - "github.com/stretchr/testify/require" -) - -func TestMaxCurrencyScaleUsesSharedMoneyBaseRange(t *testing.T) { - require.Equal(t, moneybase.MaxScale, MaxCurrencyScale) -} - -func TestNormalizeExplicitCurrencyScalesNormalizesKeys(t *testing.T) { - scales, err := NormalizeExplicitCurrencyScales(map[Currency]uint32{"usd": 2, "usdt": 6}) - require.NoError(t, err) - - usd, err := CurrencyScale(scales, "USD") - require.NoError(t, err) - require.Equal(t, uint32(2), usd) - - usdt, err := CurrencyScale(scales, "USDT") - require.NoError(t, err) - require.Equal(t, uint32(6), usdt) -} - -func TestCurrencyScaleRejectsUnconfiguredCurrency(t *testing.T) { - _, err := CurrencyScale(map[Currency]uint32{"USD": 2}, "KWD") - - require.Error(t, err) - require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) -} - -func TestNormalizeExplicitCurrencyScalesRejectsMissingScales(t *testing.T) { - _, err := NormalizeExplicitCurrencyScales(nil) - - require.Error(t, err) - require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) -} diff --git a/backend/pkg/model/iban.go b/backend/pkg/model/iban.go deleted file mode 100644 index a4cd7ea..0000000 --- a/backend/pkg/model/iban.go +++ /dev/null @@ -1,26 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type IbanPaymentData struct { - Iban string `bson:"iban" json:"iban"` - AccountHolder string `bson:"accountHolder" json:"accountHolder"` - Bic *string `bson:"bic,omitempty" json:"bic,omitempty"` - BankName *string `bson:"bankName,omitempty" json:"bankName,omitempty"` -} - -func (m *PaymentMethod) AsIban() (*IbanPaymentData, error) { - if m.Type != PaymentTypeIban { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not iban", m.Type), "type") - } - var d IbanPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} diff --git a/backend/pkg/model/indexable.go b/backend/pkg/model/indexable.go deleted file mode 100644 index 3dee3f8..0000000 --- a/backend/pkg/model/indexable.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -import "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - -type Indexable struct { - Index int `bson:"index" json:"index"` -} - -type IndexableRef struct { - storable.Ref `bson:",inline" json:",inline"` - Indexable `bson:",inline" json:",inline"` -} diff --git a/backend/pkg/model/invitation.go b/backend/pkg/model/invitation.go deleted file mode 100644 index 0135457..0000000 --- a/backend/pkg/model/invitation.go +++ /dev/null @@ -1,72 +0,0 @@ -package model - -import ( - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type InvitationStatus string - -const ( - InvitationCreated InvitationStatus = "created" - InvitationSent InvitationStatus = "sent" - InvitationAccepted InvitationStatus = "accepted" - InvitationDeclined InvitationStatus = "declined" - InvitationRevoked InvitationStatus = "revoked" -) - -type invitationDesc struct { - Email string `bson:"email" json:"email"` - Name string `bson:"name" json:"name"` - LastName string `bson:"lastName" json:"lastName"` - Comment string `bson:"comment" json:"comment"` -} - -func (id *invitationDesc) Collection() string { - return mservice.Invitations -} - -type Invitation struct { - PermissionBound `bson:",inline" json:",inline"` - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` - RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` - InviterRef bson.ObjectID `bson:"inviterRef" json:"inviterRef"` - Status InvitationStatus `bson:"status" json:"status"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` - Content invitationDesc `bson:"description" json:"description"` -} - -func (*Invitation) Collection() string { - return mservice.Invitations -} - -type employeeDesc struct { - Description Describable `bson:"description" json:"description"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` -} - -type organizationDesc struct { - Description Describable `bson:"description" json:"description"` - LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` -} - -type invitationStorable struct { - storable.Base `bson:",inline" json:",inline"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` -} - -type PublicInvitation struct { - Storable invitationStorable `bson:"storable" json:"storable"` - Employee employeeDesc `bson:"employee" json:"employee"` - Organization organizationDesc `bson:"organization" json:"organization"` - Role Describable `bson:"role" json:"role"` - Invitation invitationDesc `bson:"invitation" json:"invitation"` - RequiresRegistration bool `bson:"registrationRequired" json:"registrationRequired"` -} - -func (pi *PublicInvitation) Collection() string { - return mservice.Invitations -} diff --git a/backend/pkg/model/invitation_status.go b/backend/pkg/model/invitation_status.go deleted file mode 100644 index b5799c2..0000000 --- a/backend/pkg/model/invitation_status.go +++ /dev/null @@ -1,18 +0,0 @@ -package model - -func PendingInvitationStatuses() []InvitationStatus { - return []InvitationStatus{InvitationCreated, InvitationSent} -} - -func PendingInvitationStatusValues() []string { - statuses := PendingInvitationStatuses() - values := make([]string, 0, len(statuses)) - for _, status := range statuses { - values = append(values, string(status)) - } - return values -} - -func IsPendingInvitationStatus(status InvitationStatus) bool { - return status == InvitationCreated || status == InvitationSent -} diff --git a/backend/pkg/model/invitation_test.go b/backend/pkg/model/invitation_test.go deleted file mode 100644 index fd80f2b..0000000 --- a/backend/pkg/model/invitation_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model - -import "testing" - -func TestPendingInvitationStatusValues(t *testing.T) { - got := PendingInvitationStatusValues() - want := []string{"created", "sent"} - if len(got) != len(want) { - t.Fatalf("pending status count mismatch: got %d want %d", len(got), len(want)) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("pending status %d mismatch: got %q want %q", i, got[i], want[i]) - } - } - - if !IsPendingInvitationStatus(InvitationCreated) { - t.Fatal("created must be pending") - } - if !IsPendingInvitationStatus(InvitationSent) { - t.Fatal("sent must be pending") - } - for _, status := range []InvitationStatus{InvitationAccepted, InvitationDeclined, InvitationRevoked} { - if IsPendingInvitationStatus(status) { - t.Fatalf("%q must not be pending", status) - } - } -} - -func TestInvitationValidateEmail(t *testing.T) { - tests := []struct { - name string - email string - wantErr bool - }{ - {name: "valid", email: "invitee@example.com"}, - {name: "whitespace padded valid", email: " invitee@example.com "}, - {name: "empty", email: "", wantErr: true}, - {name: "whitespace only", email: " ", wantErr: true}, - {name: "missing domain", email: "invitee@", wantErr: true}, - {name: "missing at", email: "invitee.example.com", wantErr: true}, - {name: "display name", email: "Invitee ", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inv := &Invitation{ - Status: InvitationCreated, - Content: invitationDesc{ - Email: tt.email, - }, - } - err := inv.Validate() - if tt.wantErr && err == nil { - t.Fatal("expected validation error") - } - if !tt.wantErr && err != nil { - t.Fatalf("expected valid invitation, got %v", err) - } - }) - } -} diff --git a/backend/pkg/model/invitation_validation.go b/backend/pkg/model/invitation_validation.go deleted file mode 100644 index dea9556..0000000 --- a/backend/pkg/model/invitation_validation.go +++ /dev/null @@ -1,23 +0,0 @@ -package model - -import ( - "net/mail" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -func (i *Invitation) Validate() error { - if i == nil { - return merrors.InvalidArgument("invitation is required", "invitation") - } - if strings.TrimSpace(i.Content.Email) == "" { - return merrors.InvalidArgument("invitation email must not be empty", "description.email") - } - trimmedEmail := strings.TrimSpace(i.Content.Email) - parsed, err := mail.ParseAddress(trimmedEmail) - if err != nil || parsed.Address != trimmedEmail { - return merrors.InvalidArgument("invitation email is malformed", "description.email") - } - return nil -} diff --git a/backend/pkg/model/ledger.go b/backend/pkg/model/ledger.go deleted file mode 100644 index cf8bd88..0000000 --- a/backend/pkg/model/ledger.go +++ /dev/null @@ -1,132 +0,0 @@ -package model - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model/account_role" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// AccountType defines the category of account (asset, liability, revenue, expense). -type LedgerAccountType string - -const ( - LedgerAccountTypeAsset LedgerAccountType = "asset" - LedgerAccountTypeLiability LedgerAccountType = "liability" - LedgerAccountTypeRevenue LedgerAccountType = "revenue" - LedgerAccountTypeExpense LedgerAccountType = "expense" -) - -// AccountStatus tracks the operational state of an account. -type LedgerAccountStatus string - -const ( - LedgerAccountStatusActive LedgerAccountStatus = "active" - LedgerAccountStatusFrozen LedgerAccountStatus = "frozen" - LedgerAccountStatusClosed LedgerAccountStatus = "closed" -) - -type LedgerAccountScope string - -const ( - LedgerAccountScopeOrganization LedgerAccountScope = "organization" - LedgerAccountScopeSystem LedgerAccountScope = "system" -) - -type SystemAccountPurpose string - -const ( - SystemAccountPurposeExternalSource SystemAccountPurpose = "external_source" - SystemAccountPurposeExternalSink SystemAccountPurpose = "external_sink" -) - -// Account represents a ledger account that holds balances for a specific currency. -type LedgerAccount struct { - storable.Base `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - - // Scope defines whether the account belongs to an organization - // or is a system-level ledger account used for internal accounting. - Scope LedgerAccountScope `bson:"scope" json:"scope"` - - // SystemPurpose specifies the role of a system-scoped account - // (e.g., external source or sink of funds). Must be set for system accounts - // and must be nil for organization accounts. - SystemPurpose *SystemAccountPurpose `bson:"systemPurpose,omitempty" json:"systemPurpose,omitempty"` - - // OrganizationRef links the account to an organization. - // Must be set for organization accounts and nil for system accounts. - OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` - - // Role defines the functional purpose of the account within an organization - // (e.g., pending, operating, settlement, hold, etc.). - // Must be set for organization accounts and omitted for system accounts. - Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"` - - // AccountCode is a logical classification code of the account - // (e.g., "asset:cash:usd") used for reporting and grouping. - AccountCode string `bson:"accountCode" json:"accountCode"` - - // Currency is the ISO 4217 currency code the account operates in. - Currency string `bson:"currency" json:"currency"` - - // AccountType defines the accounting category of the account - // (asset, liability, revenue, expense). - AccountType LedgerAccountType `bson:"accountType" json:"accountType"` - - // Status represents the operational state of the account. - Status LedgerAccountStatus `bson:"status" json:"status"` - - // AllowNegative defines whether the account is allowed to have - // a negative balance (used for system control accounts). - AllowNegative bool `bson:"allowNegative" json:"allowNegative"` - - // OwnerRef optionally links the account to a specific owner entity - // (e.g., user or sub-entity within the organization). - OwnerRef *bson.ObjectID `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` - - // Metadata holds additional arbitrary key-value attributes. - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` -} - -// Collection implements storable.Storable. -func (*LedgerAccount) Collection() string { - return mservice.LedgerAccounts -} - -// Validate enforces scope-specific invariants for ledger accounts. -func (a *LedgerAccount) Validate() error { - if a == nil { - return merrors.InvalidArgument("ledger account is required") - } - - switch a.Scope { - case LedgerAccountScopeOrganization: - if a.OrganizationRef == nil || a.OrganizationRef.IsZero() { - return merrors.InvalidArgument("organization_ref is required for organization accounts") - } - if strings.TrimSpace(string(a.Role)) == "" { - return merrors.InvalidArgument("role is required for organization accounts") - } - if a.SystemPurpose != nil { - return merrors.InvalidArgument("system_purpose must be nil for organization accounts") - } - case LedgerAccountScopeSystem: - if a.OrganizationRef != nil && !a.OrganizationRef.IsZero() { - return merrors.InvalidArgument("organization_ref must be nil for system accounts") - } - if strings.TrimSpace(string(a.Role)) != "" { - return merrors.InvalidArgument("role must be empty for system accounts") - } - if a.SystemPurpose == nil { - return merrors.InvalidArgument("system_purpose is required for system accounts") - } - default: - return merrors.InvalidArgument("scope is required") - } - - return nil -} diff --git a/backend/pkg/model/object.go b/backend/pkg/model/object.go deleted file mode 100644 index 7b39819..0000000 --- a/backend/pkg/model/object.go +++ /dev/null @@ -1,20 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type TypeBound struct { - Type mservice.Type `bson:"type" json:"type"` -} - -type ObjectRefs struct { - TypeBound `bson:"inline" json:"inline"` - Refs []bson.ObjectID `bson:"refs,omitempty" json:"refs,omitempty"` -} - -type ObjectRef struct { - TypeBound `bson:"inline" json:"inline"` - Ref bson.ObjectID `bson:"ref,omitempty" json:"ref,omitempty"` -} diff --git a/backend/pkg/model/organization.go b/backend/pkg/model/organization.go deleted file mode 100644 index 0f718b5..0000000 --- a/backend/pkg/model/organization.go +++ /dev/null @@ -1,111 +0,0 @@ -package model - -import ( - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type OrganizationSignupState string - -const ( - OrganizationSignupStatePending OrganizationSignupState = "pending_signup_provisioning" - OrganizationSignupStateBlocked OrganizationSignupState = "blocked_signup_provisioning" - OrganizationSignupStateActive OrganizationSignupState = "active" -) - -type OrganizationBase struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - TenantRef bson.ObjectID `bson:"tenantRef" json:"tenantRef"` - TimeZone string `bson:"timeZone" json:"timeZone"` - LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` - SignupState OrganizationSignupState `bson:"signupState,omitempty" json:"signupState,omitempty"` -} - -func (*OrganizationBase) Collection() string { - return mservice.Organizations -} - -type Organization struct { - OrganizationBase `bson:",inline" json:",inline"` - Members []bson.ObjectID `bson:"members" json:"members"` - Provisioning *OrganizationProvisioning `bson:"provisioning,omitempty" json:"-"` -} - -// OrganizationProvisioning tracks an organization that is being provisioned and -// has no members or roles yet. It is the only link between such an organization -// and its future owner, and it also guards concurrent provisioning attempts. -// Activation clears only the lease fields: the provenance stays so a retried -// create can recognize the organization it already activated. -// -// It lives on Organization rather than OrganizationBase on purpose: update -// paths replace OrganizationBase wholesale from client payloads, which would -// otherwise wipe it. It is never exposed over the API. -type OrganizationProvisioning struct { - OwnerRef bson.ObjectID `bson:"ownerRef" json:"-"` - // Name snapshots the organization name of the first attempt. It is the - // immutable half of the dedup key: renaming the organization must not - // detach it from retries of the create that produced it. - Name string `bson:"name,omitempty" json:"-"` - Origin string `bson:"origin" json:"-"` - ActorRef string `bson:"actorRef,omitempty" json:"-"` - StartedAt time.Time `bson:"startedAt" json:"-"` - LeaseOwner string `bson:"leaseOwner,omitempty" json:"-"` - LeaseExpiresAt time.Time `bson:"leaseExpiresAt,omitempty" json:"-"` -} - -const ( - OrganizationSignupStateField = "signupState" - OrganizationProvisioningField = "provisioning" - OrganizationProvisioningOwnerRefField = "provisioning.ownerRef" - OrganizationProvisioningNameField = "provisioning.name" - OrganizationProvisioningLeaseOwnerField = "provisioning.leaseOwner" - OrganizationProvisioningLeaseField = "provisioning.leaseExpiresAt" -) - -// LeaseHeld reports whether another attempt still owns the provisioning lease. -func (p *OrganizationProvisioning) LeaseHeld(now time.Time) bool { - return p != nil && p.LeaseExpiresAt.After(now) -} - -// CanFinishProvisioning reports whether an operator can drive this organization -// to active through the backoffice create operation: only pending organizations -// stamped by operator provisioning are resumable. Signup-era organizations -// carry no provisioning stamp. -func (o *Organization) CanFinishProvisioning() bool { - return o != nil && o.Provisioning != nil && o.SignupState == OrganizationSignupStatePending -} - -func (o *Organization) NormalizeSignupState() { - if o == nil { - return - } - switch o.SignupState { - case OrganizationSignupStatePending, OrganizationSignupStateBlocked, OrganizationSignupStateActive: - default: - o.SignupState = OrganizationSignupStateActive - } -} - -type OrganizationBound interface { - GetOrganizationRef() bson.ObjectID - SetOrganizationRef(organizationRef bson.ObjectID) -} - -const ( - OrganizationRefField = "organizationRef" -) - -type OrganizationBoundBase struct { - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` -} - -func (a *OrganizationBoundBase) GetOrganizationRef() bson.ObjectID { - return a.OrganizationRef -} - -func (a *OrganizationBoundBase) SetOrganizationRef(organizationRef bson.ObjectID) { - a.OrganizationRef = organizationRef -} diff --git a/backend/pkg/model/ownerbound.go b/backend/pkg/model/ownerbound.go deleted file mode 100644 index 2b13b6d..0000000 --- a/backend/pkg/model/ownerbound.go +++ /dev/null @@ -1,21 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// OwnerBound is implemented by resources that follow the personal-vs-org -// ownership model (ownerKind + ownerRef). It is the type constraint for -// ScopedDB and the canonical contract for scoped-access resources. -// -// permissionRef is deliberately absent: it is only meaningful for org-owned -// resources and is supplied separately (as OrgPolicyRef) to the enforcement -// layer. Mixing it here would re-introduce the very coupling this interface -// is meant to eliminate. -type OwnerBound interface { - storable.Storable - OrganizationBound - GetOwnerKind() OwnerKind - GetOwnerRef() bson.ObjectID -} diff --git a/backend/pkg/model/ownerkind.go b/backend/pkg/model/ownerkind.go deleted file mode 100644 index 47313d9..0000000 --- a/backend/pkg/model/ownerkind.go +++ /dev/null @@ -1,24 +0,0 @@ -package model - -// OwnerKind describes who owns a scopable resource (ledger account, recipient, -// payment method, …). It is the single source of truth for the personal-vs-org -// access model: a resource is either owned by an individual account (personal) -// or by an organization (org-scoped). -// -// The string values are persisted in Mongo and used in policy checks, so they -// must stay stable. Scopable resources embed model.Owner (which carries an -// OwnerKind) to keep one canonical vocabulary across the codebase. -type OwnerKind string - -const ( - // OwnerKindAccount marks a personal resource. Access is proven by ownership - // alone: caller is allowed iff ownerRef == caller's account. No policy check. - OwnerKindAccount OwnerKind = "account" - // OwnerKindOrganization marks an org-scoped resource. Access is gated by the - // relevant org policy via the Enforcer. - OwnerKindOrganization OwnerKind = "organization" -) - -func (k OwnerKind) IsValid() bool { - return k == OwnerKindAccount || k == OwnerKindOrganization -} diff --git a/backend/pkg/model/payment.go b/backend/pkg/model/payment.go deleted file mode 100644 index f703773..0000000 --- a/backend/pkg/model/payment.go +++ /dev/null @@ -1,276 +0,0 @@ -package model - -import ( - "bytes" - "encoding/json" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PaymentType int - -const ( - PaymentTypeIban PaymentType = iota - PaymentTypeCard - PaymentTypeCardToken - PaymentTypeBankAccount - PaymentTypeWallet - PaymentTypeCryptoAddress - PaymentTypeLedger -) - -var paymentTypeToString = map[PaymentType]string{ - PaymentTypeIban: "iban", - PaymentTypeCard: "card", - PaymentTypeCardToken: "cardToken", - PaymentTypeBankAccount: "bankAccount", - PaymentTypeWallet: "wallet", - PaymentTypeCryptoAddress: "cryptoAddress", - PaymentTypeLedger: "ledger", -} - -var paymentTypeFromString = map[string]PaymentType{ - "iban": PaymentTypeIban, - "card": PaymentTypeCard, - "cardToken": PaymentTypeCardToken, - "bankAccount": PaymentTypeBankAccount, - "wallet": PaymentTypeWallet, - "cryptoAddress": PaymentTypeCryptoAddress, - "ledger": PaymentTypeLedger, -} - -func (t PaymentType) String() string { - if v, ok := paymentTypeToString[t]; ok { - return v - } - return "iban" -} - -func (t PaymentType) IsValid() bool { - _, ok := paymentTypeToString[t] - return ok -} - -func (t PaymentType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -func (t *PaymentType) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := paymentTypeFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown PaymentType: %q", val)) - } - *t = v - return nil -} - -type PaymentMethodOwnerKind string - -const ( - PaymentMethodOwnerKindUnspecified PaymentMethodOwnerKind = "" - PaymentMethodOwnerKindAccount PaymentMethodOwnerKind = "ACCOUNT" - PaymentMethodOwnerKindRecipient PaymentMethodOwnerKind = "RECIPIENT" -) - -func (k PaymentMethodOwnerKind) String() string { - return string(k) -} - -func (k PaymentMethodOwnerKind) IsValid() bool { - switch k { - case PaymentMethodOwnerKindAccount, PaymentMethodOwnerKindRecipient: - return true - default: - return false - } -} - -type PaymentMethod struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - - OwnerKind PaymentMethodOwnerKind `bson:"ownerKind" json:"ownerKind"` - OwnerRef bson.ObjectID `bson:"ownerRef" json:"ownerRef"` - Type PaymentType `bson:"type" json:"type"` - Data bson.Raw `bson:"data" json:"data"` - IsMain bool `bson:"isMain" json:"isMain"` -} - -type paymentMethodJSON struct { - PermissionBound `json:",inline"` - Describable `json:",inline"` - - OwnerKind PaymentMethodOwnerKind `json:"ownerKind"` - OwnerRef bson.ObjectID `json:"ownerRef"` - Type PaymentType `json:"type"` - Data json.RawMessage `json:"data"` - IsMain bool `json:"isMain"` -} - -func (m PaymentMethod) MarshalJSON() ([]byte, error) { - marshaledData, err := m.marshalDataJSON() - if err != nil { - return nil, err - } - - payload := paymentMethodJSON{ - PermissionBound: m.PermissionBound, - Describable: m.Describable, - OwnerKind: m.OwnerKind, - OwnerRef: m.OwnerRef, - Type: m.Type, - Data: marshaledData, - IsMain: m.IsMain, - } - - return json.Marshal(payload) -} - -func (m *PaymentMethod) UnmarshalJSON(data []byte) error { - var payload paymentMethodJSON - if err := json.Unmarshal(data, &payload); err != nil { - return err - } - - m.PermissionBound = payload.PermissionBound - m.Describable = payload.Describable - m.OwnerKind = payload.OwnerKind - m.OwnerRef = payload.OwnerRef - m.Type = payload.Type - m.IsMain = payload.IsMain - - if len(payload.Data) == 0 || bytes.Equal(payload.Data, []byte("null")) { - m.Data = nil - return nil - } - - if raw, ok, err := m.unmarshalTypedDataJSON(payload.Data); ok || err != nil { - if err != nil { - return err - } - m.Data = raw - return nil - } - - var rawData map[string]any - if err := json.Unmarshal(payload.Data, &rawData); err != nil { - return err - } - - raw, err := bson.Marshal(rawData) - if err != nil { - return err - } - - m.Data = raw - return nil -} - -func (m PaymentMethod) marshalDataJSON() (json.RawMessage, error) { - if len(m.Data) == 0 { - return nil, nil - } - - var payload any - var err error - switch m.Type { - case PaymentTypeIban: - payload, err = marshalTypedPaymentData[IbanPaymentData](m.Data) - case PaymentTypeCard: - payload, err = marshalTypedPaymentData[CardPaymentData](m.Data) - case PaymentTypeCardToken: - payload, err = marshalTypedPaymentData[TokenPaymentData](m.Data) - case PaymentTypeBankAccount: - payload, err = marshalTypedPaymentData[RussianBankAccountPaymentData](m.Data) - case PaymentTypeWallet: - payload, err = marshalTypedPaymentData[WalletPaymentData](m.Data) - case PaymentTypeCryptoAddress: - payload, err = marshalTypedPaymentData[CryptoAddressPaymentData](m.Data) - case PaymentTypeLedger: - payload, err = marshalTypedPaymentData[LedgerPaymentData](m.Data) - default: - var data bson.M - if err := bson.Unmarshal(m.Data, &data); err != nil { - return nil, err - } - if data == nil { - return nil, nil - } - payload = data - } - if err != nil { - return nil, err - } - - data, err := json.Marshal(payload) - if err != nil { - return nil, err - } - return data, nil -} - -func (m PaymentMethod) unmarshalTypedDataJSON(data json.RawMessage) (bson.Raw, bool, error) { - switch m.Type { - case PaymentTypeIban: - return unmarshalTypedPaymentData[IbanPaymentData](data) - case PaymentTypeCard: - return unmarshalTypedPaymentData[CardPaymentData](data) - case PaymentTypeCardToken: - return unmarshalTypedPaymentData[TokenPaymentData](data) - case PaymentTypeBankAccount: - return unmarshalTypedPaymentData[RussianBankAccountPaymentData](data) - case PaymentTypeWallet: - return unmarshalTypedPaymentData[WalletPaymentData](data) - case PaymentTypeCryptoAddress: - return unmarshalTypedPaymentData[CryptoAddressPaymentData](data) - case PaymentTypeLedger: - return unmarshalTypedPaymentData[LedgerPaymentData](data) - default: - return nil, false, nil - } -} - -func marshalTypedPaymentData[T any](raw bson.Raw) (T, error) { - var payload T - err := bson.Unmarshal(raw, &payload) - return payload, err -} - -func unmarshalTypedPaymentData[T any](data json.RawMessage) (bson.Raw, bool, error) { - var payload T - if err := json.Unmarshal(data, &payload); err != nil { - return nil, true, err - } - raw, err := bson.Marshal(payload) - if err != nil { - return nil, true, err - } - return raw, true, nil -} - -type PaymentStatus string - -const ( - PaymentStatusUnspecified PaymentStatus = "unspecified" - - // Intent exists, no funds touched yet. - PaymentStatusCreated PaymentStatus = "created" - - // Internal Sendico operations: holds, ledger, FX, fee calc. - PaymentStatusAuthorizing PaymentStatus = "authorizing" - - // Funds are outside Sendico (rail/gateway/blockchain/provider). - // Observe lives ONLY in this state. - PaymentStatusExecuting PaymentStatus = "executing" - - // Final states. - PaymentStatusSucceeded PaymentStatus = "success" - PaymentStatusFailed PaymentStatus = "failed" - PaymentStatusCancelled PaymentStatus = "cancelled" -) diff --git a/backend/pkg/model/payment_ledger.go b/backend/pkg/model/payment_ledger.go deleted file mode 100644 index ee4c32b..0000000 --- a/backend/pkg/model/payment_ledger.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "encoding/json" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type LedgerPaymentData struct { - AccountRef string `bson:"accountRef,omitempty" json:"account_ref,omitempty"` - AccountCode string `bson:"accountCode,omitempty" json:"account_code,omitempty"` - ContraAccountRef string `bson:"contraAccountRef,omitempty" json:"contra_account_ref,omitempty"` - ContraAccountCode string `bson:"contraAccountCode,omitempty" json:"contra_account_code,omitempty"` -} - -func (d *LedgerPaymentData) UnmarshalJSON(data []byte) error { - var raw struct { - AccountRef string `json:"account_ref"` - LedgerAccountRef string `json:"ledger_account_ref"` - AccountCode string `json:"account_code"` - ContraAccountRef string `json:"contra_account_ref"` - ContraLedgerAccountRef string `json:"contra_ledger_account_ref"` - ContraAccountCode string `json:"contra_account_code"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - accountRef := strings.TrimSpace(raw.AccountRef) - ledgerAccountRef := strings.TrimSpace(raw.LedgerAccountRef) - if ledgerAccountRef != "" { - if accountRef != "" && accountRef != ledgerAccountRef { - return merrors.InvalidArgument("account_ref and ledger_account_ref must match") - } - accountRef = ledgerAccountRef - } - - contraAccountRef := strings.TrimSpace(raw.ContraAccountRef) - contraLedgerAccountRef := strings.TrimSpace(raw.ContraLedgerAccountRef) - if contraLedgerAccountRef != "" { - if contraAccountRef != "" && contraAccountRef != contraLedgerAccountRef { - return merrors.InvalidArgument("contra_account_ref and contra_ledger_account_ref must match") - } - contraAccountRef = contraLedgerAccountRef - } - - d.AccountRef = accountRef - d.AccountCode = strings.TrimSpace(raw.AccountCode) - d.ContraAccountRef = contraAccountRef - d.ContraAccountCode = strings.TrimSpace(raw.ContraAccountCode) - return nil -} - -func (d *LedgerPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.AccountRef = firstPaymentDataString(raw, "accountRef", "account_ref", "ledgerAccountRef", "ledger_account_ref") - d.AccountCode = firstPaymentDataString(raw, "accountCode", "account_code") - d.ContraAccountRef = firstPaymentDataString(raw, "contraAccountRef", "contra_account_ref", "contraLedgerAccountRef", "contra_ledger_account_ref") - d.ContraAccountCode = firstPaymentDataString(raw, "contraAccountCode", "contra_account_code") - return nil -} - -func (m *PaymentMethod) AsLedger() (*LedgerPaymentData, error) { - if m.Type != PaymentTypeLedger { - return nil, merrors.InvalidArgument("payment method type is "+m.Type.String()+", not ledger", "type") - } - var d LedgerPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} diff --git a/backend/pkg/model/payment_method_json_test.go b/backend/pkg/model/payment_method_json_test.go deleted file mode 100644 index c375a6b..0000000 --- a/backend/pkg/model/payment_method_json_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/person" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPaymentMethodMarshalRussianBankAccountUsesJSONRecipientNames(t *testing.T) { - raw, err := bson.Marshal(RussianBankAccountPaymentData{ - Recipient: person.Name{ - FirstName: "Success", - LastName: "Success", - }, - Inn: "7707083893", - Kpp: "770701001", - BankName: "AsterBank Test Bank", - Bik: "044525225", - AccountNumber: "40702810000000080001", - CorrespondentAccount: "30101810400000000225", - }) - if err != nil { - t.Fatalf("marshal bank account data: %v", err) - } - - payload, err := json.Marshal(PaymentMethod{ - Type: PaymentTypeBankAccount, - Data: raw, - }) - if err != nil { - t.Fatalf("marshal payment method: %v", err) - } - - var decoded struct { - Type PaymentType `json:"type"` - Data map[string]any `json:"data"` - } - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal payload: %v", err) - } - if decoded.Type != PaymentTypeBankAccount { - t.Fatalf("type mismatch: got=%s want=%s", decoded.Type, PaymentTypeBankAccount) - } - - recipient, ok := decoded.Data["recipient"].(map[string]any) - if !ok { - t.Fatalf("recipient is not an object: %#v", decoded.Data["recipient"]) - } - if got, want := recipient["first_name"], "Success"; got != want { - t.Fatalf("recipient.first_name mismatch: got=%#v want=%q", got, want) - } - if got, want := recipient["last_name"], "Success"; got != want { - t.Fatalf("recipient.last_name mismatch: got=%#v want=%q", got, want) - } - if _, ok := recipient["firstName"]; ok { - t.Fatalf("recipient must not expose BSON firstName: %#v", recipient) - } - if _, ok := recipient["lastName"]; ok { - t.Fatalf("recipient must not expose BSON lastName: %#v", recipient) - } -} - -func TestPaymentMethodUnmarshalRussianBankAccountStoresBSONRecipientNames(t *testing.T) { - payload := []byte(`{ - "type": "bankAccount", - "data": { - "recipient": { - "first_name": "Success", - "last_name": "Success" - }, - "inn": "7707083893", - "kpp": "770701001", - "bankName": "AsterBank Test Bank", - "bik": "044525225", - "accountNumber": "40702810000000080001", - "correspondentAccount": "30101810400000000225" - } - }`) - - var method PaymentMethod - if err := json.Unmarshal(payload, &method); err != nil { - t.Fatalf("unmarshal payment method: %v", err) - } - - var stored bson.M - if err := bson.Unmarshal(method.Data, &stored); err != nil { - t.Fatalf("unmarshal stored data: %v", err) - } - recipient, ok := bsonDocumentMap(stored["recipient"]) - if !ok { - t.Fatalf("stored recipient is not an object: %#v", stored["recipient"]) - } - if got, want := recipient["firstName"], "Success"; got != want { - t.Fatalf("stored recipient.firstName mismatch: got=%#v want=%q", got, want) - } - if got, want := recipient["lastName"], "Success"; got != want { - t.Fatalf("stored recipient.lastName mismatch: got=%#v want=%q", got, want) - } - if _, ok := recipient["first_name"]; ok { - t.Fatalf("stored recipient must not use JSON first_name: %#v", recipient) - } - if _, ok := recipient["last_name"]; ok { - t.Fatalf("stored recipient must not use JSON last_name: %#v", recipient) - } -} - -func bsonDocumentMap(value any) (map[string]any, bool) { - switch typed := value.(type) { - case bson.M: - return typed, true - case bson.D: - out := make(map[string]any, len(typed)) - for _, element := range typed { - out[element.Key] = element.Value - } - return out, true - default: - return nil, false - } -} diff --git a/backend/pkg/model/permission.go b/backend/pkg/model/permission.go deleted file mode 100644 index b796f23..0000000 --- a/backend/pkg/model/permission.go +++ /dev/null @@ -1,33 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PermissionBoundStorable interface { - storable.Storable - OrganizationBound - Archivable - GetPermissionRef() bson.ObjectID - SetPermissionRef(permissionRef bson.ObjectID) -} - -type PermissionBound struct { - storable.Base `bson:",inline" json:",inline"` - ArchivableBase `bson:",inline" json:",inline"` - OrganizationBoundBase `bson:",inline" json:",inline"` - PermissionRef bson.ObjectID `bson:"permissionRef" json:"permissionRef"` -} - -func (b *PermissionBound) GetPermissionRef() bson.ObjectID { - return b.PermissionRef -} - -func (b *PermissionBound) GetOrganizationRef() bson.ObjectID { - return b.OrganizationRef -} - -func (b *PermissionBound) SetPermissionRef(permissionRef bson.ObjectID) { - b.PermissionRef = permissionRef -} diff --git a/backend/pkg/model/rba.go b/backend/pkg/model/rba.go deleted file mode 100644 index 6ec3ef9..0000000 --- a/backend/pkg/model/rba.go +++ /dev/null @@ -1,52 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/person" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type RussianBankAccountPaymentData struct { - Recipient person.Name `bson:"recipient" json:"recipient"` - Inn string `bson:"inn" json:"inn"` - Kpp string `bson:"kpp" json:"kpp"` - BankName string `bson:"bankName" json:"bankName"` - Bik string `bson:"bik" json:"bik"` - AccountNumber string `bson:"accountNumber" json:"accountNumber"` - CorrespondentAccount string `bson:"correspondentAccount" json:"correspondentAccount"` -} - -// UnmarshalBSON accepts both stored recipient names and the API names in the -// intermediate document built by a payment-method PATCH. -func (d *RussianBankAccountPaymentData) UnmarshalBSON(data []byte) error { - type stored RussianBankAccountPaymentData - var decoded stored - if err := bson.Unmarshal(data, &decoded); err != nil { - return err - } - var fields struct { - Recipient map[string]any `bson:"recipient"` - } - if err := bson.Unmarshal(data, &fields); err != nil { - return err - } - decoded.Recipient.FirstName = firstPaymentDataString(fields.Recipient, "firstName", "first_name") - decoded.Recipient.LastName = firstPaymentDataString(fields.Recipient, "lastName", "last_name") - *d = RussianBankAccountPaymentData(decoded) - return nil -} - -func (m *PaymentMethod) AsRussianBankAccount() (*RussianBankAccountPaymentData, error) { - if m.Type != PaymentTypeBankAccount { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not bankAccount", m.Type), "type") - } - - var d RussianBankAccountPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/recipient.go b/backend/pkg/model/recipient.go deleted file mode 100644 index 10f8a18..0000000 --- a/backend/pkg/model/recipient.go +++ /dev/null @@ -1,128 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type RecipientStatus int - -const ( - RecipientStatusReady RecipientStatus = iota - RecipientStatusRegistered - RecipientStatusNotRegistered -) - -var recipientStatusToString = map[RecipientStatus]string{ - RecipientStatusReady: "ready", - RecipientStatusRegistered: "registered", - RecipientStatusNotRegistered: "notRegistered", -} - -var recipientStatusFromString = map[string]RecipientStatus{ - "ready": RecipientStatusReady, - "registered": RecipientStatusRegistered, - "notRegistered": RecipientStatusNotRegistered, -} - -func (s RecipientStatus) String() string { - if v, ok := recipientStatusToString[s]; ok { - return v - } - return "ready" // дефолт, можно поменять -} - -// JSON: храним как строку ("ready" / "registered" / "notRegistered") -func (s RecipientStatus) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -func (s *RecipientStatus) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := recipientStatusFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientStatus: %q", val)) - } - *s = v - return nil -} - -// RecipientType { internal, external } - -type RecipientType int - -const ( - RecipientTypeInternal RecipientType = iota - RecipientTypeExternal -) - -var recipientTypeToString = map[RecipientType]string{ - RecipientTypeInternal: "internal", - RecipientTypeExternal: "external", -} - -var recipientTypeFromString = map[string]RecipientType{ - "internal": RecipientTypeInternal, - "external": RecipientTypeExternal, -} - -func (t RecipientType) String() string { - if v, ok := recipientTypeToString[t]; ok { - return v - } - return "internal" -} - -func (t RecipientType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -func (t *RecipientType) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := recipientTypeFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientType: %q", val)) - } - *t = v - return nil -} - -// Owner is the ownership token stored under the "owner" BSON/JSON key. -// storable.Ref must stay embedded: a named field would keep the bson ",inline" -// but encoding/json has no inline option and would nest the ref under "Ref", -// while embedding promotes it, so both wire paths are owner.ref and owner.kind. -type Owner struct { - storable.Ref `bson:",inline" json:",inline"` - Kind OwnerKind `bson:"kind" json:"kind"` -} - -type Recipient struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - Email string `bson:"email" json:"email"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` - - Status RecipientStatus `bson:"status" json:"status"` - Type RecipientType `bson:"type" json:"type"` - Owner Owner `bson:"owner" json:"owner"` - - // INN is the recipient's taxpayer identifier. It is the canonical source of - // the INN for payouts to this recipient (flows into customer.tax_identifier), - // replacing the per-payment-method inn field for recipient-owned methods. - INN string `bson:"inn,omitempty" json:"inn,omitempty"` -} - -func (r *Recipient) GetOwnerKind() OwnerKind { return r.Owner.Kind } -func (r *Recipient) GetOwnerRef() bson.ObjectID { return r.Owner.Ref.Ref } -func (r *Recipient) SetOwnerKind(k OwnerKind) { r.Owner.Kind = k } -func (r *Recipient) SetOwnerRef(ref bson.ObjectID) { r.Owner.Ref.Ref = ref } diff --git a/backend/pkg/model/recipient_json_test.go b/backend/pkg/model/recipient_json_test.go deleted file mode 100644 index 563ec39..0000000 --- a/backend/pkg/model/recipient_json_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// TestOwnerJSONFlattensRef locks in the on-wire shape documented on Owner: -// owner.ref and owner.kind must both be top-level keys. Ref must stay -// anonymously embedded — a NAMED field with a `json:",inline"` tag is a -// BSON-only convention and is not flattened by encoding/json, which would -// silently nest it under the Go field name instead (a real regression this -// test caught: {"Ref":{"ref":"..."},"kind":"..."} instead of -// {"ref":"...","kind":"..."}), breaking every JSON consumer of owner.ref. -func TestOwnerJSONFlattensRef(t *testing.T) { - ownerRef := bson.NewObjectID() - owner := Owner{Kind: OwnerKindAccount} - owner.Ref.Ref = ownerRef - - b, err := json.Marshal(owner) - if err != nil { - t.Fatalf("marshal owner: %v", err) - } - - var decoded map[string]any - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal owner json: %v", err) - } - if got, want := decoded["ref"], ownerRef.Hex(); got != want { - t.Fatalf("top-level owner.ref = %v, want %q (got JSON: %s)", got, want, b) - } - if got, want := decoded["kind"], string(OwnerKindAccount); got != want { - t.Fatalf("top-level owner.kind = %v, want %q (got JSON: %s)", got, want, b) - } - if _, nested := decoded["Ref"]; nested { - t.Fatalf("owner.ref must not nest under the Go field name \"Ref\", got JSON: %s", b) - } -} - -func TestRecipientJSONRoundTripsOwner(t *testing.T) { - r := &Recipient{} - r.SetOwnerKind(OwnerKindOrganization) - r.SetOwnerRef(bson.NewObjectID()) - - b, err := json.Marshal(r) - if err != nil { - t.Fatalf("marshal recipient: %v", err) - } - - var roundTripped Recipient - if err := json.Unmarshal(b, &roundTripped); err != nil { - t.Fatalf("unmarshal recipient json: %v", err) - } - if roundTripped.GetOwnerKind() != r.GetOwnerKind() { - t.Fatalf("owner kind round-trip = %v, want %v", roundTripped.GetOwnerKind(), r.GetOwnerKind()) - } - if roundTripped.GetOwnerRef() != r.GetOwnerRef() { - t.Fatalf("owner ref round-trip = %v, want %v", roundTripped.GetOwnerRef(), r.GetOwnerRef()) - } -} diff --git a/backend/pkg/model/recipient_wire_test.go b/backend/pkg/model/recipient_wire_test.go deleted file mode 100644 index ebb254b..0000000 --- a/backend/pkg/model/recipient_wire_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// The frontend parses owner as {"ref": ..., "kind": ...}; a regression to the -// nested {"Ref": {"ref": ...}} shape breaks every recipients screen. -func TestOwnerJSONIsFlat(t *testing.T) { - ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") - if err != nil { - t.Fatal(err) - } - - out, err := json.Marshal(Owner{ - Ref: storable.Ref{Ref: ref}, - Kind: "account", - }) - if err != nil { - t.Fatal(err) - } - - want := `{"ref":"69834f22fff13a7457793b6c","kind":"account"}` - if string(out) != want { - t.Fatalf("owner JSON = %s, want %s", out, want) - } - - var back Owner - if err := json.Unmarshal(out, &back); err != nil { - t.Fatal(err) - } - if back.Ref.Ref != ref || back.Kind != "account" { - t.Fatalf("round-trip lost data: %+v", back) - } -} - -// Mongo queries filter on owner.ref / owner.kind (see auth.ScopedDBImp), so -// the BSON document must keep those exact paths. -func TestOwnerBSONPathsUnchanged(t *testing.T) { - ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") - if err != nil { - t.Fatal(err) - } - - out, err := bson.Marshal(Owner{ - Ref: storable.Ref{Ref: ref}, - Kind: "account", - }) - if err != nil { - t.Fatal(err) - } - - var doc bson.M - if err := bson.Unmarshal(out, &doc); err != nil { - t.Fatal(err) - } - if doc["ref"] != ref || doc["kind"] != "account" { - t.Fatalf("bson doc = %v, want flat ref/kind", doc) - } -} diff --git a/backend/pkg/model/scopedstorablemodel.go b/backend/pkg/model/scopedstorablemodel.go deleted file mode 100644 index c96b6dc..0000000 --- a/backend/pkg/model/scopedstorablemodel.go +++ /dev/null @@ -1,19 +0,0 @@ -package model - -import "go.mongodb.org/mongo-driver/v2/bson" - -// ScopedStorable is the type constraint for resources that follow the -// personal-vs-org dual-ownership model. ScopedDBImp[T] provides generic -// CRUD and bulk operations for any T satisfying this interface without -// the permissionRef==NilObjectID sentinel pattern that ProtectedDBImp requires -// for personal-owned resources. -// -// Resources that embed model.PermissionBound and model.Owner satisfy -// ScopedStorable automatically once SetOwnerKind/SetOwnerRef methods are added. -type ScopedStorable interface { - OwnerBound - SetOwnerKind(OwnerKind) - SetOwnerRef(bson.ObjectID) - GetPermissionRef() bson.ObjectID - SetPermissionRef(bson.ObjectID) -} diff --git a/backend/pkg/model/signup_provisioning.go b/backend/pkg/model/signup_provisioning.go deleted file mode 100644 index 20eb96d..0000000 --- a/backend/pkg/model/signup_provisioning.go +++ /dev/null @@ -1,150 +0,0 @@ -package model - -import ( - "strings" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type SignupProvisioningState string - -const ( - SignupProvisioningStateStarted SignupProvisioningState = "started" - SignupProvisioningStateProvisioning SignupProvisioningState = "provisioning" - SignupProvisioningStateCompensating SignupProvisioningState = "compensating" - SignupProvisioningStateFailedPartialClean SignupProvisioningState = "failed_partial_cleanup" - SignupProvisioningStateFailedClean SignupProvisioningState = "failed_clean" - SignupProvisioningStateCompleted SignupProvisioningState = "completed" -) - -type SignupProvisioningCleanupStatus string - -const ( - SignupProvisioningCleanupStatusNotNeeded SignupProvisioningCleanupStatus = "not_needed" - SignupProvisioningCleanupStatusPending SignupProvisioningCleanupStatus = "pending" - SignupProvisioningCleanupStatusRunning SignupProvisioningCleanupStatus = "running" - SignupProvisioningCleanupStatusComplete SignupProvisioningCleanupStatus = "complete" - SignupProvisioningCleanupStatusPartial SignupProvisioningCleanupStatus = "partial" -) - -type SignupProvisioningTarget struct { - Chain ChainNetwork `bson:"chain" json:"chain"` - TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` - ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` -} - -func (t *SignupProvisioningTarget) Normalize() { - if t == nil { - return - } - t.Chain = ChainNetwork(strings.TrimSpace(t.Chain.String())) - t.TokenSymbol = strings.ToUpper(strings.TrimSpace(t.TokenSymbol)) - t.ContractAddress = strings.TrimSpace(t.ContractAddress) -} - -type SignupProvisionedWallet struct { - Chain ChainNetwork `bson:"chain" json:"chain"` - WalletRef string `bson:"walletRef" json:"walletRef"` -} - -func (w *SignupProvisionedWallet) Normalize() { - if w == nil { - return - } - w.Chain = ChainNetwork(strings.TrimSpace(w.Chain.String())) - w.WalletRef = strings.TrimSpace(w.WalletRef) -} - -type SignupProvisionedRoute struct { - WalletRef string `bson:"walletRef" json:"walletRef"` - RouteRef string `bson:"routeRef" json:"routeRef"` -} - -func (r *SignupProvisionedRoute) Normalize() { - if r == nil { - return - } - r.WalletRef = strings.TrimSpace(r.WalletRef) - r.RouteRef = strings.TrimSpace(r.RouteRef) -} - -type SignupProvisioningRecord struct { - storable.Base `bson:",inline" json:",inline"` - - NormalizedLogin string `bson:"normalizedLogin" json:"normalizedLogin"` - OrganizationRef string `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` - AttemptID string `bson:"attemptId" json:"attemptId"` - State SignupProvisioningState `bson:"state" json:"state"` - CleanupStatus SignupProvisioningCleanupStatus `bson:"cleanupStatus" json:"cleanupStatus"` - LeaseOwner string `bson:"leaseOwner" json:"leaseOwner"` - LeaseExpiresAt time.Time `bson:"leaseExpiresAt" json:"leaseExpiresAt"` - HeartbeatAt time.Time `bson:"heartbeatAt" json:"heartbeatAt"` - SelectedTargets []SignupProvisioningTarget `bson:"selectedTargets,omitempty" json:"selectedTargets,omitempty"` - Wallets []SignupProvisionedWallet `bson:"wallets,omitempty" json:"wallets,omitempty"` - Routes []SignupProvisionedRoute `bson:"routes,omitempty" json:"routes,omitempty"` - StepStatus map[string]string `bson:"stepStatus,omitempty" json:"stepStatus,omitempty"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` - LastCleanupError string `bson:"lastCleanupError,omitempty" json:"lastCleanupError,omitempty"` - ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` -} - -func (*SignupProvisioningRecord) Collection() string { - return mservice.SignupProvisioning -} - -func (r *SignupProvisioningRecord) Normalize() { - if r == nil { - return - } - r.NormalizedLogin = strings.ToLower(strings.TrimSpace(r.NormalizedLogin)) - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.AttemptID = strings.TrimSpace(r.AttemptID) - r.LeaseOwner = strings.TrimSpace(r.LeaseOwner) - r.LastError = strings.TrimSpace(r.LastError) - r.LastCleanupError = strings.TrimSpace(r.LastCleanupError) - - for i := range r.SelectedTargets { - r.SelectedTargets[i].Normalize() - } - for i := range r.Wallets { - r.Wallets[i].Normalize() - } - for i := range r.Routes { - r.Routes[i].Normalize() - } - if r.StepStatus == nil { - r.StepStatus = map[string]string{} - } -} - -func (r *SignupProvisioningRecord) Active() bool { - if r == nil { - return false - } - switch r.State { - case SignupProvisioningStateStarted, SignupProvisioningStateProvisioning, SignupProvisioningStateCompensating: - return true - default: - return false - } -} - -func (r *SignupProvisioningRecord) Terminal() bool { - if r == nil { - return false - } - switch r.State { - case SignupProvisioningStateFailedPartialClean, SignupProvisioningStateFailedClean, SignupProvisioningStateCompleted: - return true - default: - return false - } -} - -func (r *SignupProvisioningRecord) OrganizationObjectID() bson.ObjectID { - ref, _ := bson.ObjectIDFromHex(strings.TrimSpace(r.OrganizationRef)) - return ref -} diff --git a/backend/pkg/model/wallet.go b/backend/pkg/model/wallet.go deleted file mode 100644 index aae91b6..0000000 --- a/backend/pkg/model/wallet.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type WalletPaymentData struct { - WalletID string `bson:"walletId" json:"walletId"` -} - -func (m *PaymentMethod) AsWallet() (*WalletPaymentData, error) { - if m.Type != PaymentTypeWallet { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not wallet", m.Type), "type") - } - - var d WalletPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/walletprovisioning.go b/backend/pkg/model/walletprovisioning.go deleted file mode 100644 index 616b1bd..0000000 --- a/backend/pkg/model/walletprovisioning.go +++ /dev/null @@ -1,105 +0,0 @@ -package model - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -type WalletProvisioningState string - -const ( - WalletProvisioningStateProvisioning WalletProvisioningState = "provisioning" - WalletProvisioningStateCompleted WalletProvisioningState = "completed" - WalletProvisioningStateFailed WalletProvisioningState = "failed" -) - -type WalletProvisioningTargetState string - -const ( - WalletProvisioningTargetStatePending WalletProvisioningTargetState = "pending" - WalletProvisioningTargetStateWalletReady WalletProvisioningTargetState = "wallet_ready" - WalletProvisioningTargetStateSuspended WalletProvisioningTargetState = "suspended" - WalletProvisioningTargetStateRouted WalletProvisioningTargetState = "routed" -) - -type WalletProvisioningTarget struct { - TargetID string `bson:"targetId" json:"targetId"` - Chain ChainNetwork `bson:"chain" json:"chain"` - Currency string `bson:"currency" json:"currency"` - ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` - GatewayURI string `bson:"gatewayUri,omitempty" json:"gatewayUri,omitempty"` - WalletIdempotencyKey string `bson:"walletIdempotencyKey,omitempty" json:"walletIdempotencyKey,omitempty"` - State WalletProvisioningTargetState `bson:"state" json:"state"` - WalletRef string `bson:"walletRef,omitempty" json:"walletRef,omitempty"` - Address string `bson:"address,omitempty" json:"address,omitempty"` - RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` -} - -func (t *WalletProvisioningTarget) Normalize() { - if t == nil { - return - } - t.TargetID = strings.TrimSpace(t.TargetID) - t.Currency = strings.ToUpper(strings.TrimSpace(t.Currency)) - t.ContractAddress = strings.TrimSpace(t.ContractAddress) - if strings.HasPrefix(strings.ToLower(t.ContractAddress), "0x") { - t.ContractAddress = strings.ToLower(t.ContractAddress) - } - t.GatewayID = strings.TrimSpace(t.GatewayID) - t.GatewayURI = strings.TrimSpace(t.GatewayURI) - t.WalletIdempotencyKey = strings.TrimSpace(t.WalletIdempotencyKey) - t.WalletRef = strings.TrimSpace(t.WalletRef) - t.Address = strings.TrimSpace(t.Address) - t.RouteRef = strings.TrimSpace(t.RouteRef) - t.LastError = strings.TrimSpace(t.LastError) - if t.State == "" { - t.State = WalletProvisioningTargetStatePending - } -} - -type WalletProvisioningRecord struct { - storable.Base `bson:",inline" json:",inline"` - - ProvisioningRef string `bson:"provisioningRef" json:"provisioningRef"` - OrganizationRef string `bson:"organizationRef" json:"organizationRef"` - OwnerRef string `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` - LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"` - ActorRef string `bson:"actorRef,omitempty" json:"actorRef,omitempty"` - Reason string `bson:"reason" json:"reason"` - Currency string `bson:"currency" json:"currency"` - State WalletProvisioningState `bson:"state" json:"state"` - Targets []WalletProvisioningTarget `bson:"targets" json:"targets"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` - // Version fences concurrent journal writers: Save replaces only the - // revision this attempt read, so a stale attempt whose lease was taken - // over cannot roll back the checkpoints of the attempt that owns it now. - Version int64 `bson:"version" json:"-"` -} - -func (*WalletProvisioningRecord) Collection() string { - return mservice.WalletProvisioning -} - -func (r *WalletProvisioningRecord) Normalize() { - if r == nil { - return - } - r.ProvisioningRef = strings.TrimSpace(r.ProvisioningRef) - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.OwnerRef = strings.TrimSpace(r.OwnerRef) - r.LedgerAccountRef = strings.TrimSpace(r.LedgerAccountRef) - r.ActorRef = strings.TrimSpace(r.ActorRef) - r.Reason = strings.TrimSpace(r.Reason) - r.Currency = strings.ToUpper(strings.TrimSpace(r.Currency)) - r.LastError = strings.TrimSpace(r.LastError) - for index := range r.Targets { - r.Targets[index].Normalize() - } - if r.State == "" { - r.State = WalletProvisioningStateProvisioning - } -} diff --git a/backend/pkg/moneybase/fx_rounding.go b/backend/pkg/moneybase/fx_rounding.go deleted file mode 100644 index ec98363..0000000 --- a/backend/pkg/moneybase/fx_rounding.go +++ /dev/null @@ -1,50 +0,0 @@ -package moneybase - -import ( - "math/big" - "strings" - - decimalbase "git.sendico.io/tech/sxchnge/bff/pkg/decimal" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// SourceRoundedConversionMatches reports whether sourceAmount is the minimum -// source-scale amount that covers targetAmount at rate. -func SourceRoundedConversionMatches(sourceAmount, targetAmount, rate string, sourceScale uint32) (bool, error) { - if err := ValidateScale("source scale", sourceScale); err != nil { - return false, err - } - source, err := positiveRat(sourceAmount, "source amount") - if err != nil { - return false, err - } - target, err := positiveRat(targetAmount, "target amount") - if err != nil { - return false, err - } - rateRat, err := positiveRat(rate, "conversion rate") - if err != nil { - return false, err - } - exactSource := new(big.Rat).Quo(target, rateRat) - roundedSource := ceilDecimalToScale(exactSource, sourceScale) - return roundedSource.Cmp(source) == 0 && source.Cmp(exactSource) >= 0, nil -} - -func positiveRat(value string, field string) (*big.Rat, error) { - parsed, err := decimalbase.RatFromString(strings.TrimSpace(value)) - if err != nil || parsed.Sign() <= 0 { - return nil, merrors.InvalidArgument(field + " must be a positive decimal") - } - return parsed, nil -} - -func ceilDecimalToScale(value *big.Rat, scale uint32) *big.Rat { - factor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - scaled := new(big.Rat).Mul(value, new(big.Rat).SetInt(factor)) - q, r := new(big.Int).QuoRem(scaled.Num(), scaled.Denom(), new(big.Int)) - if r.Sign() > 0 { - q.Add(q, big.NewInt(1)) - } - return new(big.Rat).SetFrac(q, factor) -} diff --git a/backend/pkg/moneybase/fx_rounding_test.go b/backend/pkg/moneybase/fx_rounding_test.go deleted file mode 100644 index 424051f..0000000 --- a/backend/pkg/moneybase/fx_rounding_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package moneybase - -import ( - "testing" -) - -func TestSourceRoundedConversionMatches(t *testing.T) { - ok, err := SourceRoundedConversionMatches("0.06", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if !ok { - t.Fatal("expected rounded source conversion to match") - } - ok, err = SourceRoundedConversionMatches("0.07", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if ok { - t.Fatal("expected oversized source amount to fail") - } - ok, err = SourceRoundedConversionMatches("0.05", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if ok { - t.Fatal("expected undersized source amount to fail") - } -} diff --git a/backend/pkg/moneybase/scale.go b/backend/pkg/moneybase/scale.go deleted file mode 100644 index 8e33fb2..0000000 --- a/backend/pkg/moneybase/scale.go +++ /dev/null @@ -1,12 +0,0 @@ -package moneybase - -import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - -const MaxScale uint32 = 36 - -func ValidateScale(field string, scale uint32) error { - if scale > MaxScale { - return merrors.InvalidArgumentf("%s exceeds supported money scale %d", field, MaxScale) - } - return nil -} diff --git a/backend/pkg/mservice/services.go b/backend/pkg/mservice/services.go index c279715..6a49644 100644 --- a/backend/pkg/mservice/services.go +++ b/backend/pkg/mservice/services.go @@ -1,108 +1,11 @@ package mservice -import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - type Type = string const ( - Accounts Type = "accounts" // Represents user accounts in the system - Verification Type = "verification" // Represents verification code flows - Amplitude Type = "amplitude" // Represents analytics integration with Amplitude - Discovery Type = "discovery" // Represents service discovery registry - Site Type = "site" // Represents public site endpoints - Changes Type = "changes" // Tracks changes made to resources - Clients Type = "clients" // Represents client information - ChainGateway Type = "chain_gateway" // Represents chain gateway microservice - MntxGateway Type = "mcards_gateway" // Represents Monetix gateway microservice - AuroraGateway Type = "aurora_gateway" // Represents Aurora card gateway microservice - AsterBankGateway Type = "asterbank_gateway" // Represents AsterBank local/dev bank payout emulator - MBankGateway Type = "mbank_gateway" // Represents mBank real-money bank payout gateway - PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice - FXOracle Type = "fx_oracle" // Represents FX oracle microservice - FXIngestor Type = "fx_ingestor" // Represents FX ingestor microservice - Treasury Type = "treasury" // Represents Treasury microservice - OrgMoneyFlowStats Type = "org_money_flow_stats" // Represents organization money flow statistics - FeePlans Type = "fee_plans" // Represents fee plans microservice - BillingDocuments Type = "billing_documents" // Represents billing documents microservice - BillingFees Type = "billing_fees" // Represents billing fees microservice - AMLSources Type = "aml_sources" // Represents Backoffice AML data-source management - CardBlacklist Type = "card_blacklist" // Represents backoffice card-blacklist management - ComplianceAML Type = "compliance_aml" // Represents AML screening microservice - ComplianceKYC Type = "compliance_kyc" // Represents KYC verification microservice - CompliancePolicy Type = "compliance_policy" // Represents policy enforcement microservice - ComplianceAssessment Type = "compliance_assessment" // Represents durable compliance assessment coordination - CardBlacklistIngestor Type = "card_blacklist_ingestor" // Represents automated card-blacklist ingestion - Limits Type = "limits" // Represents limit reservation and usage enforcement microservice - FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources - Invitations Type = "invitations" // Represents invitations sent to users - Invoices Type = "invoices" // Represents invoices - Logo Type = "logo" // Represents logos for organizations or projects - Ledger Type = "ledger" // Represents ledger microservice - LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice - // LedgerBalances and LedgerEntries gate the resources derived from an - // organization-owned ledger account. The edge Ledger API authorizes them in - // two steps: the account must be visible to the caller (see edge/product - // internal/ledgervisibility), and reading its balance or journal entries - // then requires the matching permission below. Owners of a personal account - // are exempt from the second step. - LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice - LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice - LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice - LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice - LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice - PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice - PaymentQuotation Type = "payment_quotation" // Represents payment quotation microservice - InboundDepositProcessor Type = "crypto_deposits" // Represents inbound crypto deposit processing microservice - ReceivingRoutes Type = "receiving_routes" // Represents inbound receiving route resolution - ChainAssets Type = "chain_assets" // Represents managed chain assets - ChainWallets Type = "chain_wallets" // Represents managed chain wallets - ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances - ChainTransfers Type = "chain_transfers" // Represents chain transfers - ChainDeposits Type = "chain_deposits" // Represents chain deposits - Callbacks Type = "callbacks" // Represents webhook callback subscriptions - ChSettle Type = "chsettle_gateway" // Represents chimera settlement gateway - Notifications Type = "notifications" // Represents notifications sent to users - Organizations Type = "organizations" // Represents organizations in the system - Payments Type = "payments" // Represents payments service - Transactions Type = "transactions" // Represents transaction reporting and investigations - PaymentRoutes Type = "payment_routes" // Represents payment routing definitions - PaymentMethods Type = "payment_methods" // Represents payment methods service - Permissions Type = "permissions" // Represents permissiosns service - Policies Type = "policies" // Represents access control policies - PolicyAssignements Type = "policy_assignments" // Represents policy assignments database - Recipients Type = "recipients" // Represents payment recipients - OrgRecipients Type = "org_recipients" // Represents organization-scoped shared recipients (policy resource type) - RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication - Roles Type = "roles" // Represents roles in access control - SignupProvisioning Type = "signup_provisioning" // Represents in-flight signup provisioning records - Storage Type = "storage" // Represents statuses of tasks or projects - TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway - TronGateway Type = "tron_gateway" // Represents tron gateway microservice - Tenants Type = "tenants" // Represents tenants managed in the system - VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system - Wallets Type = "wallets" // Represents workflows for tasks or projects - WalletProvisioning Type = "wallet_provisioning" // Represents durable wallet and receiving-route provisioning state - WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing - RotatedWallets Type = "rotated_wallets" // Represents backoffice rotated-wallet review and disposition access - Workflows Type = "workflows" // Represents workflows for tasks or projects + Accounts Type = "accounts" // Represents user accounts in the system + Site Type = "site" // Represents public site endpoints + Clients Type = "clients" // Represents client information + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system ) - -const ( - DocumentsReport Type = "documents_report" // Represents employee closing reports - DocumentsPayout Type = "documents_payout" // Represents report-bound RUB payout rows - DocumentsPayoutAttempt Type = "documents_payout_attempt" // Represents immutable payout execution attempts - DocumentsPayoutBatch Type = "documents_payout_batch" // Represents imported payout batches -) - -func StringToSType(s string) (Type, error) { - switch s { - case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletProvisioning, WalletRoutes, ChainWalletBalances, - ChainTransfers, ChainDeposits, Callbacks, MntxGateway, AuroraGateway, AsterBankGateway, MBankGateway, PaymentGateway, FXOracle, FXIngestor, Treasury, OrgMoneyFlowStats, FeePlans, BillingDocuments, DocumentsReport, DocumentsPayout, DocumentsPayoutAttempt, DocumentsPayoutBatch, BillingFees, AMLSources, CardBlacklist, ComplianceAML, ComplianceKYC, CompliancePolicy, ComplianceAssessment, CardBlacklistIngestor, Limits, FilterProjects, Invitations, Invoices, Logo, Ledger, - LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, Transactions, PaymentRoutes, PaymentOrchestrator, InboundDepositProcessor, ReceivingRoutes, PaymentMethods, Permissions, Policies, PolicyAssignements, - Recipients, RefreshTokens, Roles, SignupProvisioning, Storage, Tenants, Workflows, Discovery, ChSettle, TgSettle, TronGateway, RotatedWallets: - return s, nil - default: - return "", merrors.InvalidArgument("invalid service type", s) - } -} diff --git a/backend/pkg/mservice/services_test.go b/backend/pkg/mservice/services_test.go deleted file mode 100644 index 546cbf5..0000000 --- a/backend/pkg/mservice/services_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package mservice - -import "testing" - -func TestAMLSourcesResourceTypeIsRecognized(t *testing.T) { - got, err := StringToSType(AMLSources) - if err != nil { - t.Fatalf("AMLSources resource type was rejected: %v", err) - } - if got != AMLSources { - t.Fatalf("AMLSources resource type mismatch: got=%q want=%q", got, AMLSources) - } -} - -func TestCardBlacklistIngestorServiceTypeIsRecognized(t *testing.T) { - got, err := StringToSType(CardBlacklistIngestor) - if err != nil { - t.Fatalf("card blacklist ingestor service type was rejected: %v", err) - } - if got != CardBlacklistIngestor { - t.Fatalf("card blacklist ingestor service type mismatch: got=%q want=%q", got, CardBlacklistIngestor) - } -} - -func TestDocumentsCRUDResourceTypesAreRecognized(t *testing.T) { - for _, resource := range []Type{ - DocumentsReport, - DocumentsPayout, - DocumentsPayoutAttempt, - DocumentsPayoutBatch, - } { - got, err := StringToSType(resource) - if err != nil { - t.Fatalf("documents resource type %q was rejected: %v", resource, err) - } - if got != resource { - t.Fatalf("documents resource type mismatch: got=%q want=%q", got, resource) - } - } -} diff --git a/backend/pkg/mutil/db/auth/accountbound.go b/backend/pkg/mutil/db/auth/accountbound.go deleted file mode 100644 index c550623..0000000 --- a/backend/pkg/mutil/db/auth/accountbound.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package mutil provides utility functions for working with account-bound objects -// with permission enforcement. -// -// Example usage: -// -// // Using the low-level repository approach -// objects, err := mutil.GetAccountBoundObjects[model.ProjectFilter]( -// ctx, logger, accountRef, orgRef, model.ActionRead, -// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, -// enforcer, repo) -// -// // Using the AccountBoundDB interface approach -// objects, err := mutil.GetAccountBoundObjectsFromDB[model.ProjectFilter]( -// ctx, logger, accountRef, orgRef, -// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, -// accountBoundDB) -package mutil - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// GetAccountBoundObjects retrieves account-bound objects with permission enforcement. -// This function handles the complex logic of: -// 1. Finding objects where accountRef matches OR is null/absent -// 2. Checking organization-level permissions for each object -// 3. Filtering to only objects the account has permission to read -func GetAccountBoundObjects[T any]( - ctx context.Context, - logger mlogger.Logger, - accountRef, organizationRef bson.ObjectID, - filter builder.Query, - cursor *model.ViewCursor, - enforcer auth.Enforcer, - repo repository.Repository, -) ([]T, error) { - // Build query to find objects where accountRef matches OR is null/absent - accountQuery := repository.WithOrg(accountRef, organizationRef) - - // Get all account-bound objects that match the criteria - allObjects, err := repo.ListAccountBound(ctx, repository.ApplyCursor(accountQuery, cursor)) - if err != nil { - if !errors.Is(err, merrors.ErrNoData) { - logger.Warn("Failed to fetch account bound objects", zap.Error(err), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - ) - } else { - logger.Debug("No matching account bound objects found", zap.Error(err), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - ) - } - return nil, err - } - - if len(allObjects) == 0 { - return nil, merrors.NoData("no_account_bound_objects_found") - } - - allowed := make([]bson.ObjectID, 0, len(allObjects)) - for _, ref := range allObjects { - allowed = append(allowed, *ref.GetID()) - } - if len(allowed) == 0 { - return nil, merrors.NoData("no_data_found_or_allowed") - } - - logger.Debug("Successfully retrieved account bound objects", - zap.Int("total_count", len(allObjects)), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - zap.Any("objs", allObjects), - ) - - return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) -} diff --git a/backend/pkg/mutil/db/auth/protected.go b/backend/pkg/mutil/db/auth/protected.go deleted file mode 100644 index 352ef9c..0000000 --- a/backend/pkg/mutil/db/auth/protected.go +++ /dev/null @@ -1,58 +0,0 @@ -package mutil - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func GetProtectedObjects[T any]( - ctx context.Context, - logger mlogger.Logger, - accountRef, organizationRef bson.ObjectID, - action model.Action, - filter builder.Query, - cursor *model.ViewCursor, - enforcer auth.Enforcer, - repo repository.Repository, -) ([]T, error) { - refs, err := repo.ListPermissionBound(ctx, repository.ApplyCursor(filter, cursor)) - if err != nil { - if !errors.Is(err, merrors.ErrNoData) { - logger.Warn("Failed to fetch object IDs", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - } else { - logger.Debug("No matching IDs found", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - } - return nil, err - } - res, err := enforcer.EnforceBatch(ctx, refs, accountRef, action) - if err != nil { - logger.Warn("Failed to enforce object IDs", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - return nil, err - } - - allowed := make([]bson.ObjectID, 0, len(res)) - for _, ref := range refs { - if ok := res[*ref.GetID()]; ok { - allowed = append(allowed, *ref.GetID()) - } - } - if len(allowed) == 0 { - return nil, merrors.NoData("no_data_found_or_allowed") - } - - return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) -} diff --git a/backend/pkg/mutil/mzap/object.go b/backend/pkg/mutil/mzap/object.go index e0fdd5c..01b71fd 100644 --- a/backend/pkg/mutil/mzap/object.go +++ b/backend/pkg/mutil/mzap/object.go @@ -2,7 +2,6 @@ package mzap import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -14,15 +13,3 @@ func ObjRef(name string, objRef bson.ObjectID) zap.Field { func StorableRef(obj storable.Storable) zap.Field { return ObjRef(obj.Collection()+"_ref", *obj.GetID()) } - -func OrgRef(orgRef bson.ObjectID) zap.Field { - return ObjRef("organization_ref", orgRef) -} - -func OrganizationRef(obj model.OrganizationBound) zap.Field { - return OrgRef(obj.GetOrganizationRef()) -} - -func PermissionRef(permissionRef bson.ObjectID) zap.Field { - return ObjRef("permission_ref", permissionRef) -} diff --git a/backend/pkg/person/name.go b/backend/pkg/person/name.go deleted file mode 100644 index 862fbd7..0000000 --- a/backend/pkg/person/name.go +++ /dev/null @@ -1,38 +0,0 @@ -package person - -import "strings" - -// Name represents a person's first and last name as a reusable value object. -type Name struct { - FirstName string `bson:"firstName,omitempty" json:"first_name,omitempty"` - LastName string `bson:"lastName,omitempty" json:"last_name,omitempty"` -} - -func (n Name) Trim() Name { - return Name{ - FirstName: strings.TrimSpace(n.FirstName), - LastName: strings.TrimSpace(n.LastName), - } -} - -func (n Name) WithFallback(fallback Name) Name { - n = n.Trim() - fallback = fallback.Trim() - if n.FirstName == "" { - n.FirstName = fallback.FirstName - } - if n.LastName == "" { - n.LastName = fallback.LastName - } - return n -} - -func (n Name) FullName() string { - n = n.Trim() - return strings.TrimSpace(n.FirstName + " " + n.LastName) -} - -func (n Name) IsZero() bool { - n = n.Trim() - return n.FirstName == "" && n.LastName == "" -} diff --git a/backend/pkg/person/name_test.go b/backend/pkg/person/name_test.go deleted file mode 100644 index 828b4f7..0000000 --- a/backend/pkg/person/name_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package person - -import "testing" - -func TestNameWithFallbackUsesFallbackPerField(t *testing.T) { - name := Name{FirstName: " Ivan "} - fallback := Name{FirstName: "FallbackFirst", LastName: " Ivanov "} - - got := name.WithFallback(fallback) - want := Name{FirstName: "Ivan", LastName: "Ivanov"} - if got != want { - t.Fatalf("name mismatch: got=%+v want=%+v", got, want) - } -} diff --git a/backend/scripts/seed-policies.js b/backend/scripts/seed-policies.js deleted file mode 100644 index 16bc9f0..0000000 --- a/backend/scripts/seed-policies.js +++ /dev/null @@ -1,88 +0,0 @@ -// Adapted from Sendico's canonical Product policy seed; see PROVENANCE.md. -// Run only against the SXCHNGE-owned database. This seeds descriptions only: -// no users, organization, role grants or provider resources are created. -const databaseName = process.env.SXCHNGE_MONGO_DATABASE || 'sxchnge'; -const targetDb = db.getSiblingDB(databaseName); -const now = new Date(); - -const canonicalPolicies = [ - { - _id: ObjectId('677a61569cf14638b34423e0'), - name: 'Organization Settings', - description: 'Access to organization settings', - resourceTypes: ['organizations'], - }, - { - _id: ObjectId('67bb6ec0da91191ffeb47012'), - name: 'Accounts', - description: 'Access to accounts settings', - resourceTypes: ['accounts'], - }, - { - _id: ObjectId('67b3d7bd452192e77a26b22d'), - name: 'Roles', - description: 'Access to roles management', - resourceTypes: ['roles', 'policies'], - }, -]; - -upsertCanonicalPolicies(); - -function builtInFilter(extra) { - return Object.assign( - { - $or: [ - { organizationRef: { $exists: false } }, - { organizationRef: null }, - ], - }, - extra, - ); -} - -function upsertCanonicalPolicies() { - const result = []; - for (const entry of canonicalPolicies) { - let existing = targetDb.policies.findOne({ _id: entry._id }); - if (!existing) { - // Migration-created policies carry environment-specific ids. - existing = targetDb.policies.findOne( - builtInFilter({ resourceTypes: { $all: entry.resourceTypes } }), - ); - } - if (existing) { - const patch = {}; - if (existing.name !== entry.name) patch.name = entry.name; - if (existing.description !== entry.description) { - patch.description = entry.description; - } - if ( - JSON.stringify(existing.resourceTypes || []) !== - JSON.stringify(entry.resourceTypes) - ) { - patch.resourceTypes = entry.resourceTypes; - } - if (Object.keys(patch).length > 0) { - patch.updatedAt = now; - targetDb.policies.updateOne({ _id: existing._id }, { $set: patch }); - print(`Patched built-in policy "${entry.name}" (${existing._id.toHexString()})`); - } else { - print(`Built-in policy "${entry.name}" is up to date`); - } - result.push({ entry, id: existing._id, inserted: false }); - continue; - } - - targetDb.policies.insertOne({ - _id: entry._id, - createdAt: now, - updatedAt: now, - name: entry.name, - description: entry.description, - resourceTypes: entry.resourceTypes, - }); - print(`Seeded built-in policy "${entry.name}" (${entry._id.toHexString()})`); - result.push({ entry, id: entry._id, inserted: true }); - } - return result; -} diff --git a/docs/architecture/bff.md b/docs/architecture/bff.md index 9151fa9..a4dcd73 100644 --- a/docs/architecture/bff.md +++ b/docs/architecture/bff.md @@ -18,8 +18,8 @@ The source checkout's unrelated dependency edits were not copied. | Local component | Reused implementation | | --- | --- | | `backend/edge/shared` | Chi HTTP runtime, middleware, JWT login/pending sessions, refresh/rotation/logout, CSRF/cookies, rate limiting, Mongo verification and refresh-token stores | -| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, creation, local membership/roles, activation tokens, password reset and email-change token issuance, deletion | -| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, authorization, errors, structured logging, process bootstrap and health | +| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, atomic customer creation, activation tokens, password reset and email-change token issuance, deletion | +| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, errors, structured logging, process bootstrap and health | | `backend/internal/profile` | Small SXCHNGE composition layer selecting the copied local account service | | `backend/contracts/sendico` | Separate snapshot of Sendico's public OpenAPI contract and its referenced schemas | @@ -29,10 +29,12 @@ private Sendico module requirement, gRPC client, discovery subscription or broke connection. Platform engines and Product's platform-provisioning bootstrap were not copied. The original handwritten shell and tiny replacement helpers were removed. -The existing local account service retains local organization membership and role -semantics. Use one SXCHNGE local tenant when wiring registration; this does not -create an organization in Sendico for each customer. Local database collections, -JWT keys and registered application clients belong exclusively to SXCHNGE. +Local customers have no organization membership, roles, policies or tenant ID. +The account service validates and hashes passwords, creates an account and +activation token atomically, and deletes an account with session revocation. +Customer responses map to explicit local DTOs. Account, token and session collections and application client records belong only +to SXCHNGE. The runtime starts without any policy or tenant seed. Casbin, +invitations, financial models and provisioning machinery are excluded. ## Identity and ownership @@ -98,8 +100,9 @@ Sendico task. No private transport fallback is allowed. ## Next wiring sequence 1. Wire local signup, verification delivery/completion and profile endpoints onto - the copied account and verification services. Create SXCHNGE-owned tenant, - client and role records; keep signup independent of Sendico availability. + the copied account and verification services. Configure the SXCHNGE application + client for web/native sessions; keep signup independent of Sendico availability. + Never add local organization, membership, role or policy requirements. 2. Implement the public HTTP adapter and confirm organization/resource scoping with the integration principal. Agree on external-customer contracts where the existing public API assumes a Sendico account. @@ -120,6 +123,6 @@ operation details explain what has happened and what remains pending. This task supplies the copied starting point and isolated public contracts. The local runtime installs the existing authentication transport and account service. Local signup/profile HTTP endpoints, verification delivery/completion, -initial tenant/client provisioning, Flutter HTTP adapters and live Sendico calls +application client setup, Flutter HTTP adapters and live Sendico calls are not connected yet. The frontend continues using its existing dummy services. No new provider organization, customer or payment has been created. diff --git a/docs/implementation.md b/docs/implementation.md index 548f76f..031efe5 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -363,44 +363,56 @@ Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`; committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend label, tech owner and SXCHNGE project. No stacked dependencies. -## Copied BFF foundation and public Sendico contracts — task #34 +## Minimal customer BFF and public Sendico contracts — task #34 -Bootstrapped SXCHNGE from Sendico revision -`8bb20dcd622dd8eb92d289b6b34411e7eccab083`: shared HTTP runtime, authentication -and session stack, verification and refresh storage, account service, Mongo -repositories, authorization, canonical models/errors and structured logging. -The original handwritten shell and replacement helpers were removed. File-level -[provenance](../backend/PROVENANCE.json) records 416 copied Go files and 112 public -OpenAPI files. The source checkout and its unrelated dependency edits were untouched. +[Task #34](https://git.sendico.io/tech/sxchnge/issues/34), branch `bff-34`, +[PR #35](https://git.sendico.io/tech/sxchnge/pulls/35) into `main`. -The corrected [architecture](architecture/bff.md) reflects the product boundary: -SXCHNGE owns customers/authentication; SXCHNGE itself is one Sendico organization; -all Sendico access uses the public HTTP API. Internal gRPC, discovery, messaging, -platform service factories and organization provisioning were excluded. Public -OpenAPI is isolated from local models and tested for self-contained references. -No live provider call, organization, customer or payment was created. +The initial copy at `7062672` incorrectly retained Sendico's local tenancy model. +The correction removes organization membership, roles, Casbin, policy storage and +seeding, invitations and their models/repositories/tests. It also removes unused +financial models/errors, ledger/wallet provisioning metadata, platform service +names, S3 configuration and WebSocket scaffolding. No default tenant or bypassing +authorizer replaces them. -Restored full backend guidelines and the backend-review skill, correcting scoped -paths. Copied error constructors follow `merrors`; local adaptations also validate -startup settings, preserve listen failures/nonzero exit, close Mongo after failed -runtime creation and avoid logging database settings. Architecture checks enforce -the project error model and public-only provider boundary. +Retained Sendico source is pinned to `8bb20dcd622dd8eb92d289b6b34411e7eccab083`: +HTTP runtime, local customer/password service and Mongo repository, customer +sessions and verification storage, generic repository/transaction code, errors, +logging and health. [Provenance](../backend/PROVENANCE.json) covers 232 retained +Go files and 112 isolated public OpenAPI files; every adaptation is flagged. +The Sendico checkout remains unchanged. -The copied runtime installs local authentication transport and initializes the -account service. Signup/profile endpoints, verification delivery/completion, -initial local tenant/client provisioning, Flutter HTTP adapters and live Sendico -calls remain future wiring. Public external-customer compliance/ownership gaps -are documented; internal APIs are not an allowed workaround. +Customer creation accepts a raw password, validates/hashes it and commits the +customer and activation token together. Token transactions join an active outer +customer transaction. Deletion revokes only the selected customer's sessions and +deletes its account atomically, including when it is the final local customer. +Customer lifecycle and login use the same configured auth collections. Pending, +active and blocked customer state and session/client binding remain local. -Validation: `make test` (race-enabled backend suite with real MongoDB containers, -plus all frontend package/app tests), `make lint-backend` (architecture, gofmt, -vet), `go build` and `git diff --check` passed. Local smoke used a disposable -SXCHNGE Mongo replica set: health 200, invalid login 400, SIGTERM exit 0 and missing -configuration exit 1. Earlier extraction failures were fixed: unused import, -wrong example-config test path, an out-of-scope recipient migration test -copied without its uncopied migration, and missing built-in policy records on -fresh startup. The copied policy seed was run twice and retained three records. No security scan was requested or run. +The sole Sendico organization reference belongs to the future public HTTP adapter +configuration. [Architecture and capabilities](architecture/bff.md) document +server-side customer/resource ownership and the public external-customer contract +gaps. No internal service, shared database or provider resource creation is used. +Signup/profile HTTP routes, verification delivery/completion, application client +setup and live Sendico integration remain subsequent wiring work. -Delivery: [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), branch `bff-34` -into `main`; task #34 retains backend label, tech owner and SXCHNGE project. -This entry supersedes the earlier new-shell/delegated-provisioning proposal. +Backend completion review covered the retained copied runtime and the full +extraction diff. Inactive-customer checks and refresh client/account binding were +added after review; explicit response DTOs keep storage fields out of HTTP output. + +Validation passed: `make test` (race-enabled backend suite, including fresh Mongo +replica-set customer lifecycle/runtime tests, plus all frontend suites), +`make lint-backend` (architecture, formatting and vet), and +`go test -tags integration -run '^$' ./...` from `backend` (compilation of the +additional tagged suites; this command does not execute those tests). +Provenance/source blobs, public contract references and `git diff --check` passed. +Earlier extraction compile errors were corrected. The initial fresh-database test +attempt used an unavailable image tag and then hit Docker Desktop replica-set +address discovery; it now uses `mongo:8` and a direct connection to the disposable +single-node replica set. No security scan was requested or run. + +PR #35 remains unmerged. There is no deployed-data migration: this bootstrap has +not been deployed. Existing experimental databases may retain unused tenancy +collections; this change does not delete external database records. Customer HTTP +responses now expose only ID, name, last name, login, locale and optional avatar. +The frontend still uses dummy services and is unchanged. -- 2.54.0 From f0720ec66bd296ade2d74a9a6749281cf460d479 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 14:14:08 +0200 Subject: [PATCH 05/11] Organize backend into product executable and shared Go modules --- Makefile | 10 +- backend/AGENTS.md | 17 +- backend/PROVENANCE.json | 1147 +++++------------ backend/PROVENANCE.md | 27 +- backend/README.md | 66 +- backend/cmd/bff/main.go | 21 - backend/contracts/sendico/api.yaml | 129 -- .../sendico/api/accounts/auth_login.yaml | 29 - .../sendico/api/accounts/auth_login_api.yaml | 30 - .../sendico/api/accounts/auth_refresh.yaml | 31 - .../sendico/api/accounts/auth_rotate.yaml | 31 - .../sendico/api/accounts/bodies/auth.yaml | 24 - .../sendico/api/accounts/request/auth.yaml | 34 - .../sendico/api/accounts/response/auth.yaml | 62 - .../sendico/api/callbacks/archive.yaml | 37 - .../api/callbacks/bodies/callback.yaml | 17 - .../sendico/api/callbacks/create.yaml | 33 - .../contracts/sendico/api/callbacks/list.yaml | 35 - .../sendico/api/callbacks/object.yaml | 64 - .../api/callbacks/request/callback.yaml | 113 -- .../api/callbacks/response/callback.yaml | 22 - .../sendico/api/callbacks/rotate_secret.yaml | 31 - .../sendico/api/callbacks/update.yaml | 31 - .../sendico/api/ledger_accounts/balance.yaml | 58 - .../api/ledger_accounts/operation.yaml | 36 - .../api/ledger_accounts/operations.yaml | 36 - .../api/ledger_accounts/response/balance.yaml | 15 - .../ledger_accounts/response/operation.yaml | 33 - .../sendico/api/organizations/get.yaml | 29 - .../organizations/response/organization.yaml | 18 - .../sendico/api/organizations/root.yaml | 27 - .../sendico/api/parameters/accounts_ref.yaml | 9 - .../sendico/api/parameters/archived.yaml | 9 - .../sendico/api/parameters/callbacks_ref.yaml | 9 - .../sendico/api/parameters/cascade.yaml | 9 - .../sendico/api/parameters/cursor.yaml | 9 - .../sendico/api/parameters/entry_ref.yaml | 9 - .../sendico/api/parameters/filter_states.yaml | 11 - .../api/parameters/ledger_account_ref.yaml | 9 - .../sendico/api/parameters/limit.yaml | 11 - .../sendico/api/parameters/offset.yaml | 11 - .../sendico/api/parameters/org_ref.yaml | 9 - .../api/parameters/organizations_ref.yaml | 9 - .../api/parameters/payment_methods_ref.yaml | 9 - .../sendico/api/parameters/payments_ref.yaml | 10 - .../api/parameters/recipients_ref.yaml | 9 - .../sendico/api/parameters/state.yaml | 11 - .../sendico/api/parameters/states.yaml | 10 - .../sendico/api/payment_methods/accounts.yaml | 74 -- .../sendico/api/payment_methods/archive.yaml | 36 - .../bodies/payment_method.yaml | 17 - .../sendico/api/payment_methods/object.yaml | 102 -- .../api/payment_methods/recipients.yaml | 74 -- .../request/payment_method.yaml | 42 - .../response/payment_method.yaml | 18 - .../sendico/api/payments/bodies/payment.yaml | 33 - .../sendico/api/payments/by_multiquote.yaml | 37 - .../sendico/api/payments/by_quote.yaml | 41 - .../api/payments/documents_certificate.yaml | 47 - .../sendico/api/payments/fx_currencies.yaml | 40 - .../sendico/api/payments/fx_directions.yaml | 48 - .../sendico/api/payments/immediate.yaml | 48 - .../contracts/sendico/api/payments/list.yaml | 76 -- .../sendico/api/payments/multiquote.yaml | 37 - .../sendico/api/payments/object.yaml | 31 - .../contracts/sendico/api/payments/quote.yaml | 37 - .../sendico/api/payments/registry.yaml | 30 - .../api/payments/registry_refresh.yaml | 32 - .../sendico/api/payments/request/payment.yaml | 163 --- .../api/payments/response/payment.yaml | 114 -- .../sendico/api/recipients/archive.yaml | 37 - .../api/recipients/bodies/recipient.yaml | 17 - .../sendico/api/recipients/create.yaml | 33 - .../sendico/api/recipients/list.yaml | 36 - .../sendico/api/recipients/object.yaml | 64 - .../api/recipients/request/recipient.yaml | 53 - .../api/recipients/response/recipient.yaml | 18 - .../sendico/api/recipients/update.yaml | 31 - .../contracts/sendico/api/response/error.yaml | 24 - .../sendico/api/response/operation.yaml | 85 -- .../sendico/api/response/response.yaml | 47 - .../sendico/api/response/status.yaml | 10 - .../api/verification/bodies/verification.yaml | 17 - .../sendico/api/verification/create.yaml | 37 - .../verification/request/verification.yaml | 73 -- .../sendico/api/verification/resend.yaml | 37 - .../verification/response/verification.yaml | 35 - .../sendico/api/verification/verify.yaml | 37 - .../contracts/sendico/api/wallets/assets.yaml | 35 - .../sendico/api/wallets/response/wallet.yaml | 18 - .../sendico/external/chain_network.yaml | 10 - .../sendico/external/endpoint_type.yaml | 15 - .../contracts/sendico/external/fx_side.yaml | 7 - .../sendico/external/payment_kind.yaml | 8 - .../sendico/external/payment_method_type.yaml | 12 - .../sendico/external/payment_state.yaml | 11 - .../sendico/external/settlement_mode.yaml | 8 - .../sendico/models/account/account.yaml | 15 - .../models/account/account_public.yaml | 33 - .../models/auth/client_refresh_token.yaml | 15 - .../sendico/models/auth/login_data.yaml | 21 - .../models/auth/session_identifier.yaml | 16 - .../sendico/models/auth/token_data.yaml | 17 - .../sendico/models/callback/callback.yaml | 152 --- .../sendico/models/common/describable.yaml | 16 - .../sendico/models/common/money.yaml | 18 - .../sendico/models/common/pagination.yaml | 10 - .../sendico/models/ledger/balance.yaml | 53 - .../sendico/models/ledger/operation.yaml | 124 -- .../contracts/sendico/models/objectid.yaml | 9 - .../models/organization/organization.yaml | 39 - .../sendico/models/payment/fx_metadata.yaml | 78 -- .../sendico/models/payment/payment.yaml | 879 ------------- .../models/payment_method/payment_method.yaml | 29 - .../sendico/models/permission_bound.yaml | 23 - .../sendico/models/recipient/recipient.yaml | 30 - .../contracts/sendico/models/storable.yaml | 22 - .../sendico/models/wallet/wallet_asset.yaml | 19 - backend/edge/shared/interface/api/config.go | 15 - .../shared/interface/api/srequest/login.go | 9 - .../shared/interface/api/srequest/refresh.go | 5 - .../shared/interface/api/srequest/rotate.go | 5 - .../interface/api/sresponse/response.go | 14 - backend/edge/shared/server/server.go | 12 - .../storage/refreshtokens/refreshtokens.go | 19 - .../storage/verification/context_ref.go | 22 - .../internal/architecture/contracts_test.go | 60 - backend/pkg/db/account/account.go | 14 - backend/pkg/db/config.go | 13 - .../pkg/db/internal/mongo/accountdb/user.go | 13 - backend/pkg/mlogger/factory/mlogger.go | 10 - backend/pkg/version/factory/factory.go | 10 - backend/product/README.md | 38 + .../interface => product/app}/api/api.go | 8 +- backend/product/app/api/config.go | 16 + .../interface => product/app}/api/register.go | 6 +- .../interface => product/app}/api/runtime.go | 2 +- .../routers => product/app/health}/health.go | 6 +- .../app/health/internal}/health.go | 4 +- .../app/health/internal}/health_test.go | 2 +- .../app/health/internal}/status.go | 6 +- .../app/health/status}/status.go | 0 .../app}/http/middleware.go | 27 +- .../app}/http/middleware_test.go | 4 +- .../router => product/app}/metrics/handler.go | 2 +- .../router => product/app}/metrics/router.go | 2 +- .../server => product/app/process}/factory.go | 2 +- .../app/process}/internal/instance.go | 4 +- .../app/process}/internal/server.go | 8 +- .../main => product/app/process/run}/run.go | 6 +- .../server => product/app/process}/server.go | 0 .../app}/profile/runtime.go | 4 +- .../internal => product/app}/runtime/api.go | 14 +- .../app}/runtime/bootstrap.go | 14 +- .../app}/runtime/bootstrap_test.go | 14 +- .../app}/runtime/lifecycle.go | 2 +- .../app}/runtime/services.go | 8 +- .../app/server/internal}/config_test.go | 6 +- .../app/server/internal}/server.go | 14 +- .../app/server/internal}/server_test.go | 2 +- backend/product/app/server/server.go | 12 + .../app/urls}/domain_provider.go | 4 +- .../app/urls/internal}/domain_provider.go | 2 +- .../app/version}/version.go | 4 +- .../architecture/boundary_test.go | 3 + .../architecture/customer_test.go | 3 +- .../architecture/errors_test.go | 2 +- .../shared => product}/auth/client/client.go | 8 +- .../auth/client/client_test.go | 10 +- .../auth/config}/auth_config.go | 7 +- .../auth/config}/cors_test.go | 0 .../auth/config}/login_rate_limit.go | 2 +- .../auth/config}/login_rate_limit_test.go | 2 +- .../auth/config}/middleware.go | 2 +- .../auth/config}/token_test.go | 0 .../auth/http}/authorized/customer_test.go | 35 +- .../auth/http}/authorized/handler.go | 26 +- .../auth/http}/authorized/router.go | 22 +- .../auth/http}/authorized/session_test.go | 20 +- .../auth/http}/dispatcher.go | 22 +- .../auth/http}/endpoint/endpoint.go | 11 +- .../auth/http}/endpoint/install.go | 6 +- .../auth/http}/endpoint/token.go | 11 +- .../auth/http}/public/login.go | 41 +- .../auth/http}/public/login_rate_limit.go | 16 +- .../http}/public/login_rate_limit_test.go | 22 +- .../auth/http}/public/login_test.go | 31 +- .../http}/public/login_test_support_test.go | 59 +- .../auth/http}/public/logout.go | 10 +- .../auth/http}/public/logout_test.go | 36 +- .../auth/http}/public/refresh.go | 10 +- .../auth/http}/public/refresh_test.go | 17 +- .../auth/http}/public/respond.go | 15 +- .../auth/http}/public/rotate.go | 6 +- .../auth/http}/public/router.go | 20 +- .../auth/http}/public/validate.go | 19 +- .../router => product/auth/http}/router.go | 6 +- .../http => product/auth}/ipguard/ipguard.go | 0 .../auth}/ipguard/ipguard_test.go | 0 .../interface => product/auth}/model/token.go | 19 +- .../auth}/model/token_test.go | 10 +- backend/product/auth/request/login.go | 11 + backend/product/auth/request/refresh.go | 5 + backend/product/auth/request/rotate.go | 5 + .../auth/request}/validateable.go | 0 .../auth/request}/verification.go | 6 +- .../auth/response}/account.go | 10 +- .../auth/response}/authresp.go | 0 .../auth/response}/file.go | 4 +- .../auth/response}/login.go | 10 +- .../auth/response}/login_pending.go | 8 +- .../auth/response}/login_test.go | 10 +- backend/product/auth/response/response.go | 14 + .../auth/response}/result.go | 4 +- .../auth/response}/token.go | 0 .../auth/response}/verification.go | 0 .../shared => product}/auth/storage/stores.go | 27 +- backend/{ => product}/config.example.yml | 0 .../customers}/internal/customer_test.go | 45 +- .../customers}/internal/service.go | 52 +- .../customers}/internal/service_test.go | 49 +- .../customers}/model/account.go | 15 +- .../customers}/model/userdata.go | 8 +- backend/product/customers/store/account.go | 14 + .../customers/store/mongo}/db.go | 14 +- backend/product/customers/store/mongo/user.go | 13 + .../customers}/types.go | 21 +- backend/{ => product}/go.mod | 43 +- backend/{ => product}/go.sum | 0 backend/product/main.go | 21 + .../sessions}/browser/browser.go | 2 +- .../sessions}/browser/browser_test.go | 0 .../sessions/metrics}/metrics.go | 0 .../{pkg => product/sessions}/model/client.go | 4 +- .../sessions}/model/refresh.go | 4 +- .../sessions}/model/sessionid.go | 0 .../sessions/store}/client.go | 7 +- .../sessions/store}/crud.go | 34 +- .../sessions/store}/crud_test.go | 24 +- .../sessions/store}/db.go | 16 +- .../sessions/store}/fields.go | 0 .../sessions/store}/filters.go | 14 +- .../product/sessions/store/refreshtokens.go | 19 + .../store}/refreshtokens_indexes_test.go | 6 +- .../store}/refreshtokens_misc_test.go | 40 +- .../refreshtokens_session_replacement_test.go | 38 +- .../store}/refreshtokens_session_test.go | 29 +- .../sessions/store}/refreshtokens_test.go | 38 +- .../sessions/store}/revoke.go | 4 +- .../sessions}/tokens/prepare_test.go | 13 +- .../sessions}/tokens/tokens.go | 23 +- backend/product/verification/config/config.go | 6 + .../config}/verification_rate_limit.go | 6 +- .../config}/verification_rate_limit_test.go | 10 +- .../verification}/model/verificaton.go | 4 +- .../verification/store}/consume.go | 12 +- .../verification/store}/consume_logging.go | 9 +- .../verification/store}/consume_lookup.go | 24 +- .../verification/store}/consume_scope.go | 14 +- .../verification/store}/consume_update.go | 20 +- .../product/verification/store/context_ref.go | 22 + .../verification/store}/context_ref_test.go | 19 +- .../verification/store}/create.go | 18 +- .../verification/store}/db.go | 18 +- .../verification/store}/errors.go | 2 +- .../verification/store}/hash.go | 11 +- .../verification/store}/hash_test.go | 0 .../verification/store}/idempotency.go | 16 +- .../verification/store}/issuance_limit.go | 2 +- .../store}/issuance_limit_test.go | 8 +- .../store}/memory_token_repository_test.go | 38 +- .../verification/store}/request.go | 12 +- .../store}/token_query_matcher_test.go | 20 +- .../verification/store}/verification.go | 7 +- .../store}/verification_attempts_test.go | 33 +- .../store}/verification_idempotency_test.go | 45 +- .../verification/store}/verification_test.go | 105 +- .../store}/verification_test_helpers_test.go | 14 +- backend/shared/README.md | 12 + backend/{pkg/mutil => shared}/config/param.go | 2 +- backend/shared/database/config.go | 13 + .../{pkg/db => shared/database}/connection.go | 6 +- .../database/dependencies}/dependencies.go | 6 +- .../{pkg/db => shared/database}/factory.go | 14 +- .../database/internal/builder}/accumulator.go | 2 +- .../database/internal/builder}/alias.go | 2 +- .../database/internal/builder}/array.go | 2 +- .../database/internal/builder}/expression.go | 2 +- .../database/internal/builder}/field.go | 2 +- .../database/internal/builder}/func.go | 2 +- .../internal/builder}/gaccumulator.go | 2 +- .../database/internal/builder}/patch.go | 4 +- .../database/internal/builder}/patch_test.go | 4 +- .../database/internal/builder}/pipeline.go | 4 +- .../internal/builder}/pipeline_test.go | 4 +- .../database/internal/builder}/projection.go | 2 +- .../database/internal/builder}/query.go | 4 +- .../database/internal/builder}/query_test.go | 2 +- .../database/internal/builder}/value.go | 2 +- .../database}/internal/mongo/db.go | 26 +- .../database}/internal/mongo/mongo.go | 0 .../database/internal/repository}/index.go | 4 +- .../repository}/index_integration_test.go | 4 +- .../internal/repository}/index_test.go | 2 +- .../internal/repository}/repository.go | 10 +- .../repository_comprehensive_test.go | 8 +- .../internal/repository}/repository_filter.go | 6 +- .../repository}/repository_insertmany_test.go | 4 +- .../repository}/repository_patch_test.go | 8 +- .../internal/repository}/repository_test.go | 10 +- .../repository}/repository_upsert_test.go | 2 +- .../database/internal/transaction}/factory.go | 2 +- .../internal/transaction}/transaction.go | 2 +- .../db => shared/database/query}/archive.go | 8 +- .../db => shared/database/query}/array.go | 10 +- .../mutil/db => shared/database/query}/db.go | 6 +- .../storable => shared/database/record}/id.go | 0 .../database/record}/ref.go | 0 .../database/record}/storable.go | 0 .../repository/builder/accumulator.go | 0 .../database}/repository/builder/alias.go | 0 .../database}/repository/builder/array.go | 0 .../repository/builder/expression.go | 0 .../database}/repository/builder/field.go | 0 .../database}/repository/builder/keyword.go | 0 .../database}/repository/builder/operators.go | 0 .../database}/repository/builder/patch.go | 0 .../database}/repository/builder/pipeline.go | 2 +- .../repository/builder/projection.go | 0 .../database}/repository/builder/query.go | 0 .../database}/repository/builder/unwind.go | 0 .../database}/repository/builder/value.go | 0 .../database}/repository/builders.go | 14 +- .../database}/repository/cursor.go | 6 +- .../database}/repository/decoder/decoder.go | 0 .../database}/repository/index/index.go | 2 +- .../database}/repository/index/types.go | 0 .../database}/repository/repository.go | 12 +- .../database}/template/interface.go | 4 +- .../database}/template/template.go | 14 +- .../database}/transaction/context.go | 0 .../database}/transaction/context_test.go | 0 .../database}/transaction/factory.go | 0 .../database}/transaction/transaction.go | 0 .../mutil => shared}/duration/duration.go | 0 .../merrors => shared/errors}/classified.go | 0 .../{pkg/merrors => shared/errors}/errors.go | 0 .../merrors => shared/errors}/errors_test.go | 0 .../errors}/invalid_argument.go | 0 .../{pkg/merrors => shared/errors}/reason.go | 0 .../merrors => shared/errors}/throttle.go | 0 .../errors}/throttle_test.go | 0 backend/shared/go.mod | 71 + backend/shared/go.sum | 177 +++ backend/{pkg/api => shared}/http/methods.go | 0 .../api => shared}/http/response/response.go | 8 +- .../http/response/response_test.go | 4 +- .../api => shared}/http/response/result.go | 2 +- backend/{pkg/api => shared}/http/status.go | 0 backend/shared/logging/factory/mlogger.go | 10 + .../mzap => shared/logging/fields}/account.go | 7 +- .../mzap => shared/logging/fields}/error.go | 0 .../mzap => shared/logging/fields}/object.go | 2 +- .../logging/internal}/mlogger.go | 2 +- .../{pkg/mlogger => shared/logging}/logger.go | 0 .../mlogger => shared/logging}/logger_test.go | 0 .../mutil => shared/logging}/mask/mask.go | 0 .../logging}/mask/mask_test.go | 0 backend/{pkg => shared}/model/archivable.go | 0 backend/{pkg => shared}/model/describable.go | 0 backend/{pkg => shared}/model/fconfig.go | 0 backend/{pkg => shared}/model/viewcursor.go | 0 .../interface => shared}/ratelimit/config.go | 2 +- .../ratelimit/store}/db.go | 10 +- .../mservice => shared/service}/mservice.go | 0 .../mservice => shared/service}/services.go | 0 backend/shared/version/factory/factory.go | 10 + backend/{pkg => shared}/version/info.go | 0 .../version/internal/version.go | 2 +- backend/{pkg => shared}/version/version.go | 0 docs/architecture/bff.md | 57 +- docs/implementation.md | 32 +- 382 files changed, 1944 insertions(+), 6989 deletions(-) delete mode 100644 backend/cmd/bff/main.go delete mode 100644 backend/contracts/sendico/api.yaml delete mode 100644 backend/contracts/sendico/api/accounts/auth_login.yaml delete mode 100644 backend/contracts/sendico/api/accounts/auth_login_api.yaml delete mode 100644 backend/contracts/sendico/api/accounts/auth_refresh.yaml delete mode 100644 backend/contracts/sendico/api/accounts/auth_rotate.yaml delete mode 100644 backend/contracts/sendico/api/accounts/bodies/auth.yaml delete mode 100644 backend/contracts/sendico/api/accounts/request/auth.yaml delete mode 100644 backend/contracts/sendico/api/accounts/response/auth.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/archive.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/bodies/callback.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/create.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/list.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/object.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/request/callback.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/response/callback.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/rotate_secret.yaml delete mode 100644 backend/contracts/sendico/api/callbacks/update.yaml delete mode 100644 backend/contracts/sendico/api/ledger_accounts/balance.yaml delete mode 100644 backend/contracts/sendico/api/ledger_accounts/operation.yaml delete mode 100644 backend/contracts/sendico/api/ledger_accounts/operations.yaml delete mode 100644 backend/contracts/sendico/api/ledger_accounts/response/balance.yaml delete mode 100644 backend/contracts/sendico/api/ledger_accounts/response/operation.yaml delete mode 100644 backend/contracts/sendico/api/organizations/get.yaml delete mode 100644 backend/contracts/sendico/api/organizations/response/organization.yaml delete mode 100644 backend/contracts/sendico/api/organizations/root.yaml delete mode 100644 backend/contracts/sendico/api/parameters/accounts_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/archived.yaml delete mode 100644 backend/contracts/sendico/api/parameters/callbacks_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/cascade.yaml delete mode 100644 backend/contracts/sendico/api/parameters/cursor.yaml delete mode 100644 backend/contracts/sendico/api/parameters/entry_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/filter_states.yaml delete mode 100644 backend/contracts/sendico/api/parameters/ledger_account_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/limit.yaml delete mode 100644 backend/contracts/sendico/api/parameters/offset.yaml delete mode 100644 backend/contracts/sendico/api/parameters/org_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/organizations_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/payment_methods_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/payments_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/recipients_ref.yaml delete mode 100644 backend/contracts/sendico/api/parameters/state.yaml delete mode 100644 backend/contracts/sendico/api/parameters/states.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/accounts.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/archive.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/object.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/recipients.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/request/payment_method.yaml delete mode 100644 backend/contracts/sendico/api/payment_methods/response/payment_method.yaml delete mode 100644 backend/contracts/sendico/api/payments/bodies/payment.yaml delete mode 100644 backend/contracts/sendico/api/payments/by_multiquote.yaml delete mode 100644 backend/contracts/sendico/api/payments/by_quote.yaml delete mode 100644 backend/contracts/sendico/api/payments/documents_certificate.yaml delete mode 100644 backend/contracts/sendico/api/payments/fx_currencies.yaml delete mode 100644 backend/contracts/sendico/api/payments/fx_directions.yaml delete mode 100644 backend/contracts/sendico/api/payments/immediate.yaml delete mode 100644 backend/contracts/sendico/api/payments/list.yaml delete mode 100644 backend/contracts/sendico/api/payments/multiquote.yaml delete mode 100644 backend/contracts/sendico/api/payments/object.yaml delete mode 100644 backend/contracts/sendico/api/payments/quote.yaml delete mode 100644 backend/contracts/sendico/api/payments/registry.yaml delete mode 100644 backend/contracts/sendico/api/payments/registry_refresh.yaml delete mode 100644 backend/contracts/sendico/api/payments/request/payment.yaml delete mode 100644 backend/contracts/sendico/api/payments/response/payment.yaml delete mode 100644 backend/contracts/sendico/api/recipients/archive.yaml delete mode 100644 backend/contracts/sendico/api/recipients/bodies/recipient.yaml delete mode 100644 backend/contracts/sendico/api/recipients/create.yaml delete mode 100644 backend/contracts/sendico/api/recipients/list.yaml delete mode 100644 backend/contracts/sendico/api/recipients/object.yaml delete mode 100644 backend/contracts/sendico/api/recipients/request/recipient.yaml delete mode 100644 backend/contracts/sendico/api/recipients/response/recipient.yaml delete mode 100644 backend/contracts/sendico/api/recipients/update.yaml delete mode 100644 backend/contracts/sendico/api/response/error.yaml delete mode 100644 backend/contracts/sendico/api/response/operation.yaml delete mode 100644 backend/contracts/sendico/api/response/response.yaml delete mode 100644 backend/contracts/sendico/api/response/status.yaml delete mode 100644 backend/contracts/sendico/api/verification/bodies/verification.yaml delete mode 100644 backend/contracts/sendico/api/verification/create.yaml delete mode 100644 backend/contracts/sendico/api/verification/request/verification.yaml delete mode 100644 backend/contracts/sendico/api/verification/resend.yaml delete mode 100644 backend/contracts/sendico/api/verification/response/verification.yaml delete mode 100644 backend/contracts/sendico/api/verification/verify.yaml delete mode 100644 backend/contracts/sendico/api/wallets/assets.yaml delete mode 100644 backend/contracts/sendico/api/wallets/response/wallet.yaml delete mode 100644 backend/contracts/sendico/external/chain_network.yaml delete mode 100644 backend/contracts/sendico/external/endpoint_type.yaml delete mode 100644 backend/contracts/sendico/external/fx_side.yaml delete mode 100644 backend/contracts/sendico/external/payment_kind.yaml delete mode 100644 backend/contracts/sendico/external/payment_method_type.yaml delete mode 100644 backend/contracts/sendico/external/payment_state.yaml delete mode 100644 backend/contracts/sendico/external/settlement_mode.yaml delete mode 100644 backend/contracts/sendico/models/account/account.yaml delete mode 100644 backend/contracts/sendico/models/account/account_public.yaml delete mode 100644 backend/contracts/sendico/models/auth/client_refresh_token.yaml delete mode 100644 backend/contracts/sendico/models/auth/login_data.yaml delete mode 100644 backend/contracts/sendico/models/auth/session_identifier.yaml delete mode 100644 backend/contracts/sendico/models/auth/token_data.yaml delete mode 100644 backend/contracts/sendico/models/callback/callback.yaml delete mode 100644 backend/contracts/sendico/models/common/describable.yaml delete mode 100644 backend/contracts/sendico/models/common/money.yaml delete mode 100644 backend/contracts/sendico/models/common/pagination.yaml delete mode 100644 backend/contracts/sendico/models/ledger/balance.yaml delete mode 100644 backend/contracts/sendico/models/ledger/operation.yaml delete mode 100644 backend/contracts/sendico/models/objectid.yaml delete mode 100644 backend/contracts/sendico/models/organization/organization.yaml delete mode 100644 backend/contracts/sendico/models/payment/fx_metadata.yaml delete mode 100644 backend/contracts/sendico/models/payment/payment.yaml delete mode 100644 backend/contracts/sendico/models/payment_method/payment_method.yaml delete mode 100644 backend/contracts/sendico/models/permission_bound.yaml delete mode 100644 backend/contracts/sendico/models/recipient/recipient.yaml delete mode 100644 backend/contracts/sendico/models/storable.yaml delete mode 100644 backend/contracts/sendico/models/wallet/wallet_asset.yaml delete mode 100644 backend/edge/shared/interface/api/config.go delete mode 100644 backend/edge/shared/interface/api/srequest/login.go delete mode 100644 backend/edge/shared/interface/api/srequest/refresh.go delete mode 100644 backend/edge/shared/interface/api/srequest/rotate.go delete mode 100644 backend/edge/shared/interface/api/sresponse/response.go delete mode 100644 backend/edge/shared/server/server.go delete mode 100644 backend/edge/shared/storage/refreshtokens/refreshtokens.go delete mode 100644 backend/edge/shared/storage/verification/context_ref.go delete mode 100644 backend/internal/architecture/contracts_test.go delete mode 100644 backend/pkg/db/account/account.go delete mode 100644 backend/pkg/db/config.go delete mode 100644 backend/pkg/db/internal/mongo/accountdb/user.go delete mode 100644 backend/pkg/mlogger/factory/mlogger.go delete mode 100644 backend/pkg/version/factory/factory.go create mode 100644 backend/product/README.md rename backend/{edge/shared/interface => product/app}/api/api.go (65%) create mode 100644 backend/product/app/api/config.go rename backend/{edge/shared/interface => product/app}/api/register.go (70%) rename backend/{edge/shared/interface => product/app}/api/runtime.go (93%) rename backend/{pkg/api/routers => product/app/health}/health.go (67%) rename backend/{pkg/api/routers/internal/healthimp => product/app/health/internal}/health.go (94%) rename backend/{pkg/api/routers/internal/healthimp => product/app/health/internal}/health_test.go (96%) rename backend/{pkg/api/routers/internal/healthimp => product/app/health/internal}/status.go (83%) rename backend/{pkg/api/routers/health => product/app/health/status}/status.go (100%) rename backend/{edge/shared/internal/transport => product/app}/http/middleware.go (84%) rename backend/{edge/shared/internal/transport => product/app}/http/middleware_test.go (98%) rename backend/{edge/shared/internal/transport/http/router => product/app}/metrics/handler.go (94%) rename backend/{edge/shared/internal/transport/http/router => product/app}/metrics/router.go (84%) rename backend/{pkg/server => product/app/process}/factory.go (68%) rename backend/{pkg/server => product/app/process}/internal/instance.go (88%) rename backend/{pkg/server => product/app/process}/internal/server.go (88%) rename backend/{pkg/server/main => product/app/process/run}/run.go (51%) rename backend/{pkg/server => product/app/process}/server.go (100%) rename backend/{internal => product/app}/profile/runtime.go (79%) rename backend/{edge/shared/internal => product/app}/runtime/api.go (74%) rename backend/{edge/shared/internal => product/app}/runtime/bootstrap.go (86%) rename backend/{edge/shared/internal => product/app}/runtime/bootstrap_test.go (83%) rename backend/{edge/shared/internal => product/app}/runtime/lifecycle.go (93%) rename backend/{edge/shared/internal => product/app}/runtime/services.go (79%) rename backend/{edge/shared/internal/server => product/app/server/internal}/config_test.go (84%) rename backend/{edge/shared/internal/server => product/app/server/internal}/server.go (92%) rename backend/{edge/shared/internal/server => product/app/server/internal}/server_test.go (88%) create mode 100644 backend/product/app/server/server.go rename backend/{pkg/domainprovider => product/app/urls}/domain_provider.go (76%) rename backend/{pkg/domainprovider/imp => product/app/urls/internal}/domain_provider.go (95%) rename backend/{edge/shared/appversion => product/app/version}/version.go (79%) rename backend/{internal => product}/architecture/boundary_test.go (80%) rename backend/{internal => product}/architecture/customer_test.go (89%) rename backend/{internal => product}/architecture/errors_test.go (95%) rename backend/{edge/shared => product}/auth/client/client.go (69%) rename backend/{edge/shared => product}/auth/client/client_test.go (81%) rename backend/{edge/shared/interface/api => product/auth/config}/auth_config.go (86%) rename backend/{edge/shared/interface/middleware => product/auth/config}/cors_test.go (100%) rename backend/{edge/shared/interface/middleware => product/auth/config}/login_rate_limit.go (94%) rename backend/{edge/shared/interface/middleware => product/auth/config}/login_rate_limit_test.go (97%) rename backend/{edge/shared/interface/middleware => product/auth/config}/middleware.go (98%) rename backend/{edge/shared/interface/middleware => product/auth/config}/token_test.go (100%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/authorized/customer_test.go (59%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/authorized/handler.go (90%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/authorized/router.go (73%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/authorized/session_test.go (82%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/dispatcher.go (67%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/endpoint/endpoint.go (64%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/endpoint/install.go (90%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/endpoint/token.go (63%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/login.go (84%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/login_rate_limit.go (91%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/login_rate_limit_test.go (93%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/login_test.go (88%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/login_test_support_test.go (79%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/logout.go (87%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/logout_test.go (85%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/refresh.go (84%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/refresh_test.go (89%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/respond.go (72%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/rotate.go (86%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/router.go (71%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/public/validate.go (83%) rename backend/{edge/shared/internal/transport/http/router => product/auth/http}/router.go (72%) rename backend/{edge/shared/http => product/auth}/ipguard/ipguard.go (100%) rename backend/{edge/shared/http => product/auth}/ipguard/ipguard_test.go (100%) rename backend/{edge/shared/interface => product/auth}/model/token.go (82%) rename backend/{edge/shared/interface => product/auth}/model/token_test.go (76%) create mode 100644 backend/product/auth/request/login.go create mode 100644 backend/product/auth/request/refresh.go create mode 100644 backend/product/auth/request/rotate.go rename backend/{edge/shared/interface/api/srequest => product/auth/request}/validateable.go (100%) rename backend/{edge/shared/interface/api/srequest => product/auth/request}/verification.go (55%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/account.go (68%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/authresp.go (100%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/file.go (68%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/login.go (65%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/login_pending.go (61%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/login_test.go (84%) create mode 100644 backend/product/auth/response/response.go rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/result.go (85%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/token.go (100%) rename backend/{edge/shared/interface/api/sresponse => product/auth/response}/verification.go (100%) rename backend/{edge/shared => product}/auth/storage/stores.go (84%) rename backend/{ => product}/config.example.yml (100%) rename backend/{edge/product/interface/accountservice => product/customers}/internal/customer_test.go (70%) rename backend/{edge/product/interface/accountservice => product/customers}/internal/service.go (80%) rename backend/{edge/product/interface/accountservice => product/customers}/internal/service_test.go (82%) rename backend/{pkg => product/customers}/model/account.go (71%) rename backend/{pkg => product/customers}/model/userdata.go (69%) create mode 100644 backend/product/customers/store/account.go rename backend/{pkg/db/internal/mongo/accountdb => product/customers/store/mongo}/db.go (67%) create mode 100644 backend/product/customers/store/mongo/user.go rename backend/{edge/product/interface/accountservice => product/customers}/types.go (70%) rename backend/{ => product}/go.mod (94%) rename backend/{ => product}/go.sum (100%) create mode 100644 backend/product/main.go rename backend/{edge/shared/auth => product/sessions}/browser/browser.go (98%) rename backend/{edge/shared/auth => product/sessions}/browser/browser_test.go (100%) rename backend/{edge/shared/auth/sessionmetrics => product/sessions/metrics}/metrics.go (100%) rename backend/{pkg => product/sessions}/model/client.go (89%) rename backend/{pkg => product/sessions}/model/refresh.go (88%) rename backend/{pkg => product/sessions}/model/sessionid.go (100%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/client.go (58%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/crud.go (84%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/crud_test.go (85%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/db.go (83%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/fields.go (100%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/filters.go (62%) create mode 100644 backend/product/sessions/store/refreshtokens.go rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/refreshtokens_indexes_test.go (92%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/refreshtokens_misc_test.go (84%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/refreshtokens_session_replacement_test.go (83%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/refreshtokens_session_test.go (86%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/refreshtokens_test.go (86%) rename backend/{edge/shared/storage/refreshtokens => product/sessions/store}/revoke.go (84%) rename backend/{edge/shared/auth => product/sessions}/tokens/prepare_test.go (84%) rename backend/{edge/shared/auth => product/sessions}/tokens/tokens.go (73%) create mode 100644 backend/product/verification/config/config.go rename backend/{edge/shared/interface/api => product/verification/config}/verification_rate_limit.go (85%) rename backend/{edge/shared/interface/api => product/verification/config}/verification_rate_limit_test.go (72%) rename backend/{pkg => product/verification}/model/verificaton.go (95%) rename backend/{edge/shared/storage/verification => product/verification/store}/consume.go (83%) rename backend/{edge/shared/storage/verification => product/verification/store}/consume_logging.go (88%) rename backend/{edge/shared/storage/verification => product/verification/store}/consume_lookup.go (87%) rename backend/{edge/shared/storage/verification => product/verification/store}/consume_scope.go (84%) rename backend/{edge/shared/storage/verification => product/verification/store}/consume_update.go (89%) create mode 100644 backend/product/verification/store/context_ref.go rename backend/{edge/shared/storage/verification => product/verification/store}/context_ref_test.go (78%) rename backend/{edge/shared/storage/verification => product/verification/store}/create.go (92%) rename backend/{edge/shared/storage/verification => product/verification/store}/db.go (77%) rename backend/{edge/shared/storage/verification => product/verification/store}/errors.go (97%) rename backend/{edge/shared/storage/verification => product/verification/store}/hash.go (84%) rename backend/{edge/shared/storage/verification => product/verification/store}/hash_test.go (100%) rename backend/{edge/shared/storage/verification => product/verification/store}/idempotency.go (76%) rename backend/{edge/shared/storage/verification => product/verification/store}/issuance_limit.go (96%) rename backend/{edge/shared/storage/verification => product/verification/store}/issuance_limit_test.go (96%) rename backend/{edge/shared/storage/verification => product/verification/store}/memory_token_repository_test.go (86%) rename backend/{edge/shared/storage/verification => product/verification/store}/request.go (79%) rename backend/{edge/shared/storage/verification => product/verification/store}/token_query_matcher_test.go (84%) rename backend/{edge/shared/storage/verification => product/verification/store}/verification.go (56%) rename backend/{edge/shared/storage/verification => product/verification/store}/verification_attempts_test.go (69%) rename backend/{edge/shared/storage/verification => product/verification/store}/verification_idempotency_test.go (66%) rename backend/{edge/shared/storage/verification => product/verification/store}/verification_test.go (58%) rename backend/{edge/shared/storage/verification => product/verification/store}/verification_test_helpers_test.go (79%) create mode 100644 backend/shared/README.md rename backend/{pkg/mutil => shared}/config/param.go (97%) create mode 100644 backend/shared/database/config.go rename backend/{pkg/db => shared/database}/connection.go (89%) rename backend/{edge/shared/storage/mongo => shared/database/dependencies}/dependencies.go (84%) rename backend/{pkg/db => shared/database}/factory.go (56%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/accumulator.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/alias.go (96%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/array.go (89%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/expression.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/field.go (95%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/func.go (98%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/gaccumulator.go (92%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/patch.go (96%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/patch_test.go (95%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/pipeline.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/pipeline_test.go (99%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/projection.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/query.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/query_test.go (92%) rename backend/{pkg/db/internal/mongo/repositoryimp/builderimp => shared/database/internal/builder}/value.go (74%) rename backend/{pkg/db => shared/database}/internal/mongo/db.go (88%) rename backend/{pkg/db => shared/database}/internal/mongo/mongo.go (100%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/index.go (96%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/index_integration_test.go (95%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/index_test.go (95%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository.go (95%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_comprehensive_test.go (98%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_filter.go (90%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_insertmany_test.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_patch_test.go (97%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_test.go (94%) rename backend/{pkg/db/internal/mongo/repositoryimp => shared/database/internal/repository}/repository_upsert_test.go (97%) rename backend/{pkg/db/internal/mongo/transactionimp => shared/database/internal/transaction}/factory.go (86%) rename backend/{pkg/db/internal/mongo/transactionimp => shared/database/internal/transaction}/transaction.go (92%) rename backend/{pkg/mutil/db => shared/database/query}/archive.go (81%) rename backend/{pkg/mutil/db => shared/database/query}/array.go (60%) rename backend/{pkg/mutil/db => shared/database/query}/db.go (74%) rename backend/{pkg/db/storable => shared/database/record}/id.go (100%) rename backend/{pkg/db/storable => shared/database/record}/ref.go (100%) rename backend/{pkg/db/storable => shared/database/record}/storable.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/accumulator.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/alias.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/array.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/expression.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/field.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/keyword.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/operators.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/patch.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/pipeline.go (93%) rename backend/{pkg/db => shared/database}/repository/builder/projection.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/query.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/unwind.go (100%) rename backend/{pkg/db => shared/database}/repository/builder/value.go (100%) rename backend/{pkg/db => shared/database}/repository/builders.go (94%) rename backend/{pkg/db => shared/database}/repository/cursor.go (57%) rename backend/{pkg/db => shared/database}/repository/decoder/decoder.go (100%) rename backend/{pkg/db => shared/database}/repository/index/index.go (89%) rename backend/{pkg/db => shared/database}/repository/index/types.go (100%) rename backend/{pkg/db => shared/database}/repository/repository.go (88%) rename backend/{pkg/db => shared/database}/template/interface.go (84%) rename backend/{pkg/db => shared/database}/template/template.go (87%) rename backend/{pkg/db => shared/database}/transaction/context.go (100%) rename backend/{pkg/db => shared/database}/transaction/context_test.go (100%) rename backend/{pkg/db => shared/database}/transaction/factory.go (100%) rename backend/{pkg/db => shared/database}/transaction/transaction.go (100%) rename backend/{pkg/mutil => shared}/duration/duration.go (100%) rename backend/{pkg/merrors => shared/errors}/classified.go (100%) rename backend/{pkg/merrors => shared/errors}/errors.go (100%) rename backend/{pkg/merrors => shared/errors}/errors_test.go (100%) rename backend/{pkg/merrors => shared/errors}/invalid_argument.go (100%) rename backend/{pkg/merrors => shared/errors}/reason.go (100%) rename backend/{pkg/merrors => shared/errors}/throttle.go (100%) rename backend/{pkg/merrors => shared/errors}/throttle_test.go (100%) create mode 100644 backend/shared/go.mod create mode 100644 backend/shared/go.sum rename backend/{pkg/api => shared}/http/methods.go (100%) rename backend/{pkg/api => shared}/http/response/response.go (97%) rename backend/{pkg/api => shared}/http/response/response_test.go (92%) rename backend/{pkg/api => shared}/http/response/result.go (86%) rename backend/{pkg/api => shared}/http/status.go (100%) create mode 100644 backend/shared/logging/factory/mlogger.go rename backend/{pkg/mutil/mzap => shared/logging/fields}/account.go (72%) rename backend/{pkg/mutil/mzap => shared/logging/fields}/error.go (100%) rename backend/{pkg/mutil/mzap => shared/logging/fields}/object.go (84%) rename backend/{pkg/mlogger/internal/mlogger => shared/logging/internal}/mlogger.go (90%) rename backend/{pkg/mlogger => shared/logging}/logger.go (100%) rename backend/{pkg/mlogger => shared/logging}/logger_test.go (100%) rename backend/{pkg/mutil => shared/logging}/mask/mask.go (100%) rename backend/{pkg/mutil => shared/logging}/mask/mask_test.go (100%) rename backend/{pkg => shared}/model/archivable.go (100%) rename backend/{pkg => shared}/model/describable.go (100%) rename backend/{pkg => shared}/model/fconfig.go (100%) rename backend/{pkg => shared}/model/viewcursor.go (100%) rename backend/{edge/shared/interface => shared}/ratelimit/config.go (95%) rename backend/{edge/shared/storage/ratelimits => shared/ratelimit/store}/db.go (94%) rename backend/{pkg/mservice => shared/service}/mservice.go (100%) rename backend/{pkg/mservice => shared/service}/services.go (100%) create mode 100644 backend/shared/version/factory/factory.go rename backend/{pkg => shared}/version/info.go (100%) rename backend/{pkg => shared}/version/internal/version.go (98%) rename backend/{pkg => shared}/version/version.go (100%) diff --git a/Makefile b/Makefile index 84aeb8a..8c23427 100644 --- a/Makefile +++ b/Makefile @@ -16,12 +16,14 @@ test-frontend: .PHONY: test-backend lint-backend run-backend test-backend: - cd backend && go test -race ./... + cd backend/shared && go test -race ./... + cd backend/product && go test -race ./... lint-backend: - cd backend && go test ./internal/architecture + cd backend/product && go test ./architecture cd backend && test -z "$$(gofmt -l .)" - cd backend && go vet ./... + cd backend/shared && go vet ./... + cd backend/product && go vet ./... run-backend: - cd backend && go run ./cmd/bff + cd backend/product && go run . diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 748d279..6c3fdd8 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -25,11 +25,11 @@ skill to perform this completion workflow. ## Use the project error model - Do not introduce `fmt.Errorf` or `errors.New`. Use the constructors and - wrapping helpers in `backend/pkg/merrors`. + wrapping helpers in `backend/shared/errors`. - Preserve the underlying cause when adding context by using an appropriate `merrors` wrapping helper. - When no suitable project error exists, add the smallest generally useful - definition or helper to `backend/pkg/merrors` instead of creating an ad hoc error + definition or helper to `backend/shared/errors` instead of creating an ad hoc error elsewhere. ## Keep modules cohesive and names concise @@ -93,7 +93,18 @@ unused dependencies; remove those dependencies from the selected components. Copied BFF components are locally owned and may diverge. Use only Sendico's public HTTP API for platform operations. Never import sibling Sendico modules or access its database, internal gRPC APIs, discovery or message -bus. Keep the pinned public wire contract separate from local DTOs and models. +bus. Keep consumed public wire contracts inside the owning adapter, separate +from local DTOs and models. Do not copy unused API snapshots. Resolve the configured provider organization and customer resource ownership on the server. Never treat all customers as the backend integration principal. Use the copied structured logger, injected at operational boundaries. + +## Go module structure + +`backend/` groups independent Go modules; it is not itself a Go module. +`product/` is the customer-facing executable and owns its `main.go` and `go.mod`. +`shared/` is a library module for reusable infrastructure and has no entry point. +Backoffice belongs in its own executable module (`backoffice/`) when implemented, +with its own entry point and configuration, not inside the product executable. +Keep feature models, storage, services and transport together inside their owning +module. Shared infrastructure must not import a product or backoffice module. diff --git a/backend/PROVENANCE.json b/backend/PROVENANCE.json index c79f6f4..433f36c 100644 --- a/backend/PROVENANCE.json +++ b/backend/PROVENANCE.json @@ -4,1958 +4,1401 @@ "files": [ { "source": "api/pkg/merrors/classified.go", - "target": "pkg/merrors/classified.go", + "target": "shared/errors/classified.go", "blob": "569049273d437caa7d7d62e8f804f2d8b5ba632f", "adapted_beyond_namespace": false }, { "source": "api/pkg/merrors/errors.go", - "target": "pkg/merrors/errors.go", + "target": "shared/errors/errors.go", "blob": "dceb39670721ed0b4bc59e8da9a96a0428c87684", "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/errors_test.go", - "target": "pkg/merrors/errors_test.go", + "target": "shared/errors/errors_test.go", "blob": "4602c7cbc06b59f99059bc8e6e2780ba899f55a2", "adapted_beyond_namespace": false }, { "source": "api/pkg/merrors/invalid_argument.go", - "target": "pkg/merrors/invalid_argument.go", + "target": "shared/errors/invalid_argument.go", "blob": "954402a713c11f113377a08cdb27ef87006e40c3", "adapted_beyond_namespace": false }, { "source": "api/pkg/merrors/reason.go", - "target": "pkg/merrors/reason.go", + "target": "shared/errors/reason.go", "blob": "9205bac7ece87eaf56e410ff96ec68a0f0f69e51", "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/throttle.go", - "target": "pkg/merrors/throttle.go", + "target": "shared/errors/throttle.go", "blob": "3ff3da6a67b09911c14170fe04cadf8810f3a5f9", "adapted_beyond_namespace": false }, { "source": "api/pkg/merrors/throttle_test.go", - "target": "pkg/merrors/throttle_test.go", + "target": "shared/errors/throttle_test.go", "blob": "10014a308444208cc67fbefc61c0bb815697dafe", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/appversion/version.go", - "target": "edge/shared/appversion/version.go", + "target": "product/app/version/version.go", "blob": "85e224562f93e7e813b5bf5728cac8903c9d5cd7", "adapted_beyond_namespace": true }, { "source": "api/pkg/version/factory/factory.go", - "target": "pkg/version/factory/factory.go", + "target": "shared/version/factory/factory.go", "blob": "be90064ac5e583446678fdbbc12126c57e82ced6", "adapted_beyond_namespace": false }, { "source": "api/pkg/version/internal/version.go", - "target": "pkg/version/internal/version.go", + "target": "shared/version/internal/version.go", "blob": "15d6674c3d6c3b5ac75ce455b228fb01d6003639", "adapted_beyond_namespace": false }, { "source": "api/pkg/version/info.go", - "target": "pkg/version/info.go", + "target": "shared/version/info.go", "blob": "c9a9ce553da2905e1415d414ed190029b13e53e2", "adapted_beyond_namespace": false }, { "source": "api/pkg/version/version.go", - "target": "pkg/version/version.go", + "target": "shared/version/version.go", "blob": "7ab617fe20f65d080795cfa7d3bf8c3b21b96126", "adapted_beyond_namespace": false }, { "source": "api/pkg/server/main/run.go", - "target": "pkg/server/main/run.go", + "target": "product/app/process/run/run.go", "blob": "103dbeaf5fa7bb533dad3ce2c5e0aed1cd8667c7", "adapted_beyond_namespace": true }, { "source": "api/pkg/server/internal/instance.go", - "target": "pkg/server/internal/instance.go", + "target": "product/app/process/internal/instance.go", "blob": "e1d8f2a5b4cd6df2740562632c49cae5ee336d77", "adapted_beyond_namespace": false }, { "source": "api/pkg/server/internal/server.go", - "target": "pkg/server/internal/server.go", + "target": "product/app/process/internal/server.go", "blob": "b841af395a1ceaaedc337000cb75ec44834e27d3", "adapted_beyond_namespace": true }, { "source": "api/pkg/server/factory.go", - "target": "pkg/server/factory.go", + "target": "product/app/process/factory.go", "blob": "a91e7251de0d882cfb389fc155d636d2872b9749", "adapted_beyond_namespace": false }, { "source": "api/pkg/server/server.go", - "target": "pkg/server/server.go", + "target": "product/app/process/server.go", "blob": "127219004cdea545dc0da6f11026e18522449989", "adapted_beyond_namespace": false }, { "source": "api/pkg/mlogger/logger.go", - "target": "pkg/mlogger/logger.go", + "target": "shared/logging/logger.go", "blob": "71172f8361d8185d1b86106e9de5e568cc39fe8e", "adapted_beyond_namespace": false }, { "source": "api/pkg/mlogger/logger_test.go", - "target": "pkg/mlogger/logger_test.go", + "target": "shared/logging/logger_test.go", "blob": "9c7b9e8b6981c8a363810d84dd2050a872872499", "adapted_beyond_namespace": false }, { "source": "api/pkg/mlogger/factory/mlogger.go", - "target": "pkg/mlogger/factory/mlogger.go", + "target": "shared/logging/factory/mlogger.go", "blob": "d3315d2c6b54c44963c23900d5a84379bb1104ff", "adapted_beyond_namespace": false }, { "source": "api/pkg/mlogger/internal/mlogger/mlogger.go", - "target": "pkg/mlogger/internal/mlogger/mlogger.go", + "target": "shared/logging/internal/mlogger.go", "blob": "ad7c579f7c5a26668f171853677b52424a2f203f", "adapted_beyond_namespace": false }, { "source": "api/pkg/mservice/mservice.go", - "target": "pkg/mservice/mservice.go", + "target": "shared/service/mservice.go", "blob": "87e24f67707a64084f6eaf4d7ed9b4e14c17177c", "adapted_beyond_namespace": false }, { "source": "api/pkg/mservice/services.go", - "target": "pkg/mservice/services.go", + "target": "shared/service/services.go", "blob": "d02e6f5b5af4ba7dac1fdd1cf3f6331ecd1800a1", "adapted_beyond_namespace": true }, { "source": "api/pkg/model/account.go", - "target": "pkg/model/account.go", + "target": "product/customers/model/account.go", "blob": "552c87f19dedb4927b3758d7ca86f091be8e2f34", "adapted_beyond_namespace": true }, { "source": "api/pkg/model/archivable.go", - "target": "pkg/model/archivable.go", + "target": "shared/model/archivable.go", "blob": "9b51c661f5c09825a6ea8dfc46aed09f94166654", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/client.go", - "target": "pkg/model/client.go", + "target": "product/sessions/model/client.go", "blob": "d61c63a55985b8b3bf05bb417f1079ab98fbf495", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/describable.go", - "target": "pkg/model/describable.go", + "target": "shared/model/describable.go", "blob": "1c9b1ce051384b5b8cab4388a6be547bc9b2c1cd", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/fconfig.go", - "target": "pkg/model/fconfig.go", + "target": "shared/model/fconfig.go", "blob": "8806c3b3c30f17033a8b1a0228b445c437f7b50d", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/refresh.go", - "target": "pkg/model/refresh.go", + "target": "product/sessions/model/refresh.go", "blob": "43e956be1babedf7857f7697dd3478eb9d05b006", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/sessionid.go", - "target": "pkg/model/sessionid.go", + "target": "product/sessions/model/sessionid.go", "blob": "251eea9f0bb56d67d89d32851e004313d17aae52", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/userdata.go", - "target": "pkg/model/userdata.go", + "target": "product/customers/model/userdata.go", "blob": "8a56bd19ffececc4ede58155263eff8e50721101", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/model/verificaton.go", - "target": "pkg/model/verificaton.go", + "target": "product/verification/model/verificaton.go", "blob": "4367db5eaaebdb9caea1815dca8d641adff05a42", "adapted_beyond_namespace": false }, { "source": "api/pkg/model/viewcursor.go", - "target": "pkg/model/viewcursor.go", + "target": "shared/model/viewcursor.go", "blob": "0143c3f99f3131f778fba713c33f796fb4fb9b6e", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/storable/id.go", - "target": "pkg/db/storable/id.go", + "target": "shared/database/record/id.go", "blob": "58d06374158e2e606aa98fc21860a5077b26b934", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/storable/ref.go", - "target": "pkg/db/storable/ref.go", + "target": "shared/database/record/ref.go", "blob": "58e346847d00d8e70f435fc1346a540f76241e83", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/storable/storable.go", - "target": "pkg/db/storable/storable.go", + "target": "shared/database/record/storable.go", "blob": "e048582bbc669013eaf02ea57076072a3d4d16e6", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/mzap/account.go", - "target": "pkg/mutil/mzap/account.go", + "target": "shared/logging/fields/account.go", "blob": "628c21d887583a26b6f804c284b9383213b17627", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/mzap/error.go", - "target": "pkg/mutil/mzap/error.go", + "target": "shared/logging/fields/error.go", "blob": "e2c47cb94bdbd8bafe87022002cd86168713091c", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/mzap/object.go", - "target": "pkg/mutil/mzap/object.go", + "target": "shared/logging/fields/object.go", "blob": "663574556a14cd592fcaa3be615bed3644f7dea1", "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/mask/mask.go", - "target": "pkg/mutil/mask/mask.go", + "target": "shared/logging/mask/mask.go", "blob": "9d3ea7912594b23d51ea0e06262d5a2a7f1ff22e", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/mask/mask_test.go", - "target": "pkg/mutil/mask/mask_test.go", + "target": "shared/logging/mask/mask_test.go", "blob": "6c723a9554c63a719e10c2dc3f1dc1267a698d38", "adapted_beyond_namespace": false }, { "source": "api/edge/product/interface/accountservice/types.go", - "target": "edge/product/interface/accountservice/types.go", + "target": "product/customers/types.go", "blob": "848519b9000cda122be8fa652544b33ed2f7fd52", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/config.go", - "target": "pkg/db/config.go", + "target": "shared/database/config.go", "blob": "86d46f9f0c78e76e782f134222028fe91d955f53", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/connection.go", - "target": "pkg/db/connection.go", + "target": "shared/database/connection.go", "blob": "ce8914683aa4cee98e6a0d75c8622329b3500995", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/factory.go", - "target": "pkg/db/factory.go", + "target": "shared/database/factory.go", "blob": "b9ea3bb4da4fae375d9fabe7c816c7a6b65847bf", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/transaction/context.go", - "target": "pkg/db/transaction/context.go", + "target": "shared/database/transaction/context.go", "blob": "0b89e5fe74e08ecdd5709128acbe4edd40980fb2", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/transaction/context_test.go", - "target": "pkg/db/transaction/context_test.go", + "target": "shared/database/transaction/context_test.go", "blob": "5f0c7ed46bd3ccafbf987106814622115fa77570", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/transaction/factory.go", - "target": "pkg/db/transaction/factory.go", + "target": "shared/database/transaction/factory.go", "blob": "d26e313f69414ce7fecb63169450934d0255007a", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/transaction/transaction.go", - "target": "pkg/db/transaction/transaction.go", + "target": "shared/database/transaction/transaction.go", "blob": "859977f653db2e0dac676cd12f161acc2d91b72b", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/template/interface.go", - "target": "pkg/db/template/interface.go", + "target": "shared/database/template/interface.go", "blob": "cbf1657146d27705999f732f0e5fad11678f5c64", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/template/template.go", - "target": "pkg/db/template/template.go", + "target": "shared/database/template/template.go", "blob": "abe24d42447e1759aa373a5ecadd06ccb879d672", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/builder/accumulator.go", - "target": "pkg/db/repository/builder/accumulator.go", + "target": "shared/database/repository/builder/accumulator.go", "blob": "7d2729a40035770a43f7cbe12e07aae93dd7f48d", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/alias.go", - "target": "pkg/db/repository/builder/alias.go", + "target": "shared/database/repository/builder/alias.go", "blob": "ba587bc8b89263624167daf56156d47ba82c21eb", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/array.go", - "target": "pkg/db/repository/builder/array.go", + "target": "shared/database/repository/builder/array.go", "blob": "59e78a3b50b35b037442ea488f14bb61608f65e1", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/expression.go", - "target": "pkg/db/repository/builder/expression.go", + "target": "shared/database/repository/builder/expression.go", "blob": "0c1d284e0236891be603aea6e75bbce857b07883", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/field.go", - "target": "pkg/db/repository/builder/field.go", + "target": "shared/database/repository/builder/field.go", "blob": "4d2caaa547588d3e6b55b5a9e3f79e4b6efef84c", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/keyword.go", - "target": "pkg/db/repository/builder/keyword.go", + "target": "shared/database/repository/builder/keyword.go", "blob": "64d67b0c826821b7d679a8c76db5db105dd06982", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/operators.go", - "target": "pkg/db/repository/builder/operators.go", + "target": "shared/database/repository/builder/operators.go", "blob": "c54bbd18c6ebf873b1587254a362f88f86ba92be", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/patch.go", - "target": "pkg/db/repository/builder/patch.go", + "target": "shared/database/repository/builder/patch.go", "blob": "5da3eeebac492cf46ca2d099617e2898395b9b96", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/pipeline.go", - "target": "pkg/db/repository/builder/pipeline.go", + "target": "shared/database/repository/builder/pipeline.go", "blob": "47350e0d61eaecbdfa36c3cac8106b2f50fb7c44", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/projection.go", - "target": "pkg/db/repository/builder/projection.go", + "target": "shared/database/repository/builder/projection.go", "blob": "466f07a7dbc9d0fd4737df1dc601cdf0807d83ca", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/query.go", - "target": "pkg/db/repository/builder/query.go", + "target": "shared/database/repository/builder/query.go", "blob": "1fc397a04e2c1266f4abffb8785140c6ba770be2", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/unwind.go", - "target": "pkg/db/repository/builder/unwind.go", + "target": "shared/database/repository/builder/unwind.go", "blob": "9ed2c689ffb378b019706b2a2923cbae048df5b9", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builder/value.go", - "target": "pkg/db/repository/builder/value.go", + "target": "shared/database/repository/builder/value.go", "blob": "45b011c84f129e508231c314b4935c0f33bf08ad", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/builders.go", - "target": "pkg/db/repository/builders.go", + "target": "shared/database/repository/builders.go", "blob": "ba4190e467397693c5dfce8c991bfbbd2fcf7cd5", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/cursor.go", - "target": "pkg/db/repository/cursor.go", + "target": "shared/database/repository/cursor.go", "blob": "bfcbb351acc317539655053a5ff7861d1c987fdf", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/repository.go", - "target": "pkg/db/repository/repository.go", + "target": "shared/database/repository/repository.go", "blob": "14c4c10705cc641fe788dd0b10d9e1344c699599", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/index/index.go", - "target": "pkg/db/repository/index/index.go", + "target": "shared/database/repository/index/index.go", "blob": "a689631c9eaa3b5d34198452633efa2c853a5543", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/index/types.go", - "target": "pkg/db/repository/index/types.go", + "target": "shared/database/repository/index/types.go", "blob": "281ec1d8a98e3bd8ba21bff3d0769e3048888993", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/repository/decoder/decoder.go", - "target": "pkg/db/repository/decoder/decoder.go", + "target": "shared/database/repository/decoder/decoder.go", "blob": "3a5fb78ead22a12649817946d031a1f092fb9d35", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/index.go", - "target": "pkg/db/internal/mongo/repositoryimp/index.go", + "target": "shared/database/internal/repository/index.go", "blob": "ea49d7875b38d296d98012cb801319076254639a", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/index_integration_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/index_integration_test.go", + "target": "shared/database/internal/repository/index_integration_test.go", "blob": "52a6f3d257266124897cdfde952dc9deb84e19f0", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/index_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/index_test.go", + "target": "shared/database/internal/repository/index_test.go", "blob": "a84a6a225c1aa9c822936d9885d9141f87a19eba", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository.go", + "target": "shared/database/internal/repository/repository.go", "blob": "28d3683fc0b9ad9ce2ba8dc04eebad0247c04465", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", + "target": "shared/database/internal/repository/repository_comprehensive_test.go", "blob": "1395cc67768367047dcee5b39ac60725f58e546c", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_filter.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_filter.go", + "target": "shared/database/internal/repository/repository_filter.go", "blob": "05b5698e2b28a39e3150b8893dcd687809e6f09f", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go", + "target": "shared/database/internal/repository/repository_insertmany_test.go", "blob": "1b4836cdbede198b5f8ca7a6eebd6d8f07b8f635", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_patch_test.go", + "target": "shared/database/internal/repository/repository_patch_test.go", "blob": "8a7ae9ba0caf91d866853ba260253a1979868155", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_test.go", + "target": "shared/database/internal/repository/repository_test.go", "blob": "89f5cb2c61aabf58a7d0440d62bd1d93067f5204", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go", + "target": "shared/database/internal/repository/repository_upsert_test.go", "blob": "acffdb9f7913b43bfd2457beb5fd9cfd42b63b03", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go", + "target": "shared/database/internal/builder/accumulator.go", "blob": "09ff732bd77561ba180d7af84b1c7b1e306cac40", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/alias.go", + "target": "shared/database/internal/builder/alias.go", "blob": "325f315782b4988774fb1c54a5efd7d242e26c5a", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/array.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/array.go", + "target": "shared/database/internal/builder/array.go", "blob": "738fc9e1bcd3274475a435b602985b1cd384f125", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/expression.go", + "target": "shared/database/internal/builder/expression.go", "blob": "376a2fb8ba00ff256918652de46f72db6909f18b", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/field.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/field.go", + "target": "shared/database/internal/builder/field.go", "blob": "3a864b1a4872f7a4abaf76d7b0d98a52a1b4f16b", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/func.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/func.go", + "target": "shared/database/internal/builder/func.go", "blob": "b69410e9226a9dc540bc06f2b2acafd78c293106", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go", + "target": "shared/database/internal/builder/gaccumulator.go", "blob": "762a5da23a2f5fa2258689358a9a8f61aca6a8db", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/patch.go", + "target": "shared/database/internal/builder/patch.go", "blob": "d5519688e724745d2c20b70e98cf2e6ecbf94750", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go", + "target": "shared/database/internal/builder/patch_test.go", "blob": "ef86937d1c3c65dc0e7d8bd4b02ac5d262e68097", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go", + "target": "shared/database/internal/builder/pipeline.go", "blob": "8ab981135123f96df0a537723ca9199d582daa3e", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go", + "target": "shared/database/internal/builder/pipeline_test.go", "blob": "25a2989ae5f059426fed2ad90a84abdf614a31c2", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/projection.go", + "target": "shared/database/internal/builder/projection.go", "blob": "e53ec47bcfe487d5d9e8a12c5d8ea21c9d3056d9", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/query.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/query.go", + "target": "shared/database/internal/builder/query.go", "blob": "5aaa57826cd4af46209ec1bf3190b74ef984bb23", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go", + "target": "shared/database/internal/builder/query_test.go", "blob": "c915baaa5a26d51349c58971ed44dbcca9b040c9", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/repositoryimp/builderimp/value.go", - "target": "pkg/db/internal/mongo/repositoryimp/builderimp/value.go", + "target": "shared/database/internal/builder/value.go", "blob": "10de4fa645cc76a6e6fdb369177d63bf5f723974", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/db/archive.go", - "target": "pkg/mutil/db/archive.go", + "target": "shared/database/query/archive.go", "blob": "d4205a5d03dc534da82b6448339a5b5fdb61e09e", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/db/array.go", - "target": "pkg/mutil/db/array.go", + "target": "shared/database/query/array.go", "blob": "b0fb240cb832169a753bd4aa5076ec5c33dd5b26", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/db/db.go", - "target": "pkg/mutil/db/db.go", + "target": "shared/database/query/db.go", "blob": "d29855c92fe4df1abf5e342d5f025c4e6e7434ab", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/db.go", - "target": "pkg/db/internal/mongo/db.go", + "target": "shared/database/internal/mongo/db.go", "blob": "ae2659cc356ef53a5a228ec387c516be27b9d2bc", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/mongo.go", - "target": "pkg/db/internal/mongo/mongo.go", + "target": "shared/database/internal/mongo/mongo.go", "blob": "45915bc5409969720ff7d5e3558ac6f7a70d9758", "adapted_beyond_namespace": false }, { "source": "api/pkg/mutil/config/param.go", - "target": "pkg/mutil/config/param.go", + "target": "shared/config/param.go", "blob": "b2dad64a6b9167e686c04ab0cc28f0173a4fb4a5", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/transactionimp/factory.go", - "target": "pkg/db/internal/mongo/transactionimp/factory.go", + "target": "shared/database/internal/transaction/factory.go", "blob": "6b5c0424c6b8a27c5607d6bfb7d06ac2745dee0d", "adapted_beyond_namespace": false }, { "source": "api/pkg/db/internal/mongo/transactionimp/transaction.go", - "target": "pkg/db/internal/mongo/transactionimp/transaction.go", + "target": "shared/database/internal/transaction/transaction.go", "blob": "266feed66a033d86f8c75785718d424ac10f8db8", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/accountdb/db.go", - "target": "pkg/db/internal/mongo/accountdb/db.go", + "target": "product/customers/store/mongo/db.go", "blob": "43c302e80b8b1f931b02d5595a5ccd19bf95543c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/accountdb/user.go", - "target": "pkg/db/internal/mongo/accountdb/user.go", + "target": "product/customers/store/mongo/user.go", "blob": "22d68e5e07e5ac002c661e07409e3f21ba5c475c", "adapted_beyond_namespace": true }, { "source": "api/pkg/db/account/account.go", - "target": "pkg/db/account/account.go", + "target": "product/customers/store/account.go", "blob": "22f6191c47730d7e1373e96c8aa691940525fcf3", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/middleware/cors_test.go", - "target": "edge/shared/interface/middleware/cors_test.go", + "target": "product/auth/config/cors_test.go", "blob": "613ab83cd921e3e0a59ea40be3d7e226abdfcec0", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/middleware/login_rate_limit.go", - "target": "edge/shared/interface/middleware/login_rate_limit.go", + "target": "product/auth/config/login_rate_limit.go", "blob": "8b09e5c11e80e2af12341e7084a0a91b616fcbf0", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/middleware/login_rate_limit_test.go", - "target": "edge/shared/interface/middleware/login_rate_limit_test.go", + "target": "product/auth/config/login_rate_limit_test.go", "blob": "c5991e9e3f0966feca907c49423b7757d41ecc21", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/middleware/middleware.go", - "target": "edge/shared/interface/middleware/middleware.go", + "target": "product/auth/config/middleware.go", "blob": "be197a8313920db99c2c002417feb063dc3273d4", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/middleware/token_test.go", - "target": "edge/shared/interface/middleware/token_test.go", + "target": "product/auth/config/token_test.go", "blob": "090f6fe0041c8dc71658bcdf63cacf2791c3bc66", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/ratelimit/config.go", - "target": "edge/shared/interface/ratelimit/config.go", + "target": "shared/ratelimit/config.go", "blob": "1854a6abeadc2a9ce014e79f124c8cb213e17a65", "adapted_beyond_namespace": false }, { "source": "api/edge/product/interface/accountservice/internal/service.go", - "target": "edge/product/interface/accountservice/internal/service.go", + "target": "product/customers/internal/service.go", "blob": "e0a26dd3f3d740ad5103e98a56bfa68f1065b1b6", "adapted_beyond_namespace": true }, { "source": "api/edge/product/interface/accountservice/internal/service_test.go", - "target": "edge/product/interface/accountservice/internal/service_test.go", + "target": "product/customers/internal/service_test.go", "blob": "49dedb715e1861fdf2e5dfdbea870964a2a740b1", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/consume.go", - "target": "edge/shared/storage/verification/consume.go", + "target": "product/verification/store/consume.go", "blob": "95b713591d6bc13004b0c83ceeece35bc88893b6", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/consume_logging.go", - "target": "edge/shared/storage/verification/consume_logging.go", + "target": "product/verification/store/consume_logging.go", "blob": "250b5e3a3850d1d163a45709d424d75eb7f53f3e", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/consume_lookup.go", - "target": "edge/shared/storage/verification/consume_lookup.go", + "target": "product/verification/store/consume_lookup.go", "blob": "597d2a9cad6deae6537c48b36cd5af813477acc9", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/consume_scope.go", - "target": "edge/shared/storage/verification/consume_scope.go", + "target": "product/verification/store/consume_scope.go", "blob": "835c3618a0c80617e21146f358057e9f56c34adb", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/consume_update.go", - "target": "edge/shared/storage/verification/consume_update.go", + "target": "product/verification/store/consume_update.go", "blob": "c0c58007b9a2a076918bacf2d88a386bc84713a2", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/context_ref.go", - "target": "edge/shared/storage/verification/context_ref.go", + "target": "product/verification/store/context_ref.go", "blob": "a0cde6224e47fd8261b8a18fcac1ece12df3ecbc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/context_ref_test.go", - "target": "edge/shared/storage/verification/context_ref_test.go", + "target": "product/verification/store/context_ref_test.go", "blob": "71fc7eff86bd263b0070303b1feb49415d360a4c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/create.go", - "target": "edge/shared/storage/verification/create.go", + "target": "product/verification/store/create.go", "blob": "af1b06d68c0123f8ee222fe762e4167fc42da8e7", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/db.go", - "target": "edge/shared/storage/verification/db.go", + "target": "product/verification/store/db.go", "blob": "8e8e89d262d5c934177a15f495446e80690cbed9", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/errors.go", - "target": "edge/shared/storage/verification/errors.go", + "target": "product/verification/store/errors.go", "blob": "232dcadf98a3af9f80b1d0f6dd9760fd05a0b7f8", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/verification/hash.go", - "target": "edge/shared/storage/verification/hash.go", + "target": "product/verification/store/hash.go", "blob": "36fadcf404bfc77608b28cca1e669a4a62f31774", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/hash_test.go", - "target": "edge/shared/storage/verification/hash_test.go", + "target": "product/verification/store/hash_test.go", "blob": "342d56cd5f13ca7716de491a37a9da8851c6b42b", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/verification/idempotency.go", - "target": "edge/shared/storage/verification/idempotency.go", + "target": "product/verification/store/idempotency.go", "blob": "0d74b5908dc775dc1d03f9fdfe2ce9a37e25d1f8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/issuance_limit.go", - "target": "edge/shared/storage/verification/issuance_limit.go", + "target": "product/verification/store/issuance_limit.go", "blob": "052f1247e52b8c7e329009adda53ee518173e18f", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/verification/issuance_limit_test.go", - "target": "edge/shared/storage/verification/issuance_limit_test.go", + "target": "product/verification/store/issuance_limit_test.go", "blob": "01e81a5d2c8dd39ab07aeb6722f5bab17bf6ab3b", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/memory_token_repository_test.go", - "target": "edge/shared/storage/verification/memory_token_repository_test.go", + "target": "product/verification/store/memory_token_repository_test.go", "blob": "3a24298b02e90323416f1b47a1012d36b7cd26bc", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/request.go", - "target": "edge/shared/storage/verification/request.go", + "target": "product/verification/store/request.go", "blob": "4d767260e3136a4688bfe011a328967826213da8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/token_query_matcher_test.go", - "target": "edge/shared/storage/verification/token_query_matcher_test.go", + "target": "product/verification/store/token_query_matcher_test.go", "blob": "9a5c088919884056e793b3a791e159c7ce4c0927", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/verification.go", - "target": "edge/shared/storage/verification/verification.go", + "target": "product/verification/store/verification.go", "blob": "2e4b0e9bb12612b3766bf40c56abdb4f6efe3245", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/verification_attempts_test.go", - "target": "edge/shared/storage/verification/verification_attempts_test.go", + "target": "product/verification/store/verification_attempts_test.go", "blob": "eb2cbc58cbe68606dc152a23568fc34ec5084f32", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/verification_idempotency_test.go", - "target": "edge/shared/storage/verification/verification_idempotency_test.go", + "target": "product/verification/store/verification_idempotency_test.go", "blob": "35e38cc81794dcdd4b4d7ef07efda2d6736080aa", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/verification_test.go", - "target": "edge/shared/storage/verification/verification_test.go", + "target": "product/verification/store/verification_test.go", "blob": "d0eaab49114e1c7c9a3fa0d609cb695566585a22", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/verification_test_helpers_test.go", - "target": "edge/shared/storage/verification/verification_test_helpers_test.go", + "target": "product/verification/store/verification_test_helpers_test.go", "blob": "83f5a63f4f057befd01c83b8e667953ba3199a0f", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/ratelimits/db.go", - "target": "edge/shared/storage/ratelimits/db.go", + "target": "shared/ratelimit/store/db.go", "blob": "6d5d118198d62f545f0ef10216466c4cbec9b16d", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/mongo/dependencies.go", - "target": "edge/shared/storage/mongo/dependencies.go", + "target": "shared/database/dependencies/dependencies.go", "blob": "4aa6a89fb359a305ddc74d6e4e9bdcdb50cadb49", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/server/server.go", - "target": "edge/shared/server/server.go", + "target": "product/app/server/server.go", "blob": "186175bab73d2f2368f45fb5ad16854afec81a68", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/server/server.go", - "target": "edge/shared/internal/server/server.go", + "target": "product/app/server/internal/server.go", "blob": "e6a780272db88437ce4928cb5cb07610754628ce", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/server/server_test.go", - "target": "edge/shared/internal/server/server_test.go", + "target": "product/app/server/internal/server_test.go", "blob": "fa287130eb6ebb0e572a68470baae58411efe0bc", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/api.go", - "target": "edge/shared/interface/api/api.go", + "target": "product/app/api/api.go", "blob": "7eca87432217088eef1639f47c513c1bd0341cf0", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/auth_config.go", - "target": "edge/shared/interface/api/auth_config.go", + "target": "product/auth/config/auth_config.go", "blob": "e155092ea9646e7320e1dc3f66b8ae2bc2081409", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/config.go", - "target": "edge/shared/interface/api/config.go", + "target": "product/app/api/config.go", "blob": "1ea1e746f6bd9d28494e9b26d9f97bbdda1f000b", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/register.go", - "target": "edge/shared/interface/api/register.go", + "target": "product/app/api/register.go", "blob": "756ca1e6e3c5d5f0f91184a5013cfb2993990851", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/runtime.go", - "target": "edge/shared/interface/api/runtime.go", + "target": "product/app/api/runtime.go", "blob": "8ab8dc821bcdf1f5d6dfa6183990d30dcbdb63df", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/verification_rate_limit.go", - "target": "edge/shared/interface/api/verification_rate_limit.go", + "target": "product/verification/config/verification_rate_limit.go", "blob": "c1186157f843fe780f6cb54c6c5cf48b1962164b", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/verification_rate_limit_test.go", - "target": "edge/shared/interface/api/verification_rate_limit_test.go", + "target": "product/verification/config/verification_rate_limit_test.go", "blob": "dd85c48ab87e5556f25bb73a0b120f21031dbe4a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/methods.go", - "target": "pkg/api/http/methods.go", + "target": "shared/http/methods.go", "blob": "ec73a29c5e7f2d3ade83667ed666a9c15fe6d6e5", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/http/status.go", - "target": "pkg/api/http/status.go", + "target": "shared/http/status.go", "blob": "76f0b7a6e861e1519e7ee018564e5bfadf4be4f6", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/http/response/response.go", - "target": "pkg/api/http/response/response.go", + "target": "shared/http/response/response.go", "blob": "8904d881b718a9ec20d3c4644717118760d8d27e", "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/response_test.go", - "target": "pkg/api/http/response/response_test.go", + "target": "shared/http/response/response_test.go", "blob": "592cbefaeec2bac5bef8d10223e156b6da150a98", "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/result.go", - "target": "pkg/api/http/response/result.go", + "target": "shared/http/response/result.go", "blob": "aa65a480fbf69e3249bb3848f0bc57206c5561bd", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/sresponse/account.go", - "target": "edge/shared/interface/api/sresponse/account.go", + "target": "product/auth/response/account.go", "blob": "0369f802304ab0b19a57349939ac8a73e924322a", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/authresp.go", - "target": "edge/shared/interface/api/sresponse/authresp.go", + "target": "product/auth/response/authresp.go", "blob": "c148e9be39be6ecfdae127f8d9980360f0114051", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/sresponse/file.go", - "target": "edge/shared/interface/api/sresponse/file.go", + "target": "product/auth/response/file.go", "blob": "041d7b5d6d56dc059d54bc648b47fe7ba7151f35", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/sresponse/login.go", - "target": "edge/shared/interface/api/sresponse/login.go", + "target": "product/auth/response/login.go", "blob": "962b9ca0852da034c70b9d4174f497b887267ac8", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_pending.go", - "target": "edge/shared/interface/api/sresponse/login_pending.go", + "target": "product/auth/response/login_pending.go", "blob": "5bcd839cb379c44c4a95d897af4a055b6b9375fc", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_test.go", - "target": "edge/shared/interface/api/sresponse/login_test.go", + "target": "product/auth/response/login_test.go", "blob": "419995b9a91ebe8db544c7224be6e945de0c5e55", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/response.go", - "target": "edge/shared/interface/api/sresponse/response.go", + "target": "product/auth/response/response.go", "blob": "094ec49a7c7ca98f98be42d1946651f666a8287c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/result.go", - "target": "edge/shared/interface/api/sresponse/result.go", + "target": "product/auth/response/result.go", "blob": "64671a5ba2839e8a193a5c199a988401017b78c3", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/sresponse/token.go", - "target": "edge/shared/interface/api/sresponse/token.go", + "target": "product/auth/response/token.go", "blob": "3bbf9df40d9b0e9f7771b851e66d3afe3b71fdb7", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/sresponse/verification.go", - "target": "edge/shared/interface/api/sresponse/verification.go", + "target": "product/auth/response/verification.go", "blob": "bdd15716ff662433625fde76cb27c342509c4a3f", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/model/token.go", - "target": "edge/shared/interface/model/token.go", + "target": "product/auth/model/token.go", "blob": "8f65763eb1924a203417e5cfd85118e039b04502", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/model/token_test.go", - "target": "edge/shared/interface/model/token_test.go", + "target": "product/auth/model/token_test.go", "blob": "9506c15cf24420eb7ca490a5dac5216e43c4e2a8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/duration/duration.go", - "target": "pkg/mutil/duration/duration.go", + "target": "shared/duration/duration.go", "blob": "c27a30425d5988831fcce76a3f13c3647ea0c4e5", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/auth/browser/browser.go", - "target": "edge/shared/auth/browser/browser.go", + "target": "product/sessions/browser/browser.go", "blob": "3af7b54aea277d91e0d8c3eba9ee84c155f4b624", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/auth/browser/browser_test.go", - "target": "edge/shared/auth/browser/browser_test.go", + "target": "product/sessions/browser/browser_test.go", "blob": "d376b2879803f69acd3d52348d259f5ac3629b1b", "adapted_beyond_namespace": false }, { "source": "api/pkg/domainprovider/domain_provider.go", - "target": "pkg/domainprovider/domain_provider.go", + "target": "product/app/urls/domain_provider.go", "blob": "353aac0bf37609cfd0471ce43484ff15baac21f2", "adapted_beyond_namespace": false }, { "source": "api/pkg/domainprovider/imp/domain_provider.go", - "target": "pkg/domainprovider/imp/domain_provider.go", + "target": "product/app/urls/internal/domain_provider.go", "blob": "20d7353be9aa53c9a372fe912af6dca6e78cd709", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/runtime/api.go", - "target": "edge/shared/internal/runtime/api.go", + "target": "product/app/runtime/api.go", "blob": "081cef6ae3887dc2990c5b8d1d10a670893191c6", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/runtime/bootstrap.go", - "target": "edge/shared/internal/runtime/bootstrap.go", + "target": "product/app/runtime/bootstrap.go", "blob": "38108cc6b2f3257e32473877ce3e66fa4be2370c", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/runtime/lifecycle.go", - "target": "edge/shared/internal/runtime/lifecycle.go", + "target": "product/app/runtime/lifecycle.go", "blob": "367e277a21360a21df742c31cb1f5a7712754938", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/runtime/services.go", - "target": "edge/shared/internal/runtime/services.go", + "target": "product/app/runtime/services.go", "blob": "d3fdec1c394925f4790902fa9c18177b713fb66e", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/routers/health/status.go", - "target": "pkg/api/routers/health/status.go", + "target": "product/app/health/status/status.go", "blob": "9768bea4154c61d18712c052730f531c74fc6d53", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/routers/health.go", - "target": "pkg/api/routers/health.go", + "target": "product/app/health/health.go", "blob": "2c8ab2f2f973572f56a5865e849b4226da6ef86b", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/routers/internal/healthimp/health.go", - "target": "pkg/api/routers/internal/healthimp/health.go", + "target": "product/app/health/internal/health.go", "blob": "3b641ce70d370c65809262252847cb1d104fc3bc", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/routers/internal/healthimp/health_test.go", - "target": "pkg/api/routers/internal/healthimp/health_test.go", + "target": "product/app/health/internal/health_test.go", "blob": "5fe00ffd51338f96bf1fbdd9d18c53996f3822ac", "adapted_beyond_namespace": false }, { "source": "api/pkg/api/routers/internal/healthimp/status.go", - "target": "pkg/api/routers/internal/healthimp/status.go", + "target": "product/app/health/internal/status.go", "blob": "af7f1bc54bc3449afdc2a36f61663c9234413f32", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/middleware.go", - "target": "edge/shared/internal/transport/http/middleware.go", + "target": "product/app/http/middleware.go", "blob": "3a4c3f910fe3cbeb3bf6566afa8833dd41b6ad0f", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/middleware_test.go", - "target": "edge/shared/internal/transport/http/middleware_test.go", + "target": "product/app/http/middleware_test.go", "blob": "7359d959b4c29ef95312fb49056351d408fc9957", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/metrics/handler.go", - "target": "edge/shared/internal/transport/http/router/metrics/handler.go", + "target": "product/app/metrics/handler.go", "blob": "fd2e2de1e59c1510ca43b394fa6697497f83b9c9", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/metrics/router.go", - "target": "edge/shared/internal/transport/http/router/metrics/router.go", + "target": "product/app/metrics/router.go", "blob": "8690f1e8f9b39b3957433dd61e887bbd9bf90ef9", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/dispatcher.go", - "target": "edge/shared/internal/transport/http/router/dispatcher.go", + "target": "product/auth/http/dispatcher.go", "blob": "f173a53bab40b61dead720e92fe1cd27f3e5b5a5", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/router.go", - "target": "edge/shared/internal/transport/http/router/router.go", + "target": "product/auth/http/router.go", "blob": "d4e68b80e4b10c8aaf3e10e8e6c163f92595c0de", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/refreshtokens/client.go", - "target": "edge/shared/storage/refreshtokens/client.go", + "target": "product/sessions/store/client.go", "blob": "7289529bdccc68c5678275638e818ebc8a7999e1", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/crud.go", - "target": "edge/shared/storage/refreshtokens/crud.go", + "target": "product/sessions/store/crud.go", "blob": "5f8044ca8c7cd30d107f2272042e4b7b1cfea291", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/crud_test.go", - "target": "edge/shared/storage/refreshtokens/crud_test.go", + "target": "product/sessions/store/crud_test.go", "blob": "385710f18897996b677b0b193c5407f3b78da27d", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/db.go", - "target": "edge/shared/storage/refreshtokens/db.go", + "target": "product/sessions/store/db.go", "blob": "2b2ac2504bac1f239e17105b940b375baa17e3f3", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/fields.go", - "target": "edge/shared/storage/refreshtokens/fields.go", + "target": "product/sessions/store/fields.go", "blob": "ff7ce10d5ed25882a1fc2bf6e964410fa8e4f995", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/refreshtokens/filters.go", - "target": "edge/shared/storage/refreshtokens/filters.go", + "target": "product/sessions/store/filters.go", "blob": "4b51d1c8ee793fdad660d532aad3bccf3c18a167", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens.go", + "target": "product/sessions/store/refreshtokens.go", "blob": "3d335877d4269ef51496f7c139fcc0dd7e7f8dd4", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go", + "target": "product/sessions/store/refreshtokens_indexes_test.go", "blob": "d10a740f65ae64adde26d1438d1c2c17bbeaae0f", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens_misc_test.go", + "target": "product/sessions/store/refreshtokens_misc_test.go", "blob": "12ecda38d22b7143910d57889ea8663d9e731958", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go", + "target": "product/sessions/store/refreshtokens_session_replacement_test.go", "blob": "270025162d0902052771251f78f20ddb7f152e5e", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens_session_test.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens_session_test.go", + "target": "product/sessions/store/refreshtokens_session_test.go", "blob": "15dbe4ab226ffcc5b287bdb6d74cc608360195cc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/refreshtokens_test.go", - "target": "edge/shared/storage/refreshtokens/refreshtokens_test.go", + "target": "product/sessions/store/refreshtokens_test.go", "blob": "e6c398c7db0f603a964eef39160e79e5f9fe2bde", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/revoke.go", - "target": "edge/shared/storage/refreshtokens/revoke.go", + "target": "product/sessions/store/revoke.go", "blob": "39511ee714bcdb9e9885522517735e596c43dd8a", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/public/login.go", - "target": "edge/shared/internal/transport/http/router/public/login.go", + "target": "product/auth/http/public/login.go", "blob": "92666648e8df7c93fc7ac28a4d0e413f0a370499", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit.go", - "target": "edge/shared/internal/transport/http/router/public/login_rate_limit.go", + "target": "product/auth/http/public/login_rate_limit.go", "blob": "2122e383a68d56a81aa9ff165dd7661c4bf8068b", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go", - "target": "edge/shared/internal/transport/http/router/public/login_rate_limit_test.go", + "target": "product/auth/http/public/login_rate_limit_test.go", "blob": "06d0cf195efa36290c17dd148d6bc2551011664a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_test.go", - "target": "edge/shared/internal/transport/http/router/public/login_test.go", + "target": "product/auth/http/public/login_test.go", "blob": "0fabfa678c8f7df25c7ae744a99ec680668ca899", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_test_support_test.go", - "target": "edge/shared/internal/transport/http/router/public/login_test_support_test.go", + "target": "product/auth/http/public/login_test_support_test.go", "blob": "7578548953e01a7cf33e265e4202e6c862ad6bb6", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/logout.go", - "target": "edge/shared/internal/transport/http/router/public/logout.go", + "target": "product/auth/http/public/logout.go", "blob": "0d6d0f1fa27af82c805c28b11d0dcd90462ba34e", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/public/logout_test.go", - "target": "edge/shared/internal/transport/http/router/public/logout_test.go", + "target": "product/auth/http/public/logout_test.go", "blob": "c769afa87c0ffc12213335fe64b98576ff979457", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/refresh.go", - "target": "edge/shared/internal/transport/http/router/public/refresh.go", + "target": "product/auth/http/public/refresh.go", "blob": "88f77303ad8386a42967077b8fbb716de325b4b0", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/public/refresh_test.go", - "target": "edge/shared/internal/transport/http/router/public/refresh_test.go", + "target": "product/auth/http/public/refresh_test.go", "blob": "4f7b9f98a1705414fd1e312629d8ea498c80da64", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/respond.go", - "target": "edge/shared/internal/transport/http/router/public/respond.go", + "target": "product/auth/http/public/respond.go", "blob": "45b8841a9efff51cd8bc6910dcc4f2de8d640d86", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/rotate.go", - "target": "edge/shared/internal/transport/http/router/public/rotate.go", + "target": "product/auth/http/public/rotate.go", "blob": "6c052ba5475fce58cbdc0b7827094613d63817a4", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/public/router.go", - "target": "edge/shared/internal/transport/http/router/public/router.go", + "target": "product/auth/http/public/router.go", "blob": "6aacfa1fc8b4874011f90bcd730c16db39933a42", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/public/validate.go", - "target": "edge/shared/internal/transport/http/router/public/validate.go", + "target": "product/auth/http/public/validate.go", "blob": "5a5ea587e1a62eb194e68996e841d3a20cf97fce", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/login.go", - "target": "edge/shared/interface/api/srequest/login.go", + "target": "product/auth/request/login.go", "blob": "c1f016d26b2cbacfd4352a8a1a2a8d655a0773e5", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/refresh.go", - "target": "edge/shared/interface/api/srequest/refresh.go", + "target": "product/auth/request/refresh.go", "blob": "f19edff7cc9dd591d09bfd852e4e6137e86daf30", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/rotate.go", - "target": "edge/shared/interface/api/srequest/rotate.go", + "target": "product/auth/request/rotate.go", "blob": "6315c790396b7201f0b70748087326e6079921c1", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/validateable.go", - "target": "edge/shared/interface/api/srequest/validateable.go", + "target": "product/auth/request/validateable.go", "blob": "b4a5c12f33b1d73690a04732b1562480f544e40a", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/interface/api/srequest/verification.go", - "target": "edge/shared/interface/api/srequest/verification.go", + "target": "product/auth/request/verification.go", "blob": "b3e6cbc77dc6e09feda045477a2f4f750ed37411", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/endpoint/endpoint.go", - "target": "edge/shared/internal/transport/http/router/endpoint/endpoint.go", + "target": "product/auth/http/endpoint/endpoint.go", "blob": "f0254292b49f7e635b231a6c70098c8dcd8a86d8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/endpoint/install.go", - "target": "edge/shared/internal/transport/http/router/endpoint/install.go", + "target": "product/auth/http/endpoint/install.go", "blob": "e7c1c5dd50b393642f593072ad3484d3c220fee9", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/internal/transport/http/router/endpoint/token.go", - "target": "edge/shared/internal/transport/http/router/endpoint/token.go", + "target": "product/auth/http/endpoint/token.go", "blob": "332924534a7b5fca9a142eaaa08f01098c529905", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/auth/tokens/prepare_test.go", - "target": "edge/shared/auth/tokens/prepare_test.go", + "target": "product/sessions/tokens/prepare_test.go", "blob": "6e8892c9492c4dbc781fc6fb76e3e5aa27158f7a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/auth/tokens/tokens.go", - "target": "edge/shared/auth/tokens/tokens.go", + "target": "product/sessions/tokens/tokens.go", "blob": "233d14be95e9560697709d03c8c3ec65f381a0aa", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/http/ipguard/ipguard.go", - "target": "edge/shared/http/ipguard/ipguard.go", + "target": "product/auth/ipguard/ipguard.go", "blob": "6a4221d73346135468bba15bdfe8194e70b6d0be", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/http/ipguard/ipguard_test.go", - "target": "edge/shared/http/ipguard/ipguard_test.go", + "target": "product/auth/ipguard/ipguard_test.go", "blob": "a2b7f31d4803844eead0a5fb517ef40e994f33c1", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/auth/sessionmetrics/metrics.go", - "target": "edge/shared/auth/sessionmetrics/metrics.go", + "target": "product/sessions/metrics/metrics.go", "blob": "8b7917de210337c696af4eedc8eaec05597cff96", "adapted_beyond_namespace": false }, { "source": "api/edge/shared/auth/client/client.go", - "target": "edge/shared/auth/client/client.go", + "target": "product/auth/client/client.go", "blob": "4424ce799dda3c4945fbeeaa79cbc21c1d7afefe", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/auth/client/client_test.go", - "target": "edge/shared/auth/client/client_test.go", + "target": "product/auth/client/client_test.go", "blob": "1a429187940a658e9cc63c830281f973fa93d084", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/handler.go", - "target": "edge/shared/internal/transport/http/router/authorized/handler.go", + "target": "product/auth/http/authorized/handler.go", "blob": "bb2fcde2e850dd687e78cad5f4dc95f9b9417ac6", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/router.go", - "target": "edge/shared/internal/transport/http/router/authorized/router.go", + "target": "product/auth/http/authorized/router.go", "blob": "4347dd32db99ae4e46ded03bdff2e64a3a049515", "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/session_test.go", - "target": "edge/shared/internal/transport/http/router/authorized/session_test.go", + "target": "product/auth/http/authorized/session_test.go", "blob": "e81e9f40101bf43f55a5772215557802e15e9b1a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/auth/storage/stores.go", - "target": "edge/shared/auth/storage/stores.go", + "target": "product/auth/storage/stores.go", "blob": "7c71f8e9a5216b40865388eb4a10900c0e9b4435", - "adapted_beyond_namespace": false - }, - { - "source": "docs/openapi/api.yaml", - "target": "contracts/sendico/api.yaml", - "blob": "c37aeba487b3eef2bedf5829a89fd3b666fe7944" - }, - { - "source": "docs/openapi/api/ledger_accounts/operation.yaml", - "target": "contracts/sendico/api/ledger_accounts/operation.yaml", - "blob": "06fe2c531824155fbb6ccc2a6bd13279c63daf06" - }, - { - "source": "docs/openapi/api/response/operation.yaml", - "target": "contracts/sendico/api/response/operation.yaml", - "blob": "08c5a5f57094642d9093f8ede394d06b08f03852" - }, - { - "source": "docs/openapi/api/response/response.yaml", - "target": "contracts/sendico/api/response/response.yaml", - "blob": "89589e58af97d3c02b21ec6fab61bc692b9c2bc6" - }, - { - "source": "docs/openapi/api/response/error.yaml", - "target": "contracts/sendico/api/response/error.yaml", - "blob": "c4a70141e44ad8040844d5fa35959b718cf99cce" - }, - { - "source": "docs/openapi/api/response/status.yaml", - "target": "contracts/sendico/api/response/status.yaml", - "blob": "1a7e12360568806a679bf2b7c1457d4000dbacc4" - }, - { - "source": "docs/openapi/api/ledger_accounts/response/operation.yaml", - "target": "contracts/sendico/api/ledger_accounts/response/operation.yaml", - "blob": "44929324586f15c6a1af87e9d6776aa406f2613d" - }, - { - "source": "docs/openapi/models/ledger/operation.yaml", - "target": "contracts/sendico/models/ledger/operation.yaml", - "blob": "60cb2bbc8e398268bec3c28619421d88cdbede41" - }, - { - "source": "docs/openapi/models/objectid.yaml", - "target": "contracts/sendico/models/objectid.yaml", - "blob": "01f1c3ddf61d0364983261ba106dc1b22da1125f" - }, - { - "source": "docs/openapi/models/common/money.yaml", - "target": "contracts/sendico/models/common/money.yaml", - "blob": "b130b4c94cc5789c9721092eb17f8bc9d42f99e1" - }, - { - "source": "docs/openapi/models/auth/token_data.yaml", - "target": "contracts/sendico/models/auth/token_data.yaml", - "blob": "5e6264d6ba053516a57298bb88ef1dd90a272b55" - }, - { - "source": "docs/openapi/api/parameters/entry_ref.yaml", - "target": "contracts/sendico/api/parameters/entry_ref.yaml", - "blob": "d035050889fc20d67f812315ad8e0493703d2d38" - }, - { - "source": "docs/openapi/api/parameters/ledger_account_ref.yaml", - "target": "contracts/sendico/api/parameters/ledger_account_ref.yaml", - "blob": "ac2315052ba8c2013860966a50e46c9aa9f81571" - }, - { - "source": "docs/openapi/api/parameters/organizations_ref.yaml", - "target": "contracts/sendico/api/parameters/organizations_ref.yaml", - "blob": "668d32230b1faf86826a3f46675c6e809781fd2f" - }, - { - "source": "docs/openapi/api/ledger_accounts/operations.yaml", - "target": "contracts/sendico/api/ledger_accounts/operations.yaml", - "blob": "2910a7ba27a90f23d613934163170f13a7a4dc28" - }, - { - "source": "docs/openapi/api/parameters/offset.yaml", - "target": "contracts/sendico/api/parameters/offset.yaml", - "blob": "03b8b7cbf07b533f578f62e5feba678c1fb47658" - }, - { - "source": "docs/openapi/api/parameters/limit.yaml", - "target": "contracts/sendico/api/parameters/limit.yaml", - "blob": "71fa974fe385c691c0ac7113f4bf2dd3f959046b" - }, - { - "source": "docs/openapi/api/ledger_accounts/balance.yaml", - "target": "contracts/sendico/api/ledger_accounts/balance.yaml", - "blob": "75156517b30344caf969e90b830cb886812c0bd3" - }, - { - "source": "docs/openapi/api/ledger_accounts/response/balance.yaml", - "target": "contracts/sendico/api/ledger_accounts/response/balance.yaml", - "blob": "254864b08634dbd503823682cc9df51c89a53a0d" - }, - { - "source": "docs/openapi/models/ledger/balance.yaml", - "target": "contracts/sendico/models/ledger/balance.yaml", - "blob": "df4423b667c6c3e053a781ebbeaaf803b179d3e9" - }, - { - "source": "docs/openapi/api/wallets/assets.yaml", - "target": "contracts/sendico/api/wallets/assets.yaml", - "blob": "17c8b22aae5226042eca93f9d0a4a7ff25859fec" - }, - { - "source": "docs/openapi/api/wallets/response/wallet.yaml", - "target": "contracts/sendico/api/wallets/response/wallet.yaml", - "blob": "b390b85084e3129714e10c37db9e5db6ddd41222" - }, - { - "source": "docs/openapi/models/wallet/wallet_asset.yaml", - "target": "contracts/sendico/models/wallet/wallet_asset.yaml", - "blob": "6d1a1a6686ea06d52717a7d9551a358a12eb4a89" - }, - { - "source": "docs/openapi/external/chain_network.yaml", - "target": "contracts/sendico/external/chain_network.yaml", - "blob": "c5770115083e47b26e2119f0ac301dcf88e40be2" - }, - { - "source": "docs/openapi/api/payments/fx_directions.yaml", - "target": "contracts/sendico/api/payments/fx_directions.yaml", - "blob": "264f35dc443b35f28587892a673519119c580ea3" - }, - { - "source": "docs/openapi/api/payments/response/payment.yaml", - "target": "contracts/sendico/api/payments/response/payment.yaml", - "blob": "d4c2938f87dc8ce39a8575a05f7912242679faee" - }, - { - "source": "docs/openapi/models/payment/fx_metadata.yaml", - "target": "contracts/sendico/models/payment/fx_metadata.yaml", - "blob": "857a2a1d84f41534c563d9090998ffe00b9a5c68" - }, - { - "source": "docs/openapi/external/fx_side.yaml", - "target": "contracts/sendico/external/fx_side.yaml", - "blob": "639e6f28c673cdee0f2e6405bf6304603a33d23a" - }, - { - "source": "docs/openapi/models/payment/payment.yaml", - "target": "contracts/sendico/models/payment/payment.yaml", - "blob": "e970feb77567efb87e18b20bfc4607b128032287" - }, - { - "source": "docs/openapi/external/payment_state.yaml", - "target": "contracts/sendico/external/payment_state.yaml", - "blob": "9685cd8ecb25a0ac700d0baa009a4ce427063957" - }, - { - "source": "docs/openapi/external/settlement_mode.yaml", - "target": "contracts/sendico/external/settlement_mode.yaml", - "blob": "e5531c92b60b923ada5893670a20a0fc3c98038a" - }, - { - "source": "docs/openapi/external/payment_kind.yaml", - "target": "contracts/sendico/external/payment_kind.yaml", - "blob": "06f68ef2f705b59d01dc6cfee55039a550693d9f" - }, - { - "source": "docs/openapi/external/endpoint_type.yaml", - "target": "contracts/sendico/external/endpoint_type.yaml", - "blob": "3a23e427a02288ad1cde4ec94e150b36bb3bb32b" - }, - { - "source": "docs/openapi/models/common/pagination.yaml", - "target": "contracts/sendico/models/common/pagination.yaml", - "blob": "2a923c3317d227c27c337635c93edc2818b0b8df" - }, - { - "source": "docs/openapi/api/payments/fx_currencies.yaml", - "target": "contracts/sendico/api/payments/fx_currencies.yaml", - "blob": "f2ecf5a89513a81458fb658295d9dc2fd7a9bf05" - }, - { - "source": "docs/openapi/api/payments/registry_refresh.yaml", - "target": "contracts/sendico/api/payments/registry_refresh.yaml", - "blob": "4719af7000f06f8012ce3ded03026b8d6d7391d2" - }, - { - "source": "docs/openapi/api/payments/registry.yaml", - "target": "contracts/sendico/api/payments/registry.yaml", - "blob": "42d796d7dea7482f1739302724292e3c7f6d3c5c" - }, - { - "source": "docs/openapi/api/payments/documents_certificate.yaml", - "target": "contracts/sendico/api/payments/documents_certificate.yaml", - "blob": "e7a33787537ef0762eb33f8e9d6065d93ce5dc93" - }, - { - "source": "docs/openapi/api/payments/list.yaml", - "target": "contracts/sendico/api/payments/list.yaml", - "blob": "e01a5c9170b14478cb8cfde8547ddc893646b425" - }, - { - "source": "docs/openapi/api/parameters/filter_states.yaml", - "target": "contracts/sendico/api/parameters/filter_states.yaml", - "blob": "1c1d34fd3ca5de628935c57a3c0db98a356ee07a" - }, - { - "source": "docs/openapi/api/parameters/states.yaml", - "target": "contracts/sendico/api/parameters/states.yaml", - "blob": "96d672d3e56877b8d3f107daaa181f48a792da25" - }, - { - "source": "docs/openapi/api/parameters/state.yaml", - "target": "contracts/sendico/api/parameters/state.yaml", - "blob": "cb05776de5b2325066e3751c9ed3b6ad94984251" - }, - { - "source": "docs/openapi/api/parameters/cursor.yaml", - "target": "contracts/sendico/api/parameters/cursor.yaml", - "blob": "15252a1a96874d58a5fe25eac85dfe537bcf31ec" - }, - { - "source": "docs/openapi/api/payments/object.yaml", - "target": "contracts/sendico/api/payments/object.yaml", - "blob": "0b717523ee8cfa4f4ed9ef77b58ba8c2f4b75f07" - }, - { - "source": "docs/openapi/api/parameters/payments_ref.yaml", - "target": "contracts/sendico/api/parameters/payments_ref.yaml", - "blob": "28a015bff27618e53d0dfb90bcaccd79337fcce8" - }, - { - "source": "docs/openapi/api/payments/by_multiquote.yaml", - "target": "contracts/sendico/api/payments/by_multiquote.yaml", - "blob": "2c41a746d935a191eda71c597b15c633a4ea3c76" - }, - { - "source": "docs/openapi/api/payments/bodies/payment.yaml", - "target": "contracts/sendico/api/payments/bodies/payment.yaml", - "blob": "4dc0349c2c62a140b575245289bd318b9755fcd8" - }, - { - "source": "docs/openapi/api/payments/request/payment.yaml", - "target": "contracts/sendico/api/payments/request/payment.yaml", - "blob": "3c425e3b458975922c1b0efa421c6ba77fdfa75d" - }, - { - "source": "docs/openapi/api/payments/by_quote.yaml", - "target": "contracts/sendico/api/payments/by_quote.yaml", - "blob": "703a98895e13fa79066326e4ebba06bb054711ec" - }, - { - "source": "docs/openapi/api/payments/immediate.yaml", - "target": "contracts/sendico/api/payments/immediate.yaml", - "blob": "2a7b885724484cb99cef21a7ca588392b045d244" - }, - { - "source": "docs/openapi/api/payments/multiquote.yaml", - "target": "contracts/sendico/api/payments/multiquote.yaml", - "blob": "3ac6728ac3d3d2c8ca38cc255eb5f26670af23f5" - }, - { - "source": "docs/openapi/api/payments/quote.yaml", - "target": "contracts/sendico/api/payments/quote.yaml", - "blob": "d40955416dfcf1b06d0277a6edaa31520cad3548" - }, - { - "source": "docs/openapi/api/callbacks/rotate_secret.yaml", - "target": "contracts/sendico/api/callbacks/rotate_secret.yaml", - "blob": "6296d25d6e621c522b5160ca88bd6de599f8da98" - }, - { - "source": "docs/openapi/api/callbacks/response/callback.yaml", - "target": "contracts/sendico/api/callbacks/response/callback.yaml", - "blob": "02e262ba392578a54190f9b6b4195d5af0513a85" - }, - { - "source": "docs/openapi/models/callback/callback.yaml", - "target": "contracts/sendico/models/callback/callback.yaml", - "blob": "f555a058f230548d1b7db7f518ae76fba86e580d" - }, - { - "source": "docs/openapi/models/common/describable.yaml", - "target": "contracts/sendico/models/common/describable.yaml", - "blob": "1bf6d5b7a7122e8253175e686c06893c2296598a" - }, - { - "source": "docs/openapi/models/permission_bound.yaml", - "target": "contracts/sendico/models/permission_bound.yaml", - "blob": "c574a13346031788c0b4f05a242f48ada59636a7" - }, - { - "source": "docs/openapi/models/storable.yaml", - "target": "contracts/sendico/models/storable.yaml", - "blob": "325b291481fea9a4c4e3f7c2b39a24156881fc99" - }, - { - "source": "docs/openapi/api/parameters/callbacks_ref.yaml", - "target": "contracts/sendico/api/parameters/callbacks_ref.yaml", - "blob": "18ba8143d1ac91846c3378d51eab5fa3dd703027", - "adaptation": "Trailing blank lines normalized; schema unchanged." - }, - { - "source": "docs/openapi/api/callbacks/archive.yaml", - "target": "contracts/sendico/api/callbacks/archive.yaml", - "blob": "38d8b53ef33cf61aee5b4cd83c6e6910d98ba6dd", - "adaptation": "Trailing blank lines normalized; schema unchanged." - }, - { - "source": "docs/openapi/api/parameters/cascade.yaml", - "target": "contracts/sendico/api/parameters/cascade.yaml", - "blob": "4077d851c091da108603209a8427a836c3617ad6" - }, - { - "source": "docs/openapi/api/parameters/org_ref.yaml", - "target": "contracts/sendico/api/parameters/org_ref.yaml", - "blob": "3ef0c64a6e6f959c0a1bad51317531b1a0595b26" - }, - { - "source": "docs/openapi/api/callbacks/update.yaml", - "target": "contracts/sendico/api/callbacks/update.yaml", - "blob": "e3f99320074f081c26ab930a077319ae3d47e160" - }, - { - "source": "docs/openapi/api/callbacks/bodies/callback.yaml", - "target": "contracts/sendico/api/callbacks/bodies/callback.yaml", - "blob": "850efd327b75c821ab07894cb545658b3bbd60af" - }, - { - "source": "docs/openapi/api/callbacks/request/callback.yaml", - "target": "contracts/sendico/api/callbacks/request/callback.yaml", - "blob": "a0535c32c7ded53d36c4e57cfe45e4d560d9176e" - }, - { - "source": "docs/openapi/api/callbacks/object.yaml", - "target": "contracts/sendico/api/callbacks/object.yaml", - "blob": "914b00db31432a82e57e512554882cc21647aa28", - "adaptation": "Trailing blank lines normalized; schema unchanged." - }, - { - "source": "docs/openapi/api/callbacks/create.yaml", - "target": "contracts/sendico/api/callbacks/create.yaml", - "blob": "92c5c32552ea7471a3dd19e38be1a496644ab33f" - }, - { - "source": "docs/openapi/api/callbacks/list.yaml", - "target": "contracts/sendico/api/callbacks/list.yaml", - "blob": "0072f453ea152f275f7c84ce88989206b93480ca" - }, - { - "source": "docs/openapi/api/parameters/archived.yaml", - "target": "contracts/sendico/api/parameters/archived.yaml", - "blob": "fb8af8ca3f0ed5fbdf0df29a67c8d4e95e9ea2f4" - }, - { - "source": "docs/openapi/api/payment_methods/archive.yaml", - "target": "contracts/sendico/api/payment_methods/archive.yaml", - "blob": "55772206b43a4f653dab9184ef295c5d62542747" - }, - { - "source": "docs/openapi/api/payment_methods/response/payment_method.yaml", - "target": "contracts/sendico/api/payment_methods/response/payment_method.yaml", - "blob": "40cb7aaa4a8b76d91e3e50b78f448ab8edae95fc" - }, - { - "source": "docs/openapi/models/payment_method/payment_method.yaml", - "target": "contracts/sendico/models/payment_method/payment_method.yaml", - "blob": "7b475f009573c91d23c2eb1c052f0c598d2b83fb" - }, - { - "source": "docs/openapi/external/payment_method_type.yaml", - "target": "contracts/sendico/external/payment_method_type.yaml", - "blob": "c8c4e84b005b24df1e83f3f985e041c8f88b9b70" - }, - { - "source": "docs/openapi/api/parameters/payment_methods_ref.yaml", - "target": "contracts/sendico/api/parameters/payment_methods_ref.yaml", - "blob": "6690d53707229be9a3c94adcd924038901a8364c" - }, - { - "source": "docs/openapi/api/payment_methods/object.yaml", - "target": "contracts/sendico/api/payment_methods/object.yaml", - "blob": "a183d571cb4da960a5959429a7c95104d0cbf650" - }, - { - "source": "docs/openapi/api/payment_methods/bodies/payment_method.yaml", - "target": "contracts/sendico/api/payment_methods/bodies/payment_method.yaml", - "blob": "56d488e05949965ffaf5a4d3b4d7c615da683b63" - }, - { - "source": "docs/openapi/api/payment_methods/request/payment_method.yaml", - "target": "contracts/sendico/api/payment_methods/request/payment_method.yaml", - "blob": "6f768e7f08cbed79e86b1dd3f750080ca65dfb8c" - }, - { - "source": "docs/openapi/api/payment_methods/recipients.yaml", - "target": "contracts/sendico/api/payment_methods/recipients.yaml", - "blob": "414ab3fb957e4a3e314331d61692d2e89fcd656b" - }, - { - "source": "docs/openapi/api/parameters/recipients_ref.yaml", - "target": "contracts/sendico/api/parameters/recipients_ref.yaml", - "blob": "945bca14a5caaf09b768ea3688588ec899772d19" - }, - { - "source": "docs/openapi/api/payment_methods/accounts.yaml", - "target": "contracts/sendico/api/payment_methods/accounts.yaml", - "blob": "9c7fc85d69cda0e8956155396dc8b2669588a3c0" - }, - { - "source": "docs/openapi/api/parameters/accounts_ref.yaml", - "target": "contracts/sendico/api/parameters/accounts_ref.yaml", - "blob": "a72a17d1144b1095947280a87e53c5a1b0c03788" - }, - { - "source": "docs/openapi/api/recipients/archive.yaml", - "target": "contracts/sendico/api/recipients/archive.yaml", - "blob": "4d3cb3f37639ea2a9dad5fa3c63b509fcea4edef" - }, - { - "source": "docs/openapi/api/recipients/response/recipient.yaml", - "target": "contracts/sendico/api/recipients/response/recipient.yaml", - "blob": "337b850bd9b2cdd4525326cc6ba3df8309b1fef6" - }, - { - "source": "docs/openapi/models/recipient/recipient.yaml", - "target": "contracts/sendico/models/recipient/recipient.yaml", - "blob": "5ab3d56462523f98b69230836c744437d347dccd" - }, - { - "source": "docs/openapi/api/recipients/update.yaml", - "target": "contracts/sendico/api/recipients/update.yaml", - "blob": "f814f5b2fd485b8d12b6a789780a15183a13fc78" - }, - { - "source": "docs/openapi/api/recipients/bodies/recipient.yaml", - "target": "contracts/sendico/api/recipients/bodies/recipient.yaml", - "blob": "5785002594afd5d7231a268fb4c294016c41492d" - }, - { - "source": "docs/openapi/api/recipients/request/recipient.yaml", - "target": "contracts/sendico/api/recipients/request/recipient.yaml", - "blob": "19656c44ff18c69551275b8c26722b1f2b34a181" - }, - { - "source": "docs/openapi/api/recipients/object.yaml", - "target": "contracts/sendico/api/recipients/object.yaml", - "blob": "091e5c7c5ebec4a29f7bd5e29378750c16d8e834" - }, - { - "source": "docs/openapi/api/recipients/create.yaml", - "target": "contracts/sendico/api/recipients/create.yaml", - "blob": "10fc1ba66b2c7617932e7f25287a34bf576b5694" - }, - { - "source": "docs/openapi/api/recipients/list.yaml", - "target": "contracts/sendico/api/recipients/list.yaml", - "blob": "33ca97f1a2ddd0b392f33a48eb2972eed1e0161d" - }, - { - "source": "docs/openapi/api/organizations/get.yaml", - "target": "contracts/sendico/api/organizations/get.yaml", - "blob": "c93ed7a4d5fecdfe72ebb7cefc2f38aae7f24bf0" - }, - { - "source": "docs/openapi/api/organizations/response/organization.yaml", - "target": "contracts/sendico/api/organizations/response/organization.yaml", - "blob": "56fc0f76a363e6ecf08413ff955f63798b736a84" - }, - { - "source": "docs/openapi/models/organization/organization.yaml", - "target": "contracts/sendico/models/organization/organization.yaml", - "blob": "8b6dec1dba78c16614211b21d574121db48713aa" - }, - { - "source": "docs/openapi/api/organizations/root.yaml", - "target": "contracts/sendico/api/organizations/root.yaml", - "blob": "7a00d19c7b2500c4199d17638b817097a0c4255f" - }, - { - "source": "docs/openapi/api/verification/verify.yaml", - "target": "contracts/sendico/api/verification/verify.yaml", - "blob": "cf26d7b3b26aa5b1dcbe9606cdb7dd1fb49bc94f" - }, - { - "source": "docs/openapi/api/verification/response/verification.yaml", - "target": "contracts/sendico/api/verification/response/verification.yaml", - "blob": "f169ab7b8f843e02e64c07fd3b4bf3f4916fd270" - }, - { - "source": "docs/openapi/api/accounts/response/auth.yaml", - "target": "contracts/sendico/api/accounts/response/auth.yaml", - "blob": "dd3265423b5854b908d17cef482c8d12fab5c958" - }, - { - "source": "docs/openapi/models/account/account.yaml", - "target": "contracts/sendico/models/account/account.yaml", - "blob": "b47ae25d410359cbfb7080560715e8e7389ecbdd" - }, - { - "source": "docs/openapi/models/account/account_public.yaml", - "target": "contracts/sendico/models/account/account_public.yaml", - "blob": "3f807896b73e06d23149ca7bd6d63cada27a042c" - }, - { - "source": "docs/openapi/api/verification/bodies/verification.yaml", - "target": "contracts/sendico/api/verification/bodies/verification.yaml", - "blob": "da9dbd6b43df2daccca60506889f7273bf1a0d99" - }, - { - "source": "docs/openapi/api/verification/request/verification.yaml", - "target": "contracts/sendico/api/verification/request/verification.yaml", - "blob": "a0050984df5818d36da6a4f8c307d9ba6bc915a4" - }, - { - "source": "docs/openapi/models/auth/session_identifier.yaml", - "target": "contracts/sendico/models/auth/session_identifier.yaml", - "blob": "fba55b0e4efe7f9960e97c4fe42cdf29ec3182eb" - }, - { - "source": "docs/openapi/api/verification/resend.yaml", - "target": "contracts/sendico/api/verification/resend.yaml", - "blob": "d2ee0fc195ecdc07196d4c7d777037c7cd42101c" - }, - { - "source": "docs/openapi/api/verification/create.yaml", - "target": "contracts/sendico/api/verification/create.yaml", - "blob": "9dd4e9790c5ca73937a8b7fb543f0c5682484ef6" - }, - { - "source": "docs/openapi/api/accounts/auth_refresh.yaml", - "target": "contracts/sendico/api/accounts/auth_refresh.yaml", - "blob": "2822daaa7b00efd4f688fc1eb21fd3279e386b32" - }, - { - "source": "docs/openapi/api/accounts/bodies/auth.yaml", - "target": "contracts/sendico/api/accounts/bodies/auth.yaml", - "blob": "f7a723d2c8142f9fe7b176c562b33648f2b3654f" - }, - { - "source": "docs/openapi/api/accounts/request/auth.yaml", - "target": "contracts/sendico/api/accounts/request/auth.yaml", - "blob": "b000fd83ab37af6ceb7c5ebe252fc61733ef17d2" - }, - { - "source": "docs/openapi/models/auth/client_refresh_token.yaml", - "target": "contracts/sendico/models/auth/client_refresh_token.yaml", - "blob": "43408e367e8139fb49416038f4de00f9146b108e" - }, - { - "source": "docs/openapi/models/auth/login_data.yaml", - "target": "contracts/sendico/models/auth/login_data.yaml", - "blob": "480b3d202f0d2cf437e83f4210ff21cd42ffbcdd" - }, - { - "source": "docs/openapi/api/accounts/auth_rotate.yaml", - "target": "contracts/sendico/api/accounts/auth_rotate.yaml", - "blob": "9515e4dd178a115b09fe92f9fe28ca1e39b708d6" - }, - { - "source": "docs/openapi/api/accounts/auth_login_api.yaml", - "target": "contracts/sendico/api/accounts/auth_login_api.yaml", - "blob": "3bb72b2e1d257dcc15db1d9f9986ea0c5617d1b8" - }, - { - "source": "docs/openapi/api/accounts/auth_login.yaml", - "target": "contracts/sendico/api/accounts/auth_login.yaml", - "blob": "ede859161d26767acc3e865c040343301e7e6f55" + "adapted_beyond_namespace": true }, { "source": "api/edge/product/main.go", - "target": "cmd/bff/main.go", + "target": "product/main.go", "blob": "4c9f6dfd4436bf75fac15690decdac0efaa5b9c2", "adapted_beyond_namespace": true + }, + { + "source": "api/edge/shared/interface/api/auth_config.go", + "target": "product/verification/config/config.go", + "blob": "e155092ea9646e7320e1dc3f66b8ae2bc2081409", + "adapted_beyond_namespace": true } ], "excluded": [ diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md index 5a8e592..b7a71c6 100644 --- a/backend/PROVENANCE.md +++ b/backend/PROVENANCE.md @@ -4,9 +4,9 @@ Source repository: `S_I/sendico` (`git.sendico.io`), revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. [PROVENANCE.json](PROVENANCE.json) records each retained file, its original path -and Git blob, and whether a Go file changed beyond namespace replacement and -formatting. It currently covers 232 Go source/test files and 112 public OpenAPI -files. Sources came from the pinned commit, not the dirty Sendico checkout. +and Git blob, and whether a Go file changed beyond import-path replacement and +formatting. It currently covers 233 Go source/test entries across `product` and `shared` +(one original configuration file contributes to two feature packages). Sources came from the pinned commit, not the dirty Sendico checkout. These are SXCHNGE-owned copies that may diverge independently. ## Retained implementation @@ -18,13 +18,11 @@ These are SXCHNGE-owned copies that may diverge independently. - Verification issuance, expiry, attempts, consumption and idempotency. - Local Mongo connection, transactions and the repository/query implementation required by account, session and verification storage, with retained tests. -- Public OpenAPI 4.2.37 and its recursive schema references, isolated under - `contracts/sendico`. These wire files are unchanged except for trailing blank - line normalization in three YAML files; they are not local domain models. - ## Adaptations and exclusions -Imports belong to `git.sendico.io/tech/sxchnge/bff`. The composition root selects +The backend contains Go modules `git.sendico.io/tech/sxchnge/product` and +`git.sendico.io/tech/sxchnge/shared`. Product owns its executable entry point; +shared is a dependency library with no executable or domain-module imports. The composition root selects local customer services. Discovery, message-bus registration, private gRPC and platform factories are absent. Process identity and the refresh cookie use SXCHNGE. Startup validates required settings and timeouts, closes failed connections and @@ -60,3 +58,16 @@ This is a bootstrap. Signup/profile HTTP wiring, verification delivery/completio and the public Sendico HTTP adapter remain explicit work in the [architecture plan](../docs/architecture/bff.md). The sole Sendico organization reference belongs to that adapter's configuration, never to local customers. + +## Module organization + +`backend/` groups modules; it has no root `go.mod`. The inherited `cmd`, `edge`, +`internal`, `pkg` and empty `scripts` top-level directories are gone. `product` +contains `main.go`, its manifest and app/customer/auth/session/verification code. +`shared` owns its manifest and generic infrastructure. Models moved beside their +owning features. Customer repository construction moved out of shared database +code. Auth and verification configurations moved out of application interfaces. + +All 112 unconsumed public API snapshot files and their snapshot-only check were +removed. Future public adapters will own just the wire types they consume. +Backoffice is a separate future executable module, not an alias for product. diff --git a/backend/README.md b/backend/README.md index 0c4373f..2bb03f7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,41 +1,35 @@ -# SXCHNGE BFF +# Go backend modules -A locally owned copy of Sendico's reusable BFF runtime, authentication/session -stack and customer account service. See [provenance](PROVENANCE.md) and the -[architecture and public API plan](../docs/architecture/bff.md). +`backend/` groups independent Go modules. It is not an executable or a Go module. -SXCHNGE owns its customers and authentication. SXCHNGE itself is one organization -in Sendico. Provider integration uses only Sendico's public HTTP API; the pinned -[OpenAPI snapshot](contracts/sendico/api.yaml) is isolated from local models. +```text +backend/ +├── product/ Customer-facing BFF executable +│ ├── main.go +│ ├── go.mod +│ ├── app/ Startup, composition, HTTP server and health +│ ├── customers/ Customer service, models and Mongo repository +│ ├── auth/ Login/protected HTTP routes, DTOs and configuration +│ ├── sessions/ Session models, refresh storage, cookies and tokens +│ └── verification/ Verification models, configuration and storage +└── shared/ Reusable library module + ├── go.mod + ├── database/ Connection, transactions and repository primitives + ├── errors/ + ├── logging/ + └── … Generic HTTP, configuration and rate-limit helpers +``` -## Run locally +The backoffice BFF belongs in a separate `backend/backoffice/` executable module, +with its own `main.go`, `go.mod`, configuration and operator authentication. It is +not implemented by this customer-BFF bootstrap. Shared infrastructure has no +`main.go` and must not depend on an executable module. The product module uses a +local `replace` for `../shared`; each module has its own dependency manifest. -Requires Go 1.27 and an SXCHNGE-owned MongoDB replica set for transactions. -Copy `config.example.yml` to an untracked `config.yml`. Set: +Run the product with `make run-backend`; see its [run guide](product/README.md). +`make test` runs both backend modules and the frontend. `make lint-backend` checks +both Go modules and their architecture boundaries. -- `SXCHNGE_MONGO_URI`: URI for the local SXCHNGE database. -- `SXCHNGE_JWT_SECRET`: private random signing key, at least 32 bytes for HS256. -- `SXCHNGE_DOMAIN`: local API hostname (for example `localhost:8082`). -- `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment. -- `SXCHNGE_API_ENDPOINT`: `/api/v1`. - -The runtime starts against an empty SXCHNGE database and creates its indexes. -No organization, role, policy or seed is required. -Run `make run-backend` from the repository root. The copied CLI accepts -`-config.file`, `-version` and `-debug`. Configure exact browser origins in YAML; -secure refresh cookies require HTTPS for browser session testing. - -The runtime mounts `/api/v1/health` and the copied account login, API login, -refresh, rotation and logout handlers. Customer/client records must exist in the -local database. The account service creates customers and activation tokens -atomically, and deletes customers with session revocation. It is initialized locally; signup/profile and -verification-delivery/completion routes are the next wiring work. This is a -bootstrap, not a finished authentication deployment. No Sendico calls are made. - -## Validate - -Run `make test` and `make lint-backend`. Backend tests include real MongoDB -integration tests through Testcontainers, so Docker must be running. -`make test-backend` runs the backend suite with the race detector. -Architecture checks reject private Sendico imports/transports and ad hoc error -constructors. The frontend still uses dummy services; no Flutter source changed. +Copied source remains locally owned. See [provenance](PROVENANCE.md), the +[architecture/API plan](../docs/architecture/bff.md) and the +[delivery record](../docs/implementation.md). diff --git a/backend/cmd/bff/main.go b/backend/cmd/bff/main.go deleted file mode 100644 index e6095d5..0000000 --- a/backend/cmd/bff/main.go +++ /dev/null @@ -1,21 +0,0 @@ -// Adapted from Sendico api/edge/product/main.go; see PROVENANCE.md. -package main - -import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/appversion" - "git.sendico.io/tech/sxchnge/bff/edge/shared/server" - "git.sendico.io/tech/sxchnge/bff/internal/profile" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - application "git.sendico.io/tech/sxchnge/bff/pkg/server" - smain "git.sendico.io/tech/sxchnge/bff/pkg/server/main" - "os" -) - -func factory(logger mlogger.Logger, file string, debug bool) (application.Application, error) { - return server.Create(logger, file, debug, profile.Runtime()) -} -func main() { - if smain.RunServer("sxchnge.bff", appversion.Create(), factory) != nil { - os.Exit(1) - } -} diff --git a/backend/contracts/sendico/api.yaml b/backend/contracts/sendico/api.yaml deleted file mode 100644 index c37aeba..0000000 --- a/backend/contracts/sendico/api.yaml +++ /dev/null @@ -1,129 +0,0 @@ -openapi: 3.1.0 -info: - title: Sendico API Contract - version: 4.2.37 - summary: Minimal client contract for authenticated BFF flows. - description: | - OpenAPI 3.1 contract focused on authenticated client flows exposed by the BFF: - authentication, verification, recipients, payment methods, payments, wallets, - and ledger account history. -servers: - - url: /api/v1 - description: Main HTTP API base path - -tags: - - name: Auth - description: Authentication and token lifecycle - - name: Accounts - description: Account auth responses used by login/verification - - name: Verification - description: Pending-account code verification - - name: Organizations - description: Organization selection for payment operations - - name: Recipients - description: Recipient CRUD and archive flows - - name: Payment Methods - description: Payment method CRUD and archive flows - - name: Callbacks - description: Webhook callback subscription CRUD and signing material rotation for HMAC and detached-JWS callbacks - - name: Payments - description: Quotation and payment orchestration - - name: Wallets - description: Managed wallet metadata and balances - - name: Ledger Accounts - description: Ledger account balances and account-history operations - -paths: - /accounts/login: - $ref: ./api/accounts/auth_login.yaml - /accounts/login/api: - $ref: ./api/accounts/auth_login_api.yaml - /accounts/rotate: - $ref: ./api/accounts/auth_rotate.yaml - /accounts/refresh: - $ref: ./api/accounts/auth_refresh.yaml - - /verification: - $ref: ./api/verification/create.yaml - /verification/resend: - $ref: ./api/verification/resend.yaml - /verification/verify: - $ref: ./api/verification/verify.yaml - - /organizations: - $ref: ./api/organizations/root.yaml - /organizations/{org_ref}: - $ref: ./api/organizations/get.yaml - - /recipients/list/{org_ref}/{organizations_ref}: - $ref: ./api/recipients/list.yaml - /recipients/{org_ref}: - $ref: ./api/recipients/create.yaml - /recipients/{recipients_ref}: - $ref: ./api/recipients/object.yaml - /recipients: - $ref: ./api/recipients/update.yaml - /recipients/archive/{org_ref}/{recipients_ref}: - $ref: ./api/recipients/archive.yaml - - /payment_methods/accounts/{organizations_ref}/{accounts_ref}: - $ref: ./api/payment_methods/accounts.yaml - /payment_methods/recipients/{organizations_ref}/{recipients_ref}: - $ref: ./api/payment_methods/recipients.yaml - /payment_methods/{organizations_ref}/{payment_methods_ref}: - $ref: ./api/payment_methods/object.yaml - /payment_methods/archive/{organizations_ref}/{payment_methods_ref}: - $ref: ./api/payment_methods/archive.yaml - - /callbacks/list/{org_ref}/{organizations_ref}: - $ref: ./api/callbacks/list.yaml - /callbacks/{org_ref}: - $ref: ./api/callbacks/create.yaml - /callbacks/{callbacks_ref}: - $ref: ./api/callbacks/object.yaml - /callbacks: - $ref: ./api/callbacks/update.yaml - /callbacks/archive/{org_ref}/{callbacks_ref}: - $ref: ./api/callbacks/archive.yaml - /callbacks/rotate-secret/{callbacks_ref}: - $ref: ./api/callbacks/rotate_secret.yaml - - /payments/quote/{organizations_ref}: - $ref: ./api/payments/quote.yaml - /payments/multiquote/{organizations_ref}: - $ref: ./api/payments/multiquote.yaml - /payments/immediate/{organizations_ref}: - $ref: ./api/payments/immediate.yaml - /payments/by-quote/{organizations_ref}: - $ref: ./api/payments/by_quote.yaml - /payments/by-multiquote/{organizations_ref}: - $ref: ./api/payments/by_multiquote.yaml - /payments/by-ref/{payments_ref}: - $ref: ./api/payments/object.yaml - /payments/{organizations_ref}: - $ref: ./api/payments/list.yaml - /payments/documents/certificate/{organizations_ref}: - $ref: ./api/payments/documents_certificate.yaml - /payments/registry/{organizations_ref}: - $ref: ./api/payments/registry.yaml - /payments/registry/refresh/{organizations_ref}: - $ref: ./api/payments/registry_refresh.yaml - /payments/fx/currencies/{organizations_ref}: - $ref: ./api/payments/fx_currencies.yaml - /payments/fx/directions/{organizations_ref}: - $ref: ./api/payments/fx_directions.yaml - /chain_wallets/{organizations_ref}/assets: - $ref: ./api/wallets/assets.yaml - /ledger_accounts/{organizations_ref}/{ledger_account_ref}/balance: - $ref: ./api/ledger_accounts/balance.yaml - /ledger_accounts/{organizations_ref}/{ledger_account_ref}/operations: - $ref: ./api/ledger_accounts/operations.yaml - /ledger_accounts/{organizations_ref}/{ledger_account_ref}/operations/{entry_ref}: - $ref: ./api/ledger_accounts/operation.yaml - -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT diff --git a/backend/contracts/sendico/api/accounts/auth_login.yaml b/backend/contracts/sendico/api/accounts/auth_login.yaml deleted file mode 100644 index ede8591..0000000 --- a/backend/contracts/sendico/api/accounts/auth_login.yaml +++ /dev/null @@ -1,29 +0,0 @@ -post: - tags: [Accounts, Auth] - summary: Login using email/password and receive pending verification token - description: | - Validates account credentials and returns a short-lived `pendingToken`. - This endpoint does not return final access/refresh tokens; complete verification flow via `/verification/verify`. - operationId: accountsLogin - requestBody: - $ref: ./bodies/auth.yaml#/components/requestBodies/LoginBody - responses: - '202': - description: Pending verification token issued - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/auth.yaml#/components/schemas/PendingLoginData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_login_api.yaml b/backend/contracts/sendico/api/accounts/auth_login_api.yaml deleted file mode 100644 index 3bb72b2..0000000 --- a/backend/contracts/sendico/api/accounts/auth_login_api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -post: - tags: [Accounts, Auth] - summary: API login using email/password and client credentials - description: | - Validates account credentials and eligible API client credentials, then returns final auth payload. - This endpoint bypasses login OTP/2FA and is intended only for approved API clients. - If the client definition includes `allowedCIDRs`, request source IP must match one of those CIDRs. - operationId: accountsApiLogin - requestBody: - $ref: ./bodies/auth.yaml#/components/requestBodies/ApiLoginBody - responses: - '200': - description: Login successful - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/auth.yaml#/components/schemas/LoginData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_refresh.yaml b/backend/contracts/sendico/api/accounts/auth_refresh.yaml deleted file mode 100644 index 2822daa..0000000 --- a/backend/contracts/sendico/api/accounts/auth_refresh.yaml +++ /dev/null @@ -1,31 +0,0 @@ -post: - tags: [Accounts, Auth] - summary: Refresh access token by refresh token - description: | - Validates refresh token + session identifiers and issues a new access token. - Use this endpoint when refresh token stays the same. - operationId: accountsRefreshAccessToken - requestBody: - $ref: ./bodies/auth.yaml#/components/requestBodies/RefreshTokenBody - responses: - '200': - description: Access token refreshed - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/auth.yaml#/components/schemas/AccountAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/auth_rotate.yaml b/backend/contracts/sendico/api/accounts/auth_rotate.yaml deleted file mode 100644 index 9515e4d..0000000 --- a/backend/contracts/sendico/api/accounts/auth_rotate.yaml +++ /dev/null @@ -1,31 +0,0 @@ -post: - tags: [Accounts, Auth] - summary: Rotate refresh token and issue new access/refresh tokens - description: | - Validates refresh token + session identifiers and returns a new token pair. - Use this endpoint when replacing both access and refresh tokens. - operationId: accountsRotateRefreshToken - requestBody: - $ref: ./bodies/auth.yaml#/components/requestBodies/RefreshTokenBody - responses: - '200': - description: New token pair issued - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/auth.yaml#/components/schemas/LoginData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/accounts/bodies/auth.yaml b/backend/contracts/sendico/api/accounts/bodies/auth.yaml deleted file mode 100644 index f7a723d..0000000 --- a/backend/contracts/sendico/api/accounts/bodies/auth.yaml +++ /dev/null @@ -1,24 +0,0 @@ -components: - requestBodies: - LoginBody: - description: JSON credentials payload for standard login. - required: true - content: - application/json: - schema: - $ref: ../request/auth.yaml#/components/schemas/LoginRequest - ApiLoginBody: - description: JSON credentials payload for API client login. - required: true - content: - application/json: - schema: - $ref: ../request/auth.yaml#/components/schemas/ApiLoginRequest - - RefreshTokenBody: - description: JSON payload containing session and refresh token data. - required: true - content: - application/json: - schema: - $ref: ../request/auth.yaml#/components/schemas/RefreshTokenRequest diff --git a/backend/contracts/sendico/api/accounts/request/auth.yaml b/backend/contracts/sendico/api/accounts/request/auth.yaml deleted file mode 100644 index b000fd8..0000000 --- a/backend/contracts/sendico/api/accounts/request/auth.yaml +++ /dev/null @@ -1,34 +0,0 @@ -components: - schemas: - LoginRequest: - type: object - additionalProperties: false - required: - - login - properties: - clientId: - type: string - description: Client identifier bound to refresh token lifecycle and client policy checks. - deviceId: - type: string - description: Device/server identifier associated with the client session. - login: - $ref: ../../../models/auth/login_data.yaml#/components/schemas/LoginData - - ApiLoginRequest: - allOf: - - $ref: ./auth.yaml#/components/schemas/LoginRequest - - type: object - additionalProperties: false - required: - - clientId - - deviceId - - clientSecret - properties: - clientSecret: - type: string - format: password - description: Client secret for `client_secret_post` authentication. - - RefreshTokenRequest: - $ref: ../../../models/auth/client_refresh_token.yaml#/components/schemas/ClientRefreshToken diff --git a/backend/contracts/sendico/api/accounts/response/auth.yaml b/backend/contracts/sendico/api/accounts/response/auth.yaml deleted file mode 100644 index dd32654..0000000 --- a/backend/contracts/sendico/api/accounts/response/auth.yaml +++ /dev/null @@ -1,62 +0,0 @@ -components: - schemas: - AccountAuthData: - description: Authentication response containing account profile and access token. - type: object - additionalProperties: false - required: - - accessToken - - account - properties: - accessToken: - description: Access token used for authenticated requests. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - account: - description: Authenticated account data. - $ref: ../../../models/account/account.yaml#/components/schemas/AccountData - - LoginData: - description: Full login response including access and refresh tokens. - type: object - additionalProperties: false - required: - - accessToken - - account - - refreshToken - properties: - accessToken: - description: Access token used for authenticated requests. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - account: - description: Authenticated account data. - $ref: ../../../models/account/account.yaml#/components/schemas/AccountData - refreshToken: - description: Refresh token used to obtain a new access token. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - - PendingLoginData: - description: Pending login response requiring additional verification. - type: object - additionalProperties: false - required: - - account - - pendingToken - - target - properties: - account: - description: Interim authentication payload prepared for verification completion. - type: object - additionalProperties: false - properties: - accessToken: - description: Temporary access token issued before verification is completed. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - account: - description: Account data associated with the pending login flow. - $ref: ../../../models/account/account.yaml#/components/schemas/AccountData - pendingToken: - description: Token proving the pending verification session. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - target: - description: Verification target destination (for example email or phone). - type: string diff --git a/backend/contracts/sendico/api/callbacks/archive.yaml b/backend/contracts/sendico/api/callbacks/archive.yaml deleted file mode 100644 index dfc1cde..0000000 --- a/backend/contracts/sendico/api/callbacks/archive.yaml +++ /dev/null @@ -1,37 +0,0 @@ -get: - tags: [Callbacks] - summary: Archive/unarchive callback subscription - description: Sets callback archive state by `callbacks_ref` and required `archived` query parameter. - operationId: callbacksArchive - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef - - name: archived - in: query - required: true - description: Target archive value to set on the callback. - schema: - type: boolean - - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade - responses: - '200': - description: Archive state updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/bodies/callback.yaml b/backend/contracts/sendico/api/callbacks/bodies/callback.yaml deleted file mode 100644 index 850efd3..0000000 --- a/backend/contracts/sendico/api/callbacks/bodies/callback.yaml +++ /dev/null @@ -1,17 +0,0 @@ -components: - requestBodies: - CallbackCreateBody: - description: JSON body containing callback endpoint configuration for create. - required: true - content: - application/json: - schema: - $ref: ../request/callback.yaml#/components/schemas/CallbackCreateRequest - - CallbackUpdateBody: - description: JSON body containing callback endpoint configuration for update. - required: true - content: - application/json: - schema: - $ref: ../request/callback.yaml#/components/schemas/CallbackUpdateRequest diff --git a/backend/contracts/sendico/api/callbacks/create.yaml b/backend/contracts/sendico/api/callbacks/create.yaml deleted file mode 100644 index 92c5c32..0000000 --- a/backend/contracts/sendico/api/callbacks/create.yaml +++ /dev/null @@ -1,33 +0,0 @@ -post: - tags: [Callbacks] - summary: Create callback subscription - description: Creates callback subscription for the organization identified by `org_ref`. - operationId: callbacksCreate - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - requestBody: - $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackCreateBody - responses: - '201': - description: Callback created - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/list.yaml b/backend/contracts/sendico/api/callbacks/list.yaml deleted file mode 100644 index 0072f45..0000000 --- a/backend/contracts/sendico/api/callbacks/list.yaml +++ /dev/null @@ -1,35 +0,0 @@ -get: - tags: [Callbacks] - summary: List callback subscriptions - description: | - Lists callbacks for the given organization context. - The route currently includes both `org_ref` and `organizations_ref`; the handler resolves organization context from `org_ref` and ignores `organizations_ref`. - operationId: callbacksList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - $ref: ../parameters/offset.yaml#/components/parameters/Offset - - $ref: ../parameters/archived.yaml#/components/parameters/Archived - responses: - '200': - description: Callback list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/object.yaml b/backend/contracts/sendico/api/callbacks/object.yaml deleted file mode 100644 index e585c74..0000000 --- a/backend/contracts/sendico/api/callbacks/object.yaml +++ /dev/null @@ -1,64 +0,0 @@ -get: - tags: [Callbacks] - summary: Get callback subscription - description: Returns callback subscription by `callbacks_ref`. - operationId: callbacksGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef - responses: - '200': - description: Callback data - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -delete: - tags: [Callbacks] - summary: Delete callback subscription - description: Deletes callback by reference. - operationId: callbacksDelete - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef - - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade - responses: - '200': - description: Callback deleted - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/request/callback.yaml b/backend/contracts/sendico/api/callbacks/request/callback.yaml deleted file mode 100644 index a0535c3..0000000 --- a/backend/contracts/sendico/api/callbacks/request/callback.yaml +++ /dev/null @@ -1,113 +0,0 @@ -components: - schemas: - CallbackBackoffInput: - description: Optional retry backoff overrides. Non-positive values currently fall back to server defaults. - type: object - additionalProperties: false - properties: - minDelayMs: - type: integer - maxDelayMs: - type: integer - - CallbackRetryPolicyInput: - description: Optional retry and signing overrides. Omitted or non-positive numeric values currently fall back to server defaults. - type: object - additionalProperties: false - properties: - backoff: - $ref: '#/components/schemas/CallbackBackoffInput' - signingMode: - description: | - Optional signing mode. Canonical values are documented here; current handler also accepts legacy aliases such as `hmac`, `hmac-sha256`, `jws-eddsa-ed25519`, `jws+eddsa-ed25519`, `jws_eddsa`, and `eddsa_ed25519`. - type: string - enum: - - none - - hmac_sha256 - - jws_eddsa_ed25519 - headers: - type: object - additionalProperties: - type: string - maxAttempts: - type: integer - requestTimeoutMs: - type: integer - - CallbackCreateRequest: - description: | - Request payload used to create a callback configuration. - Only `url` is hard-required by current create logic. Other mutable fields are optional and defaulted by the handler when omitted. - type: object - additionalProperties: false - required: - - url - properties: - name: - description: Optional callback display name. When omitted, the handler defaults it to `url`. - type: string - description: - description: Optional callback description. - type: string - nullable: true - status: - description: | - Optional callback status. Canonical values are documented here; current handler also accepts `enabled` and `inactive` aliases. - type: string - enum: - - active - - disabled - url: - description: Absolute HTTPS callback endpoint URL. - type: string - format: uri - eventTypes: - description: Optional callback event types. When omitted or empty, the handler defaults this list. - type: array - items: - type: string - retryPolicy: - $ref: '#/components/schemas/CallbackRetryPolicyInput' - - CallbackUpdateRequest: - description: | - Request payload used to update a callback configuration. - The current PUT handler requires `id`, `organizationRef`, and `url`. Other mutable fields are optional, but omitted values may be reset to handler defaults during normalization. - type: object - additionalProperties: false - required: - - id - - organizationRef - - url - properties: - id: - description: Callback identifier to update. - $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId - organizationRef: - description: Organization identifier carried in the stored callback record and currently required by the update path. - $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId - name: - description: Optional callback display name. When omitted, the handler defaults it to `url`. - type: string - description: - description: Optional callback description. - type: string - nullable: true - status: - description: | - Optional callback status. Canonical values are documented here; current handler also accepts `enabled` and `inactive` aliases. - type: string - enum: - - active - - disabled - url: - description: Absolute HTTPS callback endpoint URL. - type: string - format: uri - eventTypes: - description: Optional callback event types. When omitted or empty, the handler defaults this list. - type: array - items: - type: string - retryPolicy: - $ref: '#/components/schemas/CallbackRetryPolicyInput' diff --git a/backend/contracts/sendico/api/callbacks/response/callback.yaml b/backend/contracts/sendico/api/callbacks/response/callback.yaml deleted file mode 100644 index 02e262b..0000000 --- a/backend/contracts/sendico/api/callbacks/response/callback.yaml +++ /dev/null @@ -1,22 +0,0 @@ -components: - schemas: - CallbacksAuthData: - description: Authenticated response payload containing callback configurations. - type: object - additionalProperties: false - required: - - accessToken - - callbacks - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - callbacks: - description: Collection of callbacks configured for the authenticated scope. JWS-signed callbacks expose read-only verification JWKs via `callbacks[].signingKeys`. - type: array - items: - $ref: ../../../models/callback/callback.yaml#/components/schemas/Callback - generatedSigningSecret: - description: Newly generated signing secret when HMAC signing material is created or rotated. JWS-signed callbacks do not populate this field. - type: string - nullable: true diff --git a/backend/contracts/sendico/api/callbacks/rotate_secret.yaml b/backend/contracts/sendico/api/callbacks/rotate_secret.yaml deleted file mode 100644 index 6296d25..0000000 --- a/backend/contracts/sendico/api/callbacks/rotate_secret.yaml +++ /dev/null @@ -1,31 +0,0 @@ -post: - tags: [Callbacks] - summary: Rotate callback signing material - description: Rotates the active signing material for the callback. HMAC callbacks generate and return a new shared secret once. `jws_eddsa_ed25519` callbacks generate a new Ed25519 keypair, expose the new active verification JWK on normal callback reads, and keep retiring verification keys visible for 24 hours. - operationId: callbacksRotateSecret - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef - responses: - '200': - description: Callback signing material rotated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/callbacks/update.yaml b/backend/contracts/sendico/api/callbacks/update.yaml deleted file mode 100644 index e3f9932..0000000 --- a/backend/contracts/sendico/api/callbacks/update.yaml +++ /dev/null @@ -1,31 +0,0 @@ -put: - tags: [Callbacks] - summary: Update callback subscription - description: Updates callback subscription fields. The current handler requires `id`, `organizationRef`, and `url`; omitted mutable fields may be reset to defaults. - operationId: callbacksUpdate - security: - - bearerAuth: [] - requestBody: - $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackUpdateBody - responses: - '200': - description: Callback updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/balance.yaml b/backend/contracts/sendico/api/ledger_accounts/balance.yaml deleted file mode 100644 index 7515651..0000000 --- a/backend/contracts/sendico/api/ledger_accounts/balance.yaml +++ /dev/null @@ -1,58 +0,0 @@ -get: - tags: [Ledger Accounts] - summary: Get ledger account balance - description: | - Returns the canonical balance for the requested ledger account. - - Without `as_of`, the response is the current materialized balance and includes - current metadata fields `version` and `lastUpdated`. - - With `as_of`, the response is an event-time accounting balance reconstructed - from posted statement-entry deltas where `eventTime <= as_of`. Entries exactly - equal to `as_of` are included. This is not a historical snapshot of materialized - balance state and not "what the system knew" at that clock time. Late or - backdated postings with older `eventTime` can change historical responses for - the same `as_of`. Historical responses include `ledgerAccountRef`, `balance`, - and canonical UTC `asOf`, and omit `version` and `lastUpdated` because no - historical snapshot/version semantics are defined. - - Historical balance is valid only if every account-affecting committed posting - writes matching statement entries in the same transaction as balance updates. - operationId: ledgerAccountBalanceGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef - - name: as_of - in: query - required: false - description: | - Optional RFC3339 event-time cutoff for historical balance reconstruction. - Timezone offsets are accepted and returned as canonical UTC `asOf`. - schema: - type: string - format: date-time - example: '2026-04-23T14:30:00+02:00' - responses: - '200': - description: Ledger account balance - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/balance.yaml#/components/schemas/LedgerAccountBalanceData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/operation.yaml b/backend/contracts/sendico/api/ledger_accounts/operation.yaml deleted file mode 100644 index 06fe2c5..0000000 --- a/backend/contracts/sendico/api/ledger_accounts/operation.yaml +++ /dev/null @@ -1,36 +0,0 @@ -get: - tags: [Ledger Accounts] - summary: Get ledger account operation detail - description: | - Returns the account-history projection for `entry_ref` within the requested ledger account. - This is not a general journal-entry read API. - The endpoint returns `404` when the entry is not part of the requested account's visible history. - operationId: ledgerAccountOperationsGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef - - $ref: ../parameters/entry_ref.yaml#/components/parameters/EntryRef - responses: - '200': - description: Ledger account operation detail - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/operation.yaml#/components/schemas/LedgerAccountOperationData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/operations.yaml b/backend/contracts/sendico/api/ledger_accounts/operations.yaml deleted file mode 100644 index 2910a7b..0000000 --- a/backend/contracts/sendico/api/ledger_accounts/operations.yaml +++ /dev/null @@ -1,36 +0,0 @@ -get: - tags: [Ledger Accounts] - summary: List ledger account operations - description: | - Returns account-history summary projections for the requested ledger account. - Public HTTP pagination supports only `limit` and `offset`. - Results are ordered by `version` descending and `entry_ref` descending. - Unsupported query parameters, including `archived`, are rejected. - operationId: ledgerAccountOperationsList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/ledger_account_ref.yaml#/components/parameters/LedgerAccountRef - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - $ref: ../parameters/offset.yaml#/components/parameters/Offset - responses: - '200': - description: Ledger account operation summaries - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/operation.yaml#/components/schemas/LedgerAccountOperationsData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml b/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml deleted file mode 100644 index 254864b..0000000 --- a/backend/contracts/sendico/api/ledger_accounts/response/balance.yaml +++ /dev/null @@ -1,15 +0,0 @@ -components: - schemas: - LedgerAccountBalanceData: - description: Response payload containing a ledger account balance. - type: object - additionalProperties: false - required: - - accessToken - - balance - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - balance: - $ref: ../../../models/ledger/balance.yaml#/components/schemas/LedgerAccountBalance diff --git a/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml b/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml deleted file mode 100644 index 4492932..0000000 --- a/backend/contracts/sendico/api/ledger_accounts/response/operation.yaml +++ /dev/null @@ -1,33 +0,0 @@ -components: - schemas: - LedgerAccountOperationsData: - description: Response payload containing ledger account history summaries. - type: object - additionalProperties: false - required: - - accessToken - - operations - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - operations: - description: Account-history summaries ordered by `version DESC, entryRef DESC`. - type: array - items: - $ref: ../../../models/ledger/operation.yaml#/components/schemas/LedgerAccountOperationSummary - - LedgerAccountOperationData: - description: Response payload containing a single ledger account history projection. - type: object - additionalProperties: false - required: - - accessToken - - operation - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - operation: - description: Account-history projection for the requested entry and ledger account. - $ref: ../../../models/ledger/operation.yaml#/components/schemas/LedgerAccountOperation diff --git a/backend/contracts/sendico/api/organizations/get.yaml b/backend/contracts/sendico/api/organizations/get.yaml deleted file mode 100644 index c93ed7a..0000000 --- a/backend/contracts/sendico/api/organizations/get.yaml +++ /dev/null @@ -1,29 +0,0 @@ -get: - tags: [Organizations] - summary: Get organization by reference - description: Fetches a single organization available to the authenticated account. - operationId: organizationsGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - responses: - '200': - description: Organization details - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/organization.yaml#/components/schemas/OrganizationsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/organizations/response/organization.yaml b/backend/contracts/sendico/api/organizations/response/organization.yaml deleted file mode 100644 index 56fc0f7..0000000 --- a/backend/contracts/sendico/api/organizations/response/organization.yaml +++ /dev/null @@ -1,18 +0,0 @@ -components: - schemas: - OrganizationsAuthData: - description: Authenticated response payload containing organizations. - type: object - additionalProperties: false - required: - - accessToken - - organizations - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - organizations: - description: Collection of organizations available to the authenticated account. - type: array - items: - $ref: ../../../models/organization/organization.yaml#/components/schemas/Organization diff --git a/backend/contracts/sendico/api/organizations/root.yaml b/backend/contracts/sendico/api/organizations/root.yaml deleted file mode 100644 index 7a00d19..0000000 --- a/backend/contracts/sendico/api/organizations/root.yaml +++ /dev/null @@ -1,27 +0,0 @@ -get: - tags: [Organizations] - summary: List organizations of current account - description: Returns organizations the authenticated account can access. - operationId: organizationsList - security: - - bearerAuth: [] - responses: - '200': - description: Organizations list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/organization.yaml#/components/schemas/OrganizationsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/parameters/accounts_ref.yaml b/backend/contracts/sendico/api/parameters/accounts_ref.yaml deleted file mode 100644 index a72a17d..0000000 --- a/backend/contracts/sendico/api/parameters/accounts_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - AccountsRef: - name: accounts_ref - in: path - required: true - description: Account reference, 24 hex chars. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/archived.yaml b/backend/contracts/sendico/api/parameters/archived.yaml deleted file mode 100644 index fb8af8c..0000000 --- a/backend/contracts/sendico/api/parameters/archived.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - Archived: - name: archived - in: query - required: false - description: Filter by archive flag. `true` returns archived records, `false` returns active records. - schema: - type: boolean diff --git a/backend/contracts/sendico/api/parameters/callbacks_ref.yaml b/backend/contracts/sendico/api/parameters/callbacks_ref.yaml deleted file mode 100644 index c12cefa..0000000 --- a/backend/contracts/sendico/api/parameters/callbacks_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - CallbacksRef: - name: callbacks_ref - in: path - required: true - description: Callback subscription reference, 24 hex chars. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/cascade.yaml b/backend/contracts/sendico/api/parameters/cascade.yaml deleted file mode 100644 index 4077d85..0000000 --- a/backend/contracts/sendico/api/parameters/cascade.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - Cascade: - name: cascade - in: query - required: false - description: If `true`, apply operation to dependent objects where cascade is supported. - schema: - type: boolean diff --git a/backend/contracts/sendico/api/parameters/cursor.yaml b/backend/contracts/sendico/api/parameters/cursor.yaml deleted file mode 100644 index 15252a1..0000000 --- a/backend/contracts/sendico/api/parameters/cursor.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - Cursor: - name: cursor - in: query - required: false - description: Opaque cursor from a previous page response (`page.next_cursor`). - schema: - type: string diff --git a/backend/contracts/sendico/api/parameters/entry_ref.yaml b/backend/contracts/sendico/api/parameters/entry_ref.yaml deleted file mode 100644 index d035050..0000000 --- a/backend/contracts/sendico/api/parameters/entry_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - EntryRef: - name: entry_ref - in: path - required: true - description: Statement entry reference within the requested ledger account history. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/filter_states.yaml b/backend/contracts/sendico/api/parameters/filter_states.yaml deleted file mode 100644 index 1c1d34f..0000000 --- a/backend/contracts/sendico/api/parameters/filter_states.yaml +++ /dev/null @@ -1,11 +0,0 @@ -components: - parameters: - FilterStates: - name: filter_states - in: query - required: false - description: | - Additional payment state filter. Accepts one value or comma-separated values. - Same semantics as `state`/`states`. - schema: - type: string diff --git a/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml b/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml deleted file mode 100644 index ac23150..0000000 --- a/backend/contracts/sendico/api/parameters/ledger_account_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - LedgerAccountRef: - name: ledger_account_ref - in: path - required: true - description: Ledger account reference that scopes the account-history read. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/limit.yaml b/backend/contracts/sendico/api/parameters/limit.yaml deleted file mode 100644 index 71fa974..0000000 --- a/backend/contracts/sendico/api/parameters/limit.yaml +++ /dev/null @@ -1,11 +0,0 @@ -components: - parameters: - Limit: - name: limit - in: query - description: Max number of items to return. Parsed as non-negative int64. - required: false - schema: - type: integer - format: int64 - minimum: 0 diff --git a/backend/contracts/sendico/api/parameters/offset.yaml b/backend/contracts/sendico/api/parameters/offset.yaml deleted file mode 100644 index 03b8b7c..0000000 --- a/backend/contracts/sendico/api/parameters/offset.yaml +++ /dev/null @@ -1,11 +0,0 @@ -components: - parameters: - Offset: - name: offset - in: query - description: Number of items to skip. Parsed as non-negative int64. - required: false - schema: - type: integer - format: int64 - minimum: 0 diff --git a/backend/contracts/sendico/api/parameters/org_ref.yaml b/backend/contracts/sendico/api/parameters/org_ref.yaml deleted file mode 100644 index 3ef0c64..0000000 --- a/backend/contracts/sendico/api/parameters/org_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - OrgRef: - name: org_ref - in: path - required: true - description: Organization reference in BFF route format, 24 hex chars. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/organizations_ref.yaml b/backend/contracts/sendico/api/parameters/organizations_ref.yaml deleted file mode 100644 index 668d322..0000000 --- a/backend/contracts/sendico/api/parameters/organizations_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - OrganizationsRef: - name: organizations_ref - in: path - required: true - description: Organization reference used by payments/payment-methods/recipients routes. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml b/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml deleted file mode 100644 index 6690d53..0000000 --- a/backend/contracts/sendico/api/parameters/payment_methods_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - Payment MethodsRef: - name: payment_methods_ref - in: path - required: true - description: Payment method reference, 24 hex chars. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/payments_ref.yaml b/backend/contracts/sendico/api/parameters/payments_ref.yaml deleted file mode 100644 index 28a015b..0000000 --- a/backend/contracts/sendico/api/parameters/payments_ref.yaml +++ /dev/null @@ -1,10 +0,0 @@ -components: - parameters: - PaymentsRef: - name: payments_ref - in: path - required: true - description: Stable payment reference. - schema: - type: string - minLength: 1 diff --git a/backend/contracts/sendico/api/parameters/recipients_ref.yaml b/backend/contracts/sendico/api/parameters/recipients_ref.yaml deleted file mode 100644 index 945bca1..0000000 --- a/backend/contracts/sendico/api/parameters/recipients_ref.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - parameters: - RecipientsRef: - name: recipients_ref - in: path - required: true - description: Recipient reference, 24 hex chars. - schema: - $ref: ../../models/objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/api/parameters/state.yaml b/backend/contracts/sendico/api/parameters/state.yaml deleted file mode 100644 index cb05776..0000000 --- a/backend/contracts/sendico/api/parameters/state.yaml +++ /dev/null @@ -1,11 +0,0 @@ -components: - parameters: - State: - name: state - in: query - required: false - description: | - Payment state filter. Accepts aliases like `accepted`, `settled`, `failed` - or canonical values like `orchestration_state_created`. - schema: - type: string diff --git a/backend/contracts/sendico/api/parameters/states.yaml b/backend/contracts/sendico/api/parameters/states.yaml deleted file mode 100644 index 96d672d..0000000 --- a/backend/contracts/sendico/api/parameters/states.yaml +++ /dev/null @@ -1,10 +0,0 @@ -components: - parameters: - States: - name: states - in: query - required: false - description: | - Multi-state filter. Provide a comma-separated list with same accepted values as `state`. - schema: - type: string diff --git a/backend/contracts/sendico/api/payment_methods/accounts.yaml b/backend/contracts/sendico/api/payment_methods/accounts.yaml deleted file mode 100644 index 9c7fc85..0000000 --- a/backend/contracts/sendico/api/payment_methods/accounts.yaml +++ /dev/null @@ -1,74 +0,0 @@ -get: - tags: [Payment Methods] - summary: List account-owned payment methods - description: | - Returns payment methods owned by `accounts_ref` within `organizations_ref`. - Supports offset pagination and optional archive filtering. - operationId: paymentMethodsListAccountOwned - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/accounts_ref.yaml#/components/parameters/AccountsRef - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - $ref: ../parameters/offset.yaml#/components/parameters/Offset - - $ref: ../parameters/archived.yaml#/components/parameters/Archived - responses: - '200': - description: Payment methods list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -post: - tags: [Payment Methods] - summary: Create account-owned payment method - description: Creates a payment method owned by `accounts_ref` inside `organizations_ref`. - operationId: paymentMethodsCreateAccountOwned - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/accounts_ref.yaml#/components/parameters/AccountsRef - requestBody: - $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodCreateBody - responses: - '201': - description: Payment method created - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/archive.yaml b/backend/contracts/sendico/api/payment_methods/archive.yaml deleted file mode 100644 index 5577220..0000000 --- a/backend/contracts/sendico/api/payment_methods/archive.yaml +++ /dev/null @@ -1,36 +0,0 @@ -post: - tags: [Payment Methods] - summary: Archive/unarchive payment method - description: Sets payment method archive state by `payment_methods_ref` and required `archived` query parameter. - operationId: paymentMethodsArchive - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef - - name: archived - in: query - required: true - description: Target archive value to set on the payment method. - schema: - type: boolean - responses: - '200': - description: Archive state updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml deleted file mode 100644 index 56d488e..0000000 --- a/backend/contracts/sendico/api/payment_methods/bodies/payment_method.yaml +++ /dev/null @@ -1,17 +0,0 @@ -components: - requestBodies: - PaymentMethodCreateBody: - description: JSON body containing a payment method definition for create. - required: true - content: - application/json: - schema: - $ref: ../request/payment_method.yaml#/components/schemas/PaymentMethodCreateRequest - - PaymentMethodUpdateBody: - description: JSON body containing a payment method definition for update. - required: true - content: - application/json: - schema: - $ref: ../request/payment_method.yaml#/components/schemas/PaymentMethodUpdateRequest diff --git a/backend/contracts/sendico/api/payment_methods/object.yaml b/backend/contracts/sendico/api/payment_methods/object.yaml deleted file mode 100644 index a183d57..0000000 --- a/backend/contracts/sendico/api/payment_methods/object.yaml +++ /dev/null @@ -1,102 +0,0 @@ -get: - tags: [Payment Methods] - summary: Get payment method - description: Returns payment method by `payment_methods_ref` inside `organizations_ref`. - operationId: paymentMethodsGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef - responses: - '200': - description: Payment method data - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -patch: - tags: [Payment Methods] - summary: Update payment method - description: Updates mutable payment method fields for the record identified by `payment_methods_ref` inside `organizations_ref`. - operationId: paymentMethodsUpdate - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef - requestBody: - $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodUpdateBody - responses: - '200': - description: Payment method updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -delete: - tags: [Payment Methods] - summary: Delete payment method - description: Deletes payment method by reference. - operationId: paymentMethodsDelete - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/payment_methods_ref.yaml#/components/parameters/Payment MethodsRef - responses: - '200': - description: Payment method deleted - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/recipients.yaml b/backend/contracts/sendico/api/payment_methods/recipients.yaml deleted file mode 100644 index 414ab3f..0000000 --- a/backend/contracts/sendico/api/payment_methods/recipients.yaml +++ /dev/null @@ -1,74 +0,0 @@ -get: - tags: [Payment Methods] - summary: List recipient-owned payment methods - description: | - Returns payment methods owned by `recipients_ref` within `organizations_ref`. - Supports offset pagination and optional archive filtering. - operationId: paymentMethodsListRecipientOwned - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - $ref: ../parameters/offset.yaml#/components/parameters/Offset - - $ref: ../parameters/archived.yaml#/components/parameters/Archived - responses: - '200': - description: Payment methods list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -post: - tags: [Payment Methods] - summary: Create recipient-owned payment method - description: Creates a payment method owned by `recipients_ref` inside `organizations_ref`. - operationId: paymentMethodsCreateRecipientOwned - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef - requestBody: - $ref: ./bodies/payment_method.yaml#/components/requestBodies/PaymentMethodCreateBody - responses: - '201': - description: Payment method created - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment_method.yaml#/components/schemas/Payment MethodsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml deleted file mode 100644 index 6f768e7..0000000 --- a/backend/contracts/sendico/api/payment_methods/request/payment_method.yaml +++ /dev/null @@ -1,42 +0,0 @@ -components: - schemas: - PaymentMethodBaseRequest: - type: object - additionalProperties: false - properties: - name: - description: Optional human-readable payment method name. - type: string - description: - description: Optional free-form payment method description. - type: string - nullable: true - type: - description: Payment method type discriminator. - $ref: ../../../external/payment_method_type.yaml#/components/schemas/PaymentMethodType - data: - description: | - Optional payment-method-type-specific payload. - For `type: bankAccount`, this uses the Russian bank-account - destination shape documented as `BankAccountEndpoint`. - type: object - additionalProperties: true - isMain: - description: Optional primary-method flag. When omitted, current handler behavior falls back to `false`. - type: boolean - - PaymentMethodCreateRequest: - description: | - Request payload used to create a payment method. - Ownership comes from the route path. The body only carries mutable payment-method fields. - required: - - type - allOf: - - $ref: '#/components/schemas/PaymentMethodBaseRequest' - - PaymentMethodUpdateRequest: - description: | - Request payload used to update a payment method. - This is a PATCH contract. Only `name`, `description`, `data`, and `isMain` are mutable. - allOf: - - $ref: '#/components/schemas/PaymentMethodBaseRequest' diff --git a/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml b/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml deleted file mode 100644 index 40cb7aa..0000000 --- a/backend/contracts/sendico/api/payment_methods/response/payment_method.yaml +++ /dev/null @@ -1,18 +0,0 @@ -components: - schemas: - Payment MethodsAuthData: - description: Authenticated response payload containing payment methods. - type: object - additionalProperties: false - required: - - accessToken - - payment_methods - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - payment_methods: - description: Collection of payment methods available to the authenticated account. - type: array - items: - $ref: ../../../models/payment_method/payment_method.yaml#/components/schemas/PaymentMethod diff --git a/backend/contracts/sendico/api/payments/bodies/payment.yaml b/backend/contracts/sendico/api/payments/bodies/payment.yaml deleted file mode 100644 index 4dc0349..0000000 --- a/backend/contracts/sendico/api/payments/bodies/payment.yaml +++ /dev/null @@ -1,33 +0,0 @@ -components: - requestBodies: - QuotePaymentBody: - description: JSON payload used to request a quote for one payment intent. - required: true - content: - application/json: - schema: - $ref: ../request/payment.yaml#/components/schemas/QuotePaymentRequest - - QuotePaymentsBody: - description: JSON payload used to request quotes for multiple payment intents. - required: true - content: - application/json: - schema: - $ref: ../request/payment.yaml#/components/schemas/QuotePaymentsRequest - - InitiatePaymentBody: - description: JSON payload used to initiate a single payment. - required: true - content: - application/json: - schema: - $ref: ../request/payment.yaml#/components/schemas/InitiatePaymentRequest - - InitiatePaymentsBody: - description: JSON payload used to initiate multiple payments from a quote reference. - required: true - content: - application/json: - schema: - $ref: ../request/payment.yaml#/components/schemas/InitiatePaymentsRequest diff --git a/backend/contracts/sendico/api/payments/by_multiquote.yaml b/backend/contracts/sendico/api/payments/by_multiquote.yaml deleted file mode 100644 index 2c41a74..0000000 --- a/backend/contracts/sendico/api/payments/by_multiquote.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Payments] - summary: Initiate payment batch by quote reference - description: | - Executes batch payment from a previously created multi-quote reference. If any quoted item - uses a raw `card` destination, that item's original quote must already include `exp_month` - and `exp_year`. Card-recipient AML screening occurs during execution, not quotation. An - unchanged payment item reuses its persisted compliance decision on idempotent execution - replay, avoiding a duplicate AML call. A definitive denial terminates that payment with - public `failureCode: failure_restriction` and `failureReason: aml_denied`. - operationId: paymentsInitiateBatchByQuote - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - requestBody: - $ref: ./bodies/payment.yaml#/components/requestBodies/InitiatePaymentsBody - responses: - '200': - description: Payment batch initiated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentsData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/by_quote.yaml b/backend/contracts/sendico/api/payments/by_quote.yaml deleted file mode 100644 index 703a988..0000000 --- a/backend/contracts/sendico/api/payments/by_quote.yaml +++ /dev/null @@ -1,41 +0,0 @@ -post: - tags: [Payments] - summary: Initiate payment using quote reference - description: | - Executes one payment from an existing quote reference. If the quoted destination is raw - `card`, the original quote must already include `exp_month` and `exp_year`. Card-recipient - AML screening occurs during execution, not quotation. An unchanged payment reuses its - persisted compliance decision on idempotent execution replay, avoiding a duplicate AML call. - A definitive denial terminates the payment with public `failureCode: failure_restriction` - and `failureReason: aml_denied`. - operationId: paymentsInitiateByQuote - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - requestBody: - required: true - content: - application/json: - schema: - $ref: ./request/payment.yaml#/components/schemas/InitiatePaymentByQuoteRequest - responses: - '200': - description: Payment initiated by quote - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/documents_certificate.yaml b/backend/contracts/sendico/api/payments/documents_certificate.yaml deleted file mode 100644 index e7a3378..0000000 --- a/backend/contracts/sendico/api/payments/documents_certificate.yaml +++ /dev/null @@ -1,47 +0,0 @@ -get: - tags: [Payments] - summary: Download payment certificate - description: | - Returns a generated payment certificate as binary content. - The payment must be settled; the BFF resolves certificate details from the payment reference. - operationId: paymentsGetPaymentCertificate - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - name: payment_ref - in: query - required: true - description: Settled payment reference used to generate the payment certificate. - schema: - type: string - responses: - '200': - description: Payment certificate file - content: - application/pdf: - schema: - type: string - format: binary - application/octet-stream: - schema: - type: string - format: binary - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - '503': - description: Upstream service unavailable - content: - application/json: - schema: - $ref: ../response/response.yaml#/components/schemas/ErrorResponse diff --git a/backend/contracts/sendico/api/payments/fx_currencies.yaml b/backend/contracts/sendico/api/payments/fx_currencies.yaml deleted file mode 100644 index f2ecf5a..0000000 --- a/backend/contracts/sendico/api/payments/fx_currencies.yaml +++ /dev/null @@ -1,40 +0,0 @@ -get: - tags: [Payments] - summary: List available FX currencies - description: | - Returns the BFF startup-cached Treasury Currency Catalog projection for - client-visible FX currencies. `decimals`, `scale`, and `displayScale` are - presentation fields backed by Treasury `display_scale`. - `accountingScaleCandidate` is informational only and must not be used by - clients to determine executable payout precision, quote precision, or - guaranteed provider support. - The endpoint is authenticated and authorized by `organizations_ref`, but the - returned catalogue is global rather than organization-customized. If Treasury - was unavailable at BFF startup while this endpoint was enabled, BFF fails - startup; if Treasury becomes unavailable after startup, BFF continues serving - the cached catalog revision. - operationId: paymentsFXCurrencies - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - responses: - '200': - description: FX currency catalogue projected from Treasury - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/FXCurrenciesData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '503': - $ref: ../response/operation.yaml#/components/responses/ServiceUnavailable diff --git a/backend/contracts/sendico/api/payments/fx_directions.yaml b/backend/contracts/sendico/api/payments/fx_directions.yaml deleted file mode 100644 index 264f35d..0000000 --- a/backend/contracts/sendico/api/payments/fx_directions.yaml +++ /dev/null @@ -1,48 +0,0 @@ -get: - tags: [Payments] - summary: List available FX directions - description: | - Returns organization-level FX direction availability. - - A returned direction means the organization has at least one eligible - direction-level route. It does not mean that every quote or payment for this - direction will be accepted. The actual route, gateway, limits, pricing, fees, - asset domain, and settlement details are resolved during quotation/payment - execution. - - If dependencies are healthy but no organization-eligible executable - directions match oracle quoteable pair sides, the response contains an empty - `directions` array. Oracle or gateway/route registry dependency failures are - returned as errors and are not converted to oracle-only or empty successful - responses. - operationId: paymentsFXDirections - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - responses: - '200': - description: Organization-level directed FX availability - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/FXDirectionsData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '412': - description: Dependency precondition failed - content: - application/json: - schema: - $ref: ../response/response.yaml#/components/schemas/ErrorResponse - '503': - $ref: ../response/operation.yaml#/components/responses/ServiceUnavailable diff --git a/backend/contracts/sendico/api/payments/immediate.yaml b/backend/contracts/sendico/api/payments/immediate.yaml deleted file mode 100644 index 2a7b885..0000000 --- a/backend/contracts/sendico/api/payments/immediate.yaml +++ /dev/null @@ -1,48 +0,0 @@ -post: - tags: [Payments] - summary: Initiate payment from immediate intent - description: | - Creates quote and executes a single payment in one call (request must include `intent`). For - raw `card` destinations, execution requires `exp_month` and `exp_year`; `cardToken` - destinations only require `token`. Card-recipient AML screening occurs during execution. - An unchanged payment reuses its persisted compliance decision on idempotent execution replay, - avoiding a duplicate AML call. A definitive denial terminates the payment with public - `failureCode: failure_restriction` and `failureReason: aml_denied`. - operationId: paymentsInitiateImmediate - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - requestBody: - required: true - content: - application/json: - schema: - allOf: - - $ref: ./request/payment.yaml#/components/schemas/InitiatePaymentRequest - - type: object - required: - - intent - not: - required: - - quoteRef - responses: - '200': - description: Payment initiated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/list.yaml b/backend/contracts/sendico/api/payments/list.yaml deleted file mode 100644 index e01a5c9..0000000 --- a/backend/contracts/sendico/api/payments/list.yaml +++ /dev/null @@ -1,76 +0,0 @@ -get: - tags: [Payments] - summary: List payments in organization - description: | - Returns organization payments with cursor pagination and optional filters. - State filters accept aliases (`accepted`, `settled`, `failed`, etc.) or canonical orchestration states. - operationId: paymentsList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/cursor.yaml#/components/parameters/Cursor - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - name: quotation_ref - in: query - required: false - description: Filter by quotation reference used for execution. - schema: - type: string - - name: quote_ref - in: query - required: false - description: Alias of `quotation_ref`. - schema: - type: string - - name: created_from - in: query - required: false - description: Include payments created at or after this RFC3339 timestamp. - schema: - type: string - format: date-time - - name: createdFrom - in: query - required: false - description: Alias of `created_from`. - schema: - type: string - format: date-time - - name: created_to - in: query - required: false - description: Include payments created before this RFC3339 timestamp (must be after `created_from`). - schema: - type: string - format: date-time - - name: createdTo - in: query - required: false - description: Alias of `created_to`. - schema: - type: string - format: date-time - - $ref: ../parameters/state.yaml#/components/parameters/State - - $ref: ../parameters/states.yaml#/components/parameters/States - - $ref: ../parameters/filter_states.yaml#/components/parameters/FilterStates - responses: - '200': - description: Payments list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentsData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/multiquote.yaml b/backend/contracts/sendico/api/payments/multiquote.yaml deleted file mode 100644 index 3ac6728..0000000 --- a/backend/contracts/sendico/api/payments/multiquote.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Payments] - summary: Quote multiple payment intents - description: | - Calculates a batch quote for multiple payment intents in one request. Each item includes - `executable`, `state`, and optional `blockReason` so callers can skip blocked items. Raw - `card` destinations can omit expiry while quoting, but quoted items intended for later - execution should include `exp_month` and `exp_year`. Card-recipient AML screening is not - performed during quotation; it runs during payment execution, so an executable quote does - not guarantee AML acceptance. - operationId: paymentsMultiQuote - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - requestBody: - $ref: ./bodies/payment.yaml#/components/requestBodies/QuotePaymentsBody - responses: - '200': - description: Quotes generated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentQuotesData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/object.yaml b/backend/contracts/sendico/api/payments/object.yaml deleted file mode 100644 index 0b71752..0000000 --- a/backend/contracts/sendico/api/payments/object.yaml +++ /dev/null @@ -1,31 +0,0 @@ -get: - tags: [Payments] - summary: Get payment by reference - description: Returns a single payment by `payments_ref`. - operationId: paymentsGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/payments_ref.yaml#/components/parameters/PaymentsRef - responses: - '200': - description: Payment data - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/quote.yaml b/backend/contracts/sendico/api/payments/quote.yaml deleted file mode 100644 index d409554..0000000 --- a/backend/contracts/sendico/api/payments/quote.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Payments] - summary: Quote single payment intent - description: | - Calculates quote for one payment intent without executing payment. The response includes - `quote.executable`, `quote.state`, and optional `quote.blockReason` so clients can reject - non-executable quotes before attempting execution. Raw `card` destinations can omit expiry - while quoting, but quotes intended for later execution should include `exp_month` and - `exp_year`. Card-recipient AML screening is not performed during quotation; it runs during - payment execution, so an executable quote does not guarantee AML acceptance. - operationId: paymentsQuote - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - requestBody: - $ref: ./bodies/payment.yaml#/components/requestBodies/QuotePaymentBody - responses: - '200': - description: Quote generated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/payment.yaml#/components/schemas/PaymentQuoteData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/registry.yaml b/backend/contracts/sendico/api/payments/registry.yaml deleted file mode 100644 index 42d796d..0000000 --- a/backend/contracts/sendico/api/payments/registry.yaml +++ /dev/null @@ -1,30 +0,0 @@ -get: - tags: [Payments] - summary: Get discovery registry snapshot for payments dependencies - description: Returns the latest discovery lookup payload used by payments integrations. - operationId: paymentsRegistryList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - responses: - '200': - description: Discovery registry payload - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - type: object - additionalProperties: true - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/registry_refresh.yaml b/backend/contracts/sendico/api/payments/registry_refresh.yaml deleted file mode 100644 index 4719af7..0000000 --- a/backend/contracts/sendico/api/payments/registry_refresh.yaml +++ /dev/null @@ -1,32 +0,0 @@ -get: - tags: [Payments] - summary: Get latest discovery refresh event for payments dependencies - description: Returns the latest refresh event consumed by the payments API. - operationId: paymentsRegistryRefreshGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - responses: - '200': - description: Discovery refresh payload - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - type: - - object - - 'null' - additionalProperties: true - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/payments/request/payment.yaml b/backend/contracts/sendico/api/payments/request/payment.yaml deleted file mode 100644 index 3c425e3..0000000 --- a/backend/contracts/sendico/api/payments/request/payment.yaml +++ /dev/null @@ -1,163 +0,0 @@ -components: - schemas: - PaymentBase: - description: Base fields shared by payment initiation request payloads. - type: object - additionalProperties: false - required: - - idempotencyKey - properties: - idempotencyKey: - description: Client-supplied key used to safely deduplicate requests. - type: string - metadata: - description: Optional request metadata forwarded through payment processing. - type: object - additionalProperties: - type: string - - QuotePaymentRequest: - description: | - Request payload to quote a single payment intent. - For raw `card` destinations, quotation only requires `pan`. - If the quoted payment may later be executed, include `exp_month` and `exp_year` during quotation because execution reuses the quoted endpoint snapshot. - type: object - additionalProperties: false - required: - - intent - properties: - idempotencyKey: - description: Idempotency key used when persisting quote context. - type: string - metadata: - description: Optional metadata associated with the quote request. - type: object - additionalProperties: - type: string - clientPaymentRef: - description: Optional caller-side payment correlation reference to persist with the quote and reuse during execution when the execute request omits it. - type: string - intent: - description: Payment intent to be priced. See `PaymentIntent.destination` for raw-card quote versus execution requirements. - $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent - previewOnly: - description: If true, returns a preview quote without requiring idempotency. - type: boolean - allOf: - - if: - properties: - previewOnly: - const: true - required: - - previewOnly - then: - not: - required: - - idempotencyKey - else: - required: - - idempotencyKey - - QuotePaymentsRequest: - description: | - Request payload to quote multiple payment intents in a single call. - For raw `card` destinations, quotation only requires `pan`. - If quoted items may later be executed, include `exp_month` and `exp_year` during quotation because execution reuses the quoted endpoint snapshot. - type: object - additionalProperties: false - required: - - intents - properties: - idempotencyKey: - description: Idempotency key used when persisting batch quote context. - type: string - metadata: - description: Optional metadata associated with the quote request. - type: object - additionalProperties: - type: string - clientPaymentRef: - description: Optional caller-side batch payment correlation reference to persist with the quote and reuse during execution when the execute request omits it. - type: string - intents: - description: List of payment intents to be priced. See each intent destination for raw-card quote versus execution requirements. - type: array - minItems: 1 - items: - $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent - previewOnly: - description: If true, returns preview quotes without requiring idempotency. - type: boolean - allOf: - - if: - properties: - previewOnly: - const: true - required: - - previewOnly - then: - not: - required: - - idempotencyKey - else: - required: - - idempotencyKey - - InitiatePaymentRequest: - description: | - Request payload to initiate a single payment. - When executing a raw `card` destination from inline `intent`, `pan`, `exp_month`, and `exp_year` must be present. - When executing a `cardToken` destination, only `token` is required by current logic. - allOf: - - $ref: ./payment.yaml#/components/schemas/PaymentBase - - type: object - additionalProperties: false - oneOf: - - required: [intent] - - required: [quoteRef] - properties: - intent: - description: Payment intent to execute directly. Raw `card` destinations require expiry at execution time. - $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentIntent - quoteRef: - description: Reference to a previously generated quote to execute. If that quote contains a raw `card` destination, the original quote must already include `exp_month` and `exp_year`. - type: string - clientPaymentRef: - description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. - type: string - - InitiatePaymentsRequest: - description: | - Request payload to initiate multiple payments from a multi-quote reference. - If any quoted item contains a raw `card` destination, that item's original quote must already include `exp_month` and `exp_year`. - allOf: - - $ref: ./payment.yaml#/components/schemas/PaymentBase - - type: object - additionalProperties: false - required: - - quoteRef - properties: - quoteRef: - description: Reference to a previously generated multi-quote. Raw-card items execute from the stored quoted endpoint snapshot. - type: string - clientPaymentRef: - description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. - type: string - - InitiatePaymentByQuoteRequest: - description: | - Request payload to initiate one payment from an existing quote reference. - If the quote contains a raw `card` destination, the original quote must already include `exp_month` and `exp_year`. - allOf: - - $ref: ./payment.yaml#/components/schemas/PaymentBase - - type: object - additionalProperties: false - required: - - quoteRef - properties: - quoteRef: - description: Reference to a previously generated quote to execute. Execution reuses the stored quoted endpoint snapshot. - type: string - clientPaymentRef: - description: Optional caller-side payment correlation reference. When omitted, execution may reuse the value stored with the quote. - type: string diff --git a/backend/contracts/sendico/api/payments/response/payment.yaml b/backend/contracts/sendico/api/payments/response/payment.yaml deleted file mode 100644 index d4c2938..0000000 --- a/backend/contracts/sendico/api/payments/response/payment.yaml +++ /dev/null @@ -1,114 +0,0 @@ -components: - schemas: - PaymentQuoteData: - description: Response payload for a single payment quote. - type: object - additionalProperties: false - required: - - accessToken - - quote - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - idempotencyKey: - description: Idempotency key associated with the quote response. - type: string - clientPaymentRef: - description: Caller-supplied payment correlation reference echoed from the quote request, when provided. - type: string - quote: - description: Generated quote data for the requested payment intent. - $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentQuote - - PaymentQuotesData: - description: Response payload for a batch quote request. - type: object - additionalProperties: false - required: - - accessToken - - quote - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - quote: - description: Batch quote summary and quoted items. - type: object - additionalProperties: false - properties: - idempotencyKey: - description: Idempotency key associated with the batch quote response. - type: string - clientPaymentRef: - description: Caller-supplied payment correlation reference echoed from the batch quote request, when provided. - type: string - quoteRef: - description: Reference to the generated batch quote. - type: string - items: - description: Collection of quotes for each requested payment intent. - type: array - items: - $ref: ../../../models/payment/payment.yaml#/components/schemas/PaymentQuote - - PaymentsData: - description: Response payload containing a list of payments. - type: object - additionalProperties: false - required: - - accessToken - - payments - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - payments: - description: Collection of payment records. - type: array - items: - $ref: ../../../models/payment/payment.yaml#/components/schemas/Payment - page: - description: Pagination cursor metadata for payment listing endpoints. - $ref: ../../../models/common/pagination.yaml#/components/schemas/CursorPageResponse - - PaymentData: - description: Response payload containing a single payment record. - type: object - additionalProperties: false - required: - - accessToken - - payment - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - payment: - description: Requested payment record. - $ref: ../../../models/payment/payment.yaml#/components/schemas/Payment - - FXCurrenciesData: - description: Response payload containing the BFF startup-cached Treasury FX currency catalogue projection. - type: object - additionalProperties: false - required: - - currencies - properties: - currencies: - description: Client-visible currencies backed by Treasury Currency Catalog metadata. - type: array - items: - $ref: ../../../models/payment/fx_metadata.yaml#/components/schemas/FXCurrency - - FXDirectionsData: - description: Response payload containing organization-level directed FX availability. - type: object - additionalProperties: false - required: - - directions - properties: - directions: - description: Directions with at least one eligible direction-level route for the organization. - type: array - items: - $ref: ../../../models/payment/fx_metadata.yaml#/components/schemas/FXDirection diff --git a/backend/contracts/sendico/api/recipients/archive.yaml b/backend/contracts/sendico/api/recipients/archive.yaml deleted file mode 100644 index 4d3cb3f..0000000 --- a/backend/contracts/sendico/api/recipients/archive.yaml +++ /dev/null @@ -1,37 +0,0 @@ -get: - tags: [Recipients] - summary: Archive/unarchive recipient - description: Sets recipient archive state by `recipients_ref` and required `archived` query parameter. - operationId: recipientsArchive - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef - - name: archived - in: query - required: true - description: Target archive value to set on the recipient. - schema: - type: boolean - - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade - responses: - '200': - description: Archive state updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/bodies/recipient.yaml b/backend/contracts/sendico/api/recipients/bodies/recipient.yaml deleted file mode 100644 index 5785002..0000000 --- a/backend/contracts/sendico/api/recipients/bodies/recipient.yaml +++ /dev/null @@ -1,17 +0,0 @@ -components: - requestBodies: - RecipientCreateBody: - description: JSON body containing recipient details for create. - required: true - content: - application/json: - schema: - $ref: ../request/recipient.yaml#/components/schemas/RecipientCreateRequest - - RecipientUpdateBody: - description: JSON body containing recipient details for update. - required: true - content: - application/json: - schema: - $ref: ../request/recipient.yaml#/components/schemas/RecipientUpdateRequest diff --git a/backend/contracts/sendico/api/recipients/create.yaml b/backend/contracts/sendico/api/recipients/create.yaml deleted file mode 100644 index 10fc1ba..0000000 --- a/backend/contracts/sendico/api/recipients/create.yaml +++ /dev/null @@ -1,33 +0,0 @@ -post: - tags: [Recipients] - summary: Create recipient - description: Creates a recipient inside the organization identified by `org_ref`. - operationId: recipientsCreate - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - requestBody: - $ref: ./bodies/recipient.yaml#/components/requestBodies/RecipientCreateBody - responses: - '201': - description: Recipient created - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/list.yaml b/backend/contracts/sendico/api/recipients/list.yaml deleted file mode 100644 index 33ca97f..0000000 --- a/backend/contracts/sendico/api/recipients/list.yaml +++ /dev/null @@ -1,36 +0,0 @@ -get: - tags: [Recipients] - summary: List recipients - description: | - Returns recipients for the specified organization context. - Supports offset pagination and optional archive filtering. - The route currently includes both `org_ref` and `organizations_ref`; the handler resolves organization context from `org_ref` and ignores `organizations_ref`. - operationId: recipientsList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - $ref: ../parameters/limit.yaml#/components/parameters/Limit - - $ref: ../parameters/offset.yaml#/components/parameters/Offset - - $ref: ../parameters/archived.yaml#/components/parameters/Archived - responses: - '200': - description: Recipient list - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/object.yaml b/backend/contracts/sendico/api/recipients/object.yaml deleted file mode 100644 index 091e5c7..0000000 --- a/backend/contracts/sendico/api/recipients/object.yaml +++ /dev/null @@ -1,64 +0,0 @@ -get: - tags: [Recipients] - summary: Get recipient by reference - description: Returns a recipient by `recipients_ref`. - operationId: recipientsGet - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef - responses: - '200': - description: Recipient data - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError - -delete: - tags: [Recipients] - summary: Delete recipient - description: Deletes recipient by reference. Use `cascade=true` to remove dependent objects when supported. - operationId: recipientsDelete - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/recipients_ref.yaml#/components/parameters/RecipientsRef - - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade - responses: - '200': - description: Recipient deleted - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/recipients/request/recipient.yaml b/backend/contracts/sendico/api/recipients/request/recipient.yaml deleted file mode 100644 index 19656c4..0000000 --- a/backend/contracts/sendico/api/recipients/request/recipient.yaml +++ /dev/null @@ -1,53 +0,0 @@ -components: - schemas: - RecipientBaseRequest: - type: object - additionalProperties: false - properties: - id: - description: Optional recipient identifier. Required by the current update handler. - $ref: ../../../models/objectid.yaml#/components/schemas/ObjectId - name: - description: Optional human-readable recipient name. - type: string - description: - description: Optional free-form recipient description. - type: string - nullable: true - email: - description: Optional recipient email address. - type: string - format: email - avatarUrl: - description: Optional avatar URL. - type: string - nullable: true - status: - description: Optional recipient status. When omitted, current handler behavior falls back to the model zero value (`ready`). - type: string - enum: - - ready - - registered - - notRegistered - type: - description: Optional recipient type. When omitted, current handler behavior falls back to the model zero value (`internal`). - type: string - enum: - - internal - - external - - RecipientCreateRequest: - description: | - Request payload used to create a recipient. - The current create handler does not require any JSON fields beyond a valid object payload; organization context comes from the `org_ref` path parameter. - allOf: - - $ref: '#/components/schemas/RecipientBaseRequest' - - RecipientUpdateRequest: - description: | - Request payload used to update a recipient. - The current PUT handler targets the record by `id` and then replaces it with the submitted values; omitted fields may therefore be reset to zero values. - required: - - id - allOf: - - $ref: '#/components/schemas/RecipientBaseRequest' diff --git a/backend/contracts/sendico/api/recipients/response/recipient.yaml b/backend/contracts/sendico/api/recipients/response/recipient.yaml deleted file mode 100644 index 337b850..0000000 --- a/backend/contracts/sendico/api/recipients/response/recipient.yaml +++ /dev/null @@ -1,18 +0,0 @@ -components: - schemas: - RecipientsAuthData: - description: Authenticated response payload containing recipients. - type: object - additionalProperties: false - required: - - accessToken - - recipients - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - recipients: - description: Collection of recipients available to the authenticated account. - type: array - items: - $ref: ../../../models/recipient/recipient.yaml#/components/schemas/Recipient diff --git a/backend/contracts/sendico/api/recipients/update.yaml b/backend/contracts/sendico/api/recipients/update.yaml deleted file mode 100644 index f814f5b..0000000 --- a/backend/contracts/sendico/api/recipients/update.yaml +++ /dev/null @@ -1,31 +0,0 @@ -put: - tags: [Recipients] - summary: Update recipient - description: Updates recipient fields from request payload. The current handler requires `id`; omitted fields may be replaced with zero values. - operationId: recipientsUpdate - security: - - bearerAuth: [] - requestBody: - $ref: ./bodies/recipient.yaml#/components/requestBodies/RecipientUpdateBody - responses: - '200': - description: Recipient updated - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/recipient.yaml#/components/schemas/RecipientsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/response/error.yaml b/backend/contracts/sendico/api/response/error.yaml deleted file mode 100644 index c4a7014..0000000 --- a/backend/contracts/sendico/api/response/error.yaml +++ /dev/null @@ -1,24 +0,0 @@ -components: - schemas: - ApiError: - description: Standard API error envelope with optional debugging details. - type: object - additionalProperties: false - required: - - code - - error - - source - properties: - code: - description: Application-specific numeric error code. - type: integer - format: int32 - error: - description: Human-readable error summary. - type: string - source: - description: Component or subsystem that produced the error. - type: string - details: - description: Optional detailed message providing additional failure context. - type: string diff --git a/backend/contracts/sendico/api/response/operation.yaml b/backend/contracts/sendico/api/response/operation.yaml deleted file mode 100644 index 08c5a5f..0000000 --- a/backend/contracts/sendico/api/response/operation.yaml +++ /dev/null @@ -1,85 +0,0 @@ -components: - responses: - Ok: - description: Successful response - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/BaseResponse - - Created: - description: Resource created - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/BaseResponse - - Accepted: - description: Request accepted for processing - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/BaseResponse - - BadRequest: - description: Bad request payload or query/path parameter - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - Unauthorized: - description: Unauthorized - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - Forbidden: - description: Forbidden - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - NotFound: - description: Resource not found - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - Gone: - description: Resource is no longer available - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - Conflict: - description: Conflict - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - TooManyRequests: - description: Too many requests - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - ServiceUnavailable: - description: Service unavailable - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse - - InternalServerError: - description: Internal server error - content: - application/json: - schema: - $ref: ./response.yaml#/components/schemas/ErrorResponse diff --git a/backend/contracts/sendico/api/response/response.yaml b/backend/contracts/sendico/api/response/response.yaml deleted file mode 100644 index 89589e5..0000000 --- a/backend/contracts/sendico/api/response/response.yaml +++ /dev/null @@ -1,47 +0,0 @@ -components: - schemas: - BaseResponse: - type: object - additionalProperties: false - required: - - status - - data - properties: - status: - $ref: ./status.yaml#/components/schemas/ApiStatus - data: - description: Payload for success or error responses. - - ErrorResponse: - allOf: - - $ref: ./response.yaml#/components/schemas/BaseResponse - - type: object - properties: - status: - type: string - enum: - - error - data: - $ref: ./error.yaml#/components/schemas/ApiError - - SuccessResultData: - type: object - additionalProperties: false - required: - - result - properties: - result: - type: boolean - - SuccessResultResponse: - allOf: - - $ref: ./response.yaml#/components/schemas/BaseResponse - - type: object - properties: - status: - type: string - enum: - - success - - processed - data: - $ref: ./response.yaml#/components/schemas/SuccessResultData diff --git a/backend/contracts/sendico/api/response/status.yaml b/backend/contracts/sendico/api/response/status.yaml deleted file mode 100644 index 1a7e123..0000000 --- a/backend/contracts/sendico/api/response/status.yaml +++ /dev/null @@ -1,10 +0,0 @@ -components: - schemas: - ApiStatus: - type: string - description: Unified response status used by backend wrappers. - enum: - - success - - processed - - error - - request diff --git a/backend/contracts/sendico/api/verification/bodies/verification.yaml b/backend/contracts/sendico/api/verification/bodies/verification.yaml deleted file mode 100644 index da9dbd6..0000000 --- a/backend/contracts/sendico/api/verification/bodies/verification.yaml +++ /dev/null @@ -1,17 +0,0 @@ -components: - requestBodies: - VerificationCodeBody: - description: JSON payload used to request a new verification code. - required: true - content: - application/json: - schema: - $ref: ../request/verification.yaml#/components/schemas/VerificationCodeRequest - - VerifyCodeBody: - description: JSON payload containing a verification code to validate. - required: true - content: - application/json: - schema: - $ref: ../request/verification.yaml#/components/schemas/CodeVerificationRequest diff --git a/backend/contracts/sendico/api/verification/create.yaml b/backend/contracts/sendico/api/verification/create.yaml deleted file mode 100644 index 9dd4e97..0000000 --- a/backend/contracts/sendico/api/verification/create.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Verification, Auth] - summary: Request verification code - description: | - Creates a verification challenge for the specified purpose/target. - For `purpose=login`, request must be authorized with a pending account token. - operationId: verificationRequestCode - security: - - bearerAuth: [] - requestBody: - $ref: ./bodies/verification.yaml#/components/requestBodies/VerificationCodeBody - responses: - '202': - description: Verification code requested - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/verification.yaml#/components/schemas/VerificationResponseData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '429': - $ref: ../response/operation.yaml#/components/responses/TooManyRequests - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/verification/request/verification.yaml b/backend/contracts/sendico/api/verification/request/verification.yaml deleted file mode 100644 index a005098..0000000 --- a/backend/contracts/sendico/api/verification/request/verification.yaml +++ /dev/null @@ -1,73 +0,0 @@ -components: - schemas: - VerificationCodeRequest: - type: object - additionalProperties: false - required: - - purpose - properties: - purpose: - type: string - description: Verification purpose, e.g. login. - target: - type: string - format: email - description: Optional destination override. When omitted, current handler falls back to the authenticated account email/login. - idempotencyKey: - type: string - quoteRef: - description: Required when `purpose` is `payout`; omitted for login and other verification purposes. - type: string - allOf: - - if: - properties: - purpose: - const: payout - required: - - purpose - then: - required: - - quoteRef - - CodeVerificationRequest: - type: object - additionalProperties: false - required: - - purpose - - code - properties: - purpose: - type: string - description: Verification purpose, e.g. login. - target: - type: string - format: email - description: Optional destination override. When omitted, current handler falls back to the authenticated account email/login. - idempotencyKey: - type: string - quoteRef: - description: Required when `purpose` is `payout`; omitted for login and other verification purposes. - type: string - code: - type: string - sessionIdentifier: - $ref: ../../../models/auth/session_identifier.yaml#/components/schemas/SessionIdentifier - allOf: - - if: - properties: - purpose: - const: login - required: - - purpose - then: - required: - - sessionIdentifier - - if: - properties: - purpose: - const: payout - required: - - purpose - then: - required: - - quoteRef diff --git a/backend/contracts/sendico/api/verification/resend.yaml b/backend/contracts/sendico/api/verification/resend.yaml deleted file mode 100644 index d2ee0fc..0000000 --- a/backend/contracts/sendico/api/verification/resend.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Verification, Auth] - summary: Resend verification code - description: | - Re-issues a verification challenge using the same request shape as `/verification`. - Cooldown and retry limits are enforced by backend verification storage policy. - operationId: verificationResendCode - security: - - bearerAuth: [] - requestBody: - $ref: ./bodies/verification.yaml#/components/requestBodies/VerificationCodeBody - responses: - '202': - description: Verification code resent - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/verification.yaml#/components/schemas/VerificationResponseData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '429': - $ref: ../response/operation.yaml#/components/responses/TooManyRequests - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/verification/response/verification.yaml b/backend/contracts/sendico/api/verification/response/verification.yaml deleted file mode 100644 index f169ab7..0000000 --- a/backend/contracts/sendico/api/verification/response/verification.yaml +++ /dev/null @@ -1,35 +0,0 @@ -components: - schemas: - VerificationResponseData: - description: Response data returned after requesting a verification code. - type: object - additionalProperties: false - required: - - idempotencyKey - - ttl_seconds - - cooldown_seconds - - target - properties: - idempotencyKey: - description: Idempotency key associated with the verification request. - type: string - ttl_seconds: - description: Verification code validity period in seconds. - type: integer - format: int32 - cooldown_seconds: - description: Cooldown period before another code can be requested. - type: integer - format: int32 - target: - description: Destination where the verification code was sent. - type: string - quoteRef: - description: Payment quote reference echoed for payout verification requests. - type: string - - VerifyResultData: - description: Result payload returned after code verification succeeds. - oneOf: - - $ref: ../../accounts/response/auth.yaml#/components/schemas/LoginData - - $ref: ../../response/response.yaml#/components/schemas/SuccessResultData diff --git a/backend/contracts/sendico/api/verification/verify.yaml b/backend/contracts/sendico/api/verification/verify.yaml deleted file mode 100644 index cf26d7b..0000000 --- a/backend/contracts/sendico/api/verification/verify.yaml +++ /dev/null @@ -1,37 +0,0 @@ -post: - tags: [Verification, Auth] - summary: Verify code and complete pending flow - description: | - Verifies submitted code and consumes verification token. - For login purpose, returns full auth payload (access + refresh tokens); for other purposes returns operation result. - operationId: verificationVerifyCode - security: - - bearerAuth: [] - requestBody: - $ref: ./bodies/verification.yaml#/components/requestBodies/VerifyCodeBody - responses: - '200': - description: Verification successful - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/verification.yaml#/components/schemas/VerifyResultData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '404': - $ref: ../response/operation.yaml#/components/responses/NotFound - '410': - $ref: ../response/operation.yaml#/components/responses/Gone - '409': - $ref: ../response/operation.yaml#/components/responses/Conflict - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/wallets/assets.yaml b/backend/contracts/sendico/api/wallets/assets.yaml deleted file mode 100644 index 17c8b22..0000000 --- a/backend/contracts/sendico/api/wallets/assets.yaml +++ /dev/null @@ -1,35 +0,0 @@ -get: - tags: [Wallets] - summary: List live managed-wallet assets - description: | - Returns the live wallet asset catalogue currently announced by healthy CRYPTO - gateways in discovery. The endpoint is authenticated and authorized by - `organizations_ref`, but the returned asset list is gateway-driven rather than - organization-customized. Malformed gateway asset announcements are skipped. - If discovery is available but no valid wallet assets are currently announced, - the response contains an empty `assets` array. - operationId: walletAssetsList - security: - - bearerAuth: [] - parameters: - - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - responses: - '200': - description: Live managed-wallet asset catalogue - content: - application/json: - schema: - allOf: - - $ref: ../response/response.yaml#/components/schemas/BaseResponse - - type: object - properties: - data: - $ref: ./response/wallet.yaml#/components/schemas/WalletAssetsAuthData - '400': - $ref: ../response/operation.yaml#/components/responses/BadRequest - '401': - $ref: ../response/operation.yaml#/components/responses/Unauthorized - '403': - $ref: ../response/operation.yaml#/components/responses/Forbidden - '500': - $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/backend/contracts/sendico/api/wallets/response/wallet.yaml b/backend/contracts/sendico/api/wallets/response/wallet.yaml deleted file mode 100644 index b390b85..0000000 --- a/backend/contracts/sendico/api/wallets/response/wallet.yaml +++ /dev/null @@ -1,18 +0,0 @@ -components: - schemas: - WalletAssetsAuthData: - description: Authenticated response payload containing live wallet assets. - type: object - additionalProperties: false - required: - - accessToken - - assets - properties: - accessToken: - description: Refreshed access token to be used in subsequent API calls. - $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData - assets: - description: Managed-wallet assets currently announced by healthy CRYPTO gateways. - type: array - items: - $ref: ../../../models/wallet/wallet_asset.yaml#/components/schemas/WalletAsset diff --git a/backend/contracts/sendico/external/chain_network.yaml b/backend/contracts/sendico/external/chain_network.yaml deleted file mode 100644 index c577011..0000000 --- a/backend/contracts/sendico/external/chain_network.yaml +++ /dev/null @@ -1,10 +0,0 @@ -components: - schemas: - ChainNetwork: - type: string - enum: - - unspecified - - ethereum_mainnet - - arbitrum_one - - tron_mainnet - - tron_nile diff --git a/backend/contracts/sendico/external/endpoint_type.yaml b/backend/contracts/sendico/external/endpoint_type.yaml deleted file mode 100644 index 3a23e42..0000000 --- a/backend/contracts/sendico/external/endpoint_type.yaml +++ /dev/null @@ -1,15 +0,0 @@ -components: - schemas: - EndpointType: - type: string - enum: - - paymentMethod - - account - - recipient - - ledger - - managedWallet - - cryptoAddress - - card - - cardToken - - bankAccount - - iban diff --git a/backend/contracts/sendico/external/fx_side.yaml b/backend/contracts/sendico/external/fx_side.yaml deleted file mode 100644 index 639e6f2..0000000 --- a/backend/contracts/sendico/external/fx_side.yaml +++ /dev/null @@ -1,7 +0,0 @@ -components: - schemas: - FxSide: - type: string - enum: - - buy_base_sell_quote - - sell_base_buy_quote diff --git a/backend/contracts/sendico/external/payment_kind.yaml b/backend/contracts/sendico/external/payment_kind.yaml deleted file mode 100644 index 06f68ef..0000000 --- a/backend/contracts/sendico/external/payment_kind.yaml +++ /dev/null @@ -1,8 +0,0 @@ -components: - schemas: - PaymentKind: - type: string - enum: - - payout - - internal_transfer - - fx_conversion diff --git a/backend/contracts/sendico/external/payment_method_type.yaml b/backend/contracts/sendico/external/payment_method_type.yaml deleted file mode 100644 index c8c4e84..0000000 --- a/backend/contracts/sendico/external/payment_method_type.yaml +++ /dev/null @@ -1,12 +0,0 @@ -components: - schemas: - PaymentMethodType: - type: string - enum: - - iban - - card - - cardToken - - bankAccount - - wallet - - cryptoAddress - - ledger diff --git a/backend/contracts/sendico/external/payment_state.yaml b/backend/contracts/sendico/external/payment_state.yaml deleted file mode 100644 index 9685cd8..0000000 --- a/backend/contracts/sendico/external/payment_state.yaml +++ /dev/null @@ -1,11 +0,0 @@ -components: - schemas: - PaymentState: - type: string - enum: - - orchestration_state_unspecified - - orchestration_state_created - - orchestration_state_executing - - orchestration_state_needs_attention - - orchestration_state_settled - - orchestration_state_failed diff --git a/backend/contracts/sendico/external/settlement_mode.yaml b/backend/contracts/sendico/external/settlement_mode.yaml deleted file mode 100644 index e5531c9..0000000 --- a/backend/contracts/sendico/external/settlement_mode.yaml +++ /dev/null @@ -1,8 +0,0 @@ -components: - schemas: - SettlementMode: - type: string - enum: - - unspecified - - fix_source - - fix_received diff --git a/backend/contracts/sendico/models/account/account.yaml b/backend/contracts/sendico/models/account/account.yaml deleted file mode 100644 index b47ae25..0000000 --- a/backend/contracts/sendico/models/account/account.yaml +++ /dev/null @@ -1,15 +0,0 @@ -components: - schemas: - AccountData: - description: Extended account payload including visibility-specific metadata. - allOf: - - $ref: ./account_public.yaml#/components/schemas/AccountPublic - - type: object - description: Account attributes that are not part of the public profile. - additionalProperties: false - required: - - isAnonymous - properties: - isAnonymous: - description: Indicates whether the account is marked as anonymous. - type: boolean diff --git a/backend/contracts/sendico/models/account/account_public.yaml b/backend/contracts/sendico/models/account/account_public.yaml deleted file mode 100644 index 3f80789..0000000 --- a/backend/contracts/sendico/models/account/account_public.yaml +++ /dev/null @@ -1,33 +0,0 @@ -components: - schemas: - AccountPublic: - description: Public account profile data exposed in API responses. - allOf: - - $ref: ../storable.yaml#/components/schemas/Storable - - $ref: ../common/describable.yaml#/components/schemas/Describable - - type: object - description: Account-specific public attributes. - additionalProperties: false - required: - - login - - locale - - lastName - - isArchived - properties: - login: - description: Account login identifier represented as an email address. - type: string - format: email - locale: - description: Preferred locale used for account-facing content. - type: string - lastName: - description: Account holder last name. - type: string - avatarUrl: - description: Optional URL of the account avatar image. - type: string - nullable: true - isArchived: - description: Indicates whether the account is archived and inactive. - type: boolean diff --git a/backend/contracts/sendico/models/auth/client_refresh_token.yaml b/backend/contracts/sendico/models/auth/client_refresh_token.yaml deleted file mode 100644 index 43408e3..0000000 --- a/backend/contracts/sendico/models/auth/client_refresh_token.yaml +++ /dev/null @@ -1,15 +0,0 @@ -components: - schemas: - ClientRefreshToken: - description: Refresh token bound to a specific client and device session. - allOf: - - $ref: ./session_identifier.yaml#/components/schemas/SessionIdentifier - - type: object - description: Refresh-token payload associated with the session identifier. - additionalProperties: false - required: - - token - properties: - token: - description: Long-lived token used to obtain a new access token. - type: string diff --git a/backend/contracts/sendico/models/auth/login_data.yaml b/backend/contracts/sendico/models/auth/login_data.yaml deleted file mode 100644 index 480b3d2..0000000 --- a/backend/contracts/sendico/models/auth/login_data.yaml +++ /dev/null @@ -1,21 +0,0 @@ -components: - schemas: - LoginData: - description: Credentials and optional locale used during user sign-in. - type: object - additionalProperties: false - required: - - login - - password - properties: - login: - description: User login identifier, represented as an email address. - type: string - format: email - password: - description: User password submitted for authentication. - type: string - format: password - locale: - description: Optional locale preference provided at login time. - type: string diff --git a/backend/contracts/sendico/models/auth/session_identifier.yaml b/backend/contracts/sendico/models/auth/session_identifier.yaml deleted file mode 100644 index fba55b0..0000000 --- a/backend/contracts/sendico/models/auth/session_identifier.yaml +++ /dev/null @@ -1,16 +0,0 @@ -components: - schemas: - SessionIdentifier: - description: Identifies an authentication session by client and device. - type: object - additionalProperties: false - required: - - clientId - - deviceId - properties: - clientId: - description: Client application identifier associated with the session. - type: string - deviceId: - description: Device/server identifier associated with the client session. - type: string diff --git a/backend/contracts/sendico/models/auth/token_data.yaml b/backend/contracts/sendico/models/auth/token_data.yaml deleted file mode 100644 index 5e6264d..0000000 --- a/backend/contracts/sendico/models/auth/token_data.yaml +++ /dev/null @@ -1,17 +0,0 @@ -components: - schemas: - TokenData: - description: Authentication token payload with expiration metadata. - type: object - additionalProperties: false - required: - - token - - expiration - properties: - token: - description: Issued authentication token value. - type: string - expiration: - description: RFC 3339 timestamp when the token expires. - type: string - format: date-time diff --git a/backend/contracts/sendico/models/callback/callback.yaml b/backend/contracts/sendico/models/callback/callback.yaml deleted file mode 100644 index f555a05..0000000 --- a/backend/contracts/sendico/models/callback/callback.yaml +++ /dev/null @@ -1,152 +0,0 @@ -components: - schemas: - CallbackBackoff: - description: Backoff window used between callback retry attempts. - type: object - additionalProperties: false - required: - - minDelayMs - - maxDelayMs - properties: - minDelayMs: - description: Minimum delay in milliseconds before scheduling a retry attempt. - type: integer - minimum: 1 - maxDelayMs: - description: Maximum delay in milliseconds before scheduling a retry attempt. - type: integer - minimum: 1 - - CallbackRetryPolicy: - description: Retry and delivery behavior for callback requests. - type: object - additionalProperties: false - required: - - backoff - - signingMode - - maxAttempts - - requestTimeoutMs - properties: - backoff: - description: Delay boundaries used to compute retry backoff between attempts. - $ref: '#/components/schemas/CallbackBackoff' - signingMode: - description: Request-signing strategy applied when dispatching callback payloads. `jws_eddsa_ed25519` sends the raw JSON body unchanged and includes a detached compact JWS in `X-Callback-JWS`. - type: string - enum: - - none - - hmac_sha256 - - jws_eddsa_ed25519 - headers: - description: Additional HTTP headers included with every callback request. - type: object - additionalProperties: - type: string - maxAttempts: - description: Maximum number of delivery attempts before marking dispatch as failed. - type: integer - minimum: 1 - requestTimeoutMs: - description: Per-request timeout in milliseconds for callback HTTP calls. - type: integer - minimum: 1 - - CallbackSigningJWK: - description: Ed25519 verification key published for callbacks that use detached JWS signing. - type: object - additionalProperties: false - required: - - kty - - crv - - alg - - use - - kid - - x - properties: - kty: - type: string - enum: [OKP] - crv: - type: string - enum: [Ed25519] - alg: - type: string - enum: [EdDSA] - use: - type: string - enum: [sig] - kid: - description: Opaque key identifier mirrored into the protected JWS header and `X-Callback-Key-Id`. - type: string - x: - description: Base64url-encoded Ed25519 public key bytes used to verify the detached JWS signature. - type: string - - CallbackSigningKey: - description: Verification material exposed for callbacks that use `jws_eddsa_ed25519`. Receivers verify `X-Callback-JWS` against the raw request body with the matching `kid`. Retiring keys remain available for 24 hours after rotation so queued deliveries remain verifiable. - type: object - additionalProperties: false - required: - - status - - jwk - properties: - status: - description: Indicates whether the key is the current signing key or a still-valid retiring key from a recent rotation. - type: string - enum: - - active - - retiring - expiresAt: - description: Timestamp after which a retiring key is no longer returned in callback reads. - type: string - format: date-time - nullable: true - jwk: - $ref: '#/components/schemas/CallbackSigningJWK' - - CallbackContent: - description: Callback endpoint configuration and delivery parameters. - allOf: - - type: object - additionalProperties: false - required: - - status - - url - - eventTypes - - retryPolicy - properties: - status: - description: Operational state controlling whether callback delivery is enabled. - type: string - enum: - - active - - disabled - url: - description: Absolute HTTPS endpoint URL that receives callback events. - type: string - format: uri - eventTypes: - description: Event type names that trigger callback delivery to this endpoint. - type: array - items: - type: string - retryPolicy: - description: Retry and timeout parameters used when dispatching callback events. - $ref: '#/components/schemas/CallbackRetryPolicy' - - - Callback: - description: Stored callback configuration bound to an organization permission scope. - allOf: - - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound - - $ref: ../common/describable.yaml#/components/schemas/Describable - - $ref: '#/components/schemas/CallbackContent' - - type: object - additionalProperties: false - properties: - signingKeys: - description: Read-only verification keys for callbacks that use detached JWS signing. The callback JSON body is not wrapped; receivers verify `X-Callback-JWS` against the raw body bytes and use the key matching the protected-header `kid`. - type: array - readOnly: true - items: - $ref: '#/components/schemas/CallbackSigningKey' diff --git a/backend/contracts/sendico/models/common/describable.yaml b/backend/contracts/sendico/models/common/describable.yaml deleted file mode 100644 index 1bf6d5b..0000000 --- a/backend/contracts/sendico/models/common/describable.yaml +++ /dev/null @@ -1,16 +0,0 @@ -components: - schemas: - Describable: - description: Common naming metadata for user-defined entities. - type: object - additionalProperties: false - required: - - name - properties: - name: - description: Human-readable name of the entity. - type: string - description: - description: Optional free-form description of the entity. - type: string - nullable: true diff --git a/backend/contracts/sendico/models/common/money.yaml b/backend/contracts/sendico/models/common/money.yaml deleted file mode 100644 index b130b4c..0000000 --- a/backend/contracts/sendico/models/common/money.yaml +++ /dev/null @@ -1,18 +0,0 @@ -components: - schemas: - Money: - description: Monetary value represented by a decimal amount and ISO currency code. - type: object - additionalProperties: false - required: - - amount - - currency - properties: - amount: - type: string - description: Decimal string amount. - example: '125.50' - currency: - description: ISO 4217 currency code for the provided amount. - type: string - example: USD diff --git a/backend/contracts/sendico/models/common/pagination.yaml b/backend/contracts/sendico/models/common/pagination.yaml deleted file mode 100644 index 2a923c3..0000000 --- a/backend/contracts/sendico/models/common/pagination.yaml +++ /dev/null @@ -1,10 +0,0 @@ -components: - schemas: - CursorPageResponse: - description: Cursor-based pagination metadata returned with list responses. - type: object - additionalProperties: false - properties: - next_cursor: - description: Opaque cursor to request the next page; omitted when no more items exist. - type: string diff --git a/backend/contracts/sendico/models/ledger/balance.yaml b/backend/contracts/sendico/models/ledger/balance.yaml deleted file mode 100644 index df4423b..0000000 --- a/backend/contracts/sendico/models/ledger/balance.yaml +++ /dev/null @@ -1,53 +0,0 @@ -components: - schemas: - LedgerAccountBalance: - description: Canonical ledger account balance. Current and historical modes have distinct field-presence semantics. - oneOf: - - $ref: ./balance.yaml#/components/schemas/CurrentLedgerAccountBalance - - $ref: ./balance.yaml#/components/schemas/HistoricalLedgerAccountBalance - - CurrentLedgerAccountBalance: - description: Current materialized ledger account balance. - type: object - additionalProperties: false - required: - - ledgerAccountRef - - balance - - version - - lastUpdated - properties: - ledgerAccountRef: - description: Ledger account reference. - $ref: ../objectid.yaml#/components/schemas/ObjectId - balance: - $ref: ../common/money.yaml#/components/schemas/Money - version: - description: Materialized account-balance version. - type: integer - format: int64 - lastUpdated: - description: Materialized balance update timestamp. - type: string - format: date-time - - HistoricalLedgerAccountBalance: - description: | - Event-time accounting balance reconstructed from posted statement-entry deltas - where `eventTime <= asOf`. This is not a materialized historical snapshot. - type: object - additionalProperties: false - required: - - ledgerAccountRef - - balance - - asOf - properties: - ledgerAccountRef: - description: Ledger account reference. - $ref: ../objectid.yaml#/components/schemas/ObjectId - balance: - $ref: ../common/money.yaml#/components/schemas/Money - asOf: - description: Canonical UTC RFC3339 event-time cutoff used for reconstruction. - type: string - format: date-time - example: '2026-04-23T12:30:00Z' diff --git a/backend/contracts/sendico/models/ledger/operation.yaml b/backend/contracts/sendico/models/ledger/operation.yaml deleted file mode 100644 index 60cb2bb..0000000 --- a/backend/contracts/sendico/models/ledger/operation.yaml +++ /dev/null @@ -1,124 +0,0 @@ -components: - schemas: - LedgerStatementVisibility: - description: Visibility of the account-history projection returned for the requested ledger account. - type: string - enum: - - full - - partial - - LedgerEntryType: - description: Lowercase ledger entry type string from the BFF response. - type: string - example: transfer - - LedgerLineType: - description: Lowercase posting-line type string from the BFF response. - type: string - example: main - - LedgerAccountOperationSummary: - description: | - Account-history summary projection for a ledger statement entry. - `accountAmount` is the exact decimal sum of visible posting-line amounts whose `ledgerAccountRef` - equals the requested statement account. Positive means net credit, negative means net debit, - zero means net neutral, and the currency matches the requested ledger account currency. - type: object - additionalProperties: false - required: - - entryRef - - entryType - - accountAmount - - eventTime - - version - - visibility - properties: - entryRef: - description: Statement entry reference. - $ref: ../objectid.yaml#/components/schemas/ObjectId - entryType: - $ref: ./operation.yaml#/components/schemas/LedgerEntryType - accountAmount: - $ref: ../common/money.yaml#/components/schemas/Money - description: - description: Human-readable entry description when present. - type: string - eventTime: - description: Event timestamp carried by the canonical statement entry. - type: string - format: date-time - version: - description: Immutable ledger history version used for deterministic ordering. - type: integer - format: int64 - visibility: - $ref: ./operation.yaml#/components/schemas/LedgerStatementVisibility - - LedgerAccountOperationLine: - description: Visible posting line included in the account-history projection. - type: object - additionalProperties: false - required: - - ledgerAccountRef - - money - - lineType - properties: - ledgerAccountRef: - description: Ledger account reference for the visible posting line. - $ref: ../objectid.yaml#/components/schemas/ObjectId - money: - $ref: ../common/money.yaml#/components/schemas/Money - lineType: - $ref: ./operation.yaml#/components/schemas/LedgerLineType - organizationRef: - description: Owning organization reference when visible to the caller. - $ref: ../objectid.yaml#/components/schemas/ObjectId - - LedgerAccountOperation: - description: | - Detailed account-history projection for a statement entry. - This payload is scoped to the requested ledger account and does not expose general journal-entry data outside that visibility model. - type: object - additionalProperties: false - required: - - entryRef - - entryType - - accountAmount - - eventTime - - version - - visibility - - lines - properties: - entryRef: - description: Statement entry reference. - $ref: ../objectid.yaml#/components/schemas/ObjectId - entryType: - $ref: ./operation.yaml#/components/schemas/LedgerEntryType - accountAmount: - $ref: ../common/money.yaml#/components/schemas/Money - description: - description: Human-readable entry description when present. - type: string - eventTime: - description: Event timestamp carried by the canonical statement entry. - type: string - format: date-time - version: - description: Immutable ledger history version used for deterministic ordering. - type: integer - format: int64 - visibility: - $ref: ./operation.yaml#/components/schemas/LedgerStatementVisibility - lines: - description: Visible posting lines included in the account-history projection. - type: array - items: - $ref: ./operation.yaml#/components/schemas/LedgerAccountOperationLine - metadata: - description: Entry metadata when visible to the caller. - type: object - additionalProperties: - type: string - organizationRef: - description: Entry organization reference when visible to the caller. - $ref: ../objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/models/objectid.yaml b/backend/contracts/sendico/models/objectid.yaml deleted file mode 100644 index 01f1c3d..0000000 --- a/backend/contracts/sendico/models/objectid.yaml +++ /dev/null @@ -1,9 +0,0 @@ -components: - schemas: - ObjectId: - description: Object identifier represented as a 24-character hexadecimal string. - type: string - format: objectid - pattern: '^[a-fA-F0-9]{24}$' - examples: - - 64f85f5f4c7dbf7cfb8f3f10 diff --git a/backend/contracts/sendico/models/organization/organization.yaml b/backend/contracts/sendico/models/organization/organization.yaml deleted file mode 100644 index 8b6dec1..0000000 --- a/backend/contracts/sendico/models/organization/organization.yaml +++ /dev/null @@ -1,39 +0,0 @@ -components: - schemas: - OrganizationBase: - allOf: - - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound - - $ref: ../common/describable.yaml#/components/schemas/Describable - - type: object - additionalProperties: false - required: - - timeZone - properties: - tenantRef: - description: Tenant identifier that owns the organization. - $ref: ../objectid.yaml#/components/schemas/ObjectId - timeZone: - type: string - logoUrl: - type: string - nullable: true - signupState: - description: Signup/provisioning lifecycle state for the organization. - type: string - enum: - - pending_signup_provisioning - - blocked_signup_provisioning - - active - - Organization: - allOf: - - $ref: ./organization.yaml#/components/schemas/OrganizationBase - - type: object - additionalProperties: false - required: - - members - properties: - members: - type: array - items: - $ref: ../objectid.yaml#/components/schemas/ObjectId diff --git a/backend/contracts/sendico/models/payment/fx_metadata.yaml b/backend/contracts/sendico/models/payment/fx_metadata.yaml deleted file mode 100644 index 857a2a1..0000000 --- a/backend/contracts/sendico/models/payment/fx_metadata.yaml +++ /dev/null @@ -1,78 +0,0 @@ -components: - schemas: - FXCurrency: - description: Display-facing currency metadata sourced from the Treasury Currency Catalog startup snapshot cached by the BFF. - type: object - additionalProperties: false - required: - - code - - kind - properties: - code: - description: Treasury canonical asset code. - type: string - kind: - description: Compatibility asset kind projection. Use `assetKind` for the Treasury-owned exact asset kind. - type: string - enum: - - fiat - - crypto - - internal - assetKind: - description: Treasury-owned asset kind. Network-specific support, provider asset codes, token contracts, and provider rails are not represented here. - type: string - enum: - - fiat - - crypto_token - - crypto_native - - internal_platform - decimals: - description: Display-facing compatibility field backed by Treasury `displayScale`. Backend services and clients must not use it as accounting, quote, or payout execution precision authority. - type: integer - format: int32 - minimum: 0 - scale: - description: Display-facing compatibility field backed by Treasury `displayScale`; always equal to `decimals` in this response. - type: integer - format: int32 - minimum: 0 - displayScale: - description: Treasury UI/display precision. - type: integer - format: int32 - minimum: 0 - accountingScaleCandidate: - description: Informational Treasury accounting scale candidate. This is not provider execution precision, not FX rate precision, and not guaranteed executable payout precision or provider support. - type: integer - format: int32 - minimum: 0 - rounding: - description: Preferred rounding mode for the currency, when supplied by the oracle. - type: string - enum: - - round_half_even - - round_half_up - - round_down - - FXDirection: - description: Direction-level FX availability for an organization. - type: object - additionalProperties: false - required: - - fromCurrency - - toCurrency - - pair - - side - properties: - fromCurrency: - description: Source currency of the directed conversion. - type: string - toCurrency: - description: Target currency of the directed conversion. - type: string - pair: - description: Oracle canonical base/quote pair from which the direction was derived. This pair does not follow display from/to order. - $ref: ./payment.yaml#/components/schemas/CurrencyPair - side: - description: FX side interpreted against the canonical pair. `sell_base_buy_quote` means the customer sells base and receives quote; `buy_base_sell_quote` means the customer buys base and pays quote. - $ref: ../../external/fx_side.yaml#/components/schemas/FxSide diff --git a/backend/contracts/sendico/models/payment/payment.yaml b/backend/contracts/sendico/models/payment/payment.yaml deleted file mode 100644 index e970feb..0000000 --- a/backend/contracts/sendico/models/payment/payment.yaml +++ /dev/null @@ -1,879 +0,0 @@ -components: - schemas: - Asset: - description: Blockchain asset identifier used for on-chain transfers. - type: object - additionalProperties: false - properties: - chain: - description: Chain/network where the asset exists. - $ref: ../../external/chain_network.yaml#/components/schemas/ChainNetwork - token_symbol: - description: Symbol of the token on the selected chain. - type: string - contract_address: - description: Smart-contract address for tokenized assets. - type: string - - ExternalChainAsset: - description: Blockchain asset identifier used for external-chain endpoints. `token_symbol` is required by current quote/initiate logic; `chain` remains optional. - required: - - token_symbol - allOf: - - $ref: ./payment.yaml#/components/schemas/Asset - - LedgerEndpoint: - description: | - Internal ledger-based endpoint used as payment source or destination. - Current request handling accepts exactly one of `account_ref` or `account_code`. - `ledger_account_ref` is accepted as a legacy alias for `account_ref`. - type: object - additionalProperties: false - properties: - account_ref: - description: Reference of the primary ledger account. - type: string - ledger_account_ref: - description: Legacy alias for `account_ref`. - deprecated: true - type: string - account_code: - description: Account code of the primary ledger account. - type: string - contra_account_ref: - description: Optional contra ledger account reference for balancing entries. - type: string - contra_account_code: - description: Optional contra ledger account code for balancing entries. - type: string - - PaymentMethodEndpoint: - description: Stored payment method selector. Sent with endpoint type `paymentMethod` and `data.ref`. - type: object - additionalProperties: false - required: - - ref - properties: - ref: - description: Stored payment method reference. - type: string - - AccountEndpoint: - description: Account-owned main payment method selector. Sent with endpoint type `account` and `data.ref`. - type: object - additionalProperties: false - required: - - ref - properties: - ref: - description: Account reference whose main payment method should be used. - type: string - - RecipientEndpoint: - description: Recipient-owned main payment method selector. Sent with endpoint type `recipient` and `data.ref`. - type: object - additionalProperties: false - required: - - ref - properties: - ref: - description: Recipient reference whose main payment method should be used. - type: string - - ManagedWalletEndpoint: - description: Endpoint referencing a managed wallet within the platform. - type: object - additionalProperties: false - required: - - ref - properties: - ref: - description: Reference of the managed wallet. - type: string - asset: - description: Asset details for the managed wallet transfer. - $ref: ./payment.yaml#/components/schemas/Asset - - ExternalChainEndpoint: - description: Endpoint representing an external blockchain address. - type: object - additionalProperties: false - required: - - asset - - address - properties: - asset: - description: Asset to be transferred to or from the external address. - $ref: ./payment.yaml#/components/schemas/ExternalChainAsset - address: - description: Target blockchain address. - type: string - memo: - description: Optional destination memo or tag required by some chains. - type: string - - CardEndpoint: - description: | - Raw card payout endpoint. - Current quotation logic only requires `pan`; `exp_month` and `exp_year` are accepted but optional while pricing. - Raw-card payment execution requires `pan`, `exp_month`, and `exp_year`. - That execution rule also applies when a payment is later created from `quoteRef`, because the quoted endpoint snapshot is reused. - type: object - additionalProperties: false - required: - - pan - properties: - pan: - description: Primary account number of the card. Required for both quotation and raw-card payment execution. - type: string - firstName: - description: Optional cardholder first name hint. Current quote and execution logic do not require it. - type: string - lastName: - description: Optional cardholder last name hint. Current quote and execution logic do not require it. - type: string - exp_month: - description: Card expiration month. Optional for quotation, but required for raw-card payment execution, including payments executed later from `quoteRef`. - type: integer - format: int32 - minimum: 0 - exp_year: - description: Card expiration year. Optional for quotation, but required for raw-card payment execution, including payments executed later from `quoteRef`. - type: integer - format: int32 - minimum: 0 - country: - description: Optional card country hint. Current quote and execution logic do not require it. - type: string - - CardTokenEndpoint: - description: | - Tokenized card payout endpoint used instead of raw PAN details. - Current quotation and payment-execution logic require only `token`. - `masked_pan` remains optional and is mainly used for display, auditing, and provider-specific context. - type: object - additionalProperties: false - required: - - token - properties: - token: - description: Provider-issued card token. Required for both quotation and tokenized-card payment execution. - type: string - masked_pan: - description: Optional masked PAN representation for display and auditing. Current quote and execution logic do not require it. - type: string - - BankAccountEndpoint: - description: | - Russian domestic bank-account payout endpoint. - The recipient name is grouped under `recipient` with `first_name` - and `last_name`. - type: object - additionalProperties: false - required: - - recipient - - kpp - - bankName - - bik - - accountNumber - - correspondentAccount - properties: - recipient: - description: Legal recipient name for the bank-account payout. - type: object - additionalProperties: false - required: - - first_name - - last_name - properties: - first_name: - description: Recipient first name. - type: string - last_name: - description: Recipient last name. - type: string - inn: - description: Optional Russian INN tax identifier for the recipient. If present, it must be valid. Bank payout execution uses this value before falling back to the customer or saved owner tax identifier. - type: string - kpp: - description: Russian KPP tax registration reason code. - type: string - bankName: - description: Recipient bank name. - type: string - bik: - description: 9-digit Russian bank identification code. - type: string - accountNumber: - description: 20-digit Russian bank account number. - type: string - correspondentAccount: - description: 20-digit correspondent account number. - type: string - example: - recipient: - first_name: Ivan - last_name: Ivanov - inn: '7707083893' - kpp: '770701001' - bankName: Test Bank - bik: '044525225' - accountNumber: '40702810900000000001' - correspondentAccount: '30101810400000000225' - - IBANEndpoint: - description: International bank-account payout endpoint. - type: object - additionalProperties: false - required: - - iban - - accountHolder - properties: - iban: - type: string - accountHolder: - type: string - bic: - type: string - bankName: - type: string - - Endpoint: - description: | - Polymorphic payment endpoint definition. Every endpoint is expressed with the same - `type` plus `data` shape. Stored references are typed endpoints too: a stored payment - method uses `type: paymentMethod`, a recipient main method uses `type: recipient`, and - an account main method uses `type: account`, each with a `ref` inside `data`. - Flat top-level selector fields (`paymentMethodRef`, `recipientRef`, `accountRef`) are - not accepted; the request handler rejects them. - type: object - additionalProperties: false - required: - - type - - data - properties: - type: - description: Endpoint type determining the shape of the `data` field. - $ref: ../../external/endpoint_type.yaml#/components/schemas/EndpointType - data: - description: Endpoint payload corresponding to the selected endpoint type. For raw `card` payloads, `pan` is always required and expiry becomes required once the intent is executed. - anyOf: - - $ref: ./payment.yaml#/components/schemas/PaymentMethodEndpoint - - $ref: ./payment.yaml#/components/schemas/AccountEndpoint - - $ref: ./payment.yaml#/components/schemas/RecipientEndpoint - - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint - - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint - - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint - - $ref: ./payment.yaml#/components/schemas/CardEndpoint - - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint - - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint - - $ref: ./payment.yaml#/components/schemas/IBANEndpoint - metadata: - description: Optional key-value metadata attached to the endpoint. - type: object - additionalProperties: - type: string - - PaymentResponseEndpoint: - description: | - Endpoint snapshot attached to a persisted payment. Uses the same `type` plus `data` - shape as request endpoints. Stored-reference snapshots are emitted as typed endpoints - (`paymentMethod`, `account`, or `recipient`) with a `ref` inside `data`. - type: object - additionalProperties: false - required: - - type - - data - properties: - type: - description: Endpoint type for the endpoint snapshot. - $ref: ../../external/endpoint_type.yaml#/components/schemas/EndpointType - data: - description: Endpoint payload snapshot; shape depends on `type`. - anyOf: - - $ref: ./payment.yaml#/components/schemas/PaymentMethodEndpoint - - $ref: ./payment.yaml#/components/schemas/AccountEndpoint - - $ref: ./payment.yaml#/components/schemas/RecipientEndpoint - - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint - - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint - - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint - - $ref: ./payment.yaml#/components/schemas/CardEndpoint - - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint - - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint - - $ref: ./payment.yaml#/components/schemas/IBANEndpoint - - Customer: - description: Customer identity and address attributes for compliance and routing. - type: object - additionalProperties: false - properties: - id: - description: External or internal customer identifier. - type: string - first_name: - description: Customer first name. - type: string - middle_name: - description: Customer middle name. - type: string - last_name: - description: Customer last name. - type: string - ip: - description: Source IP address associated with the payment request. - type: string - zip: - description: Postal or ZIP code. - type: string - country: - description: Country code of customer residence or billing address. - type: string - state: - description: State or region. - type: string - city: - description: City name. - type: string - address: - description: Street address line. - type: string - tax_identifier: - description: Customer tax identifier. Russian bank-account payouts interpret this value as INN. - type: string - - CurrencyPair: - description: Foreign-exchange currency pair. - type: object - additionalProperties: false - required: - - base - - quote - properties: - base: - description: Base currency code. - type: string - quote: - description: Quote currency code. - type: string - - FxIntent: - description: FX execution preferences associated with a payment. - type: object - additionalProperties: false - required: - - pair - - side - properties: - pair: - description: Currency pair for FX conversion. - $ref: ./payment.yaml#/components/schemas/CurrencyPair - side: - description: Side of the FX trade intent. - $ref: ../../external/fx_side.yaml#/components/schemas/FxSide - firm: - description: Whether a firm quote is required. - type: boolean - ttl_ms: - description: Desired quote time-to-live in milliseconds. - type: integer - format: int64 - preferred_provider: - description: Preferred FX liquidity provider identifier. - type: string - max_age_ms: - description: Maximum accepted quote age in milliseconds. - type: integer - format: int32 - - PaymentIntent: - description: Requested payment execution parameters before processing. For raw `card` destinations, quotation accepts missing expiry but payment execution requires `exp_month` and `exp_year`. - type: object - additionalProperties: false - required: - - kind - - source - - destination - - amount - properties: - kind: - description: High-level payment kind that drives orchestration behavior. - $ref: ../../external/payment_kind.yaml#/components/schemas/PaymentKind - source: - description: Source endpoint from which funds are debited. - $ref: ./payment.yaml#/components/schemas/Endpoint - destination: - description: Destination endpoint to which funds are credited. If `type=card`, `exp_month` and `exp_year` are optional while quoting but required when the payment is executed. - $ref: ./payment.yaml#/components/schemas/Endpoint - amount: - description: Requested payment amount. - $ref: ../common/money.yaml#/components/schemas/Money - fx: - description: Optional FX preferences when conversion is needed. - $ref: ./payment.yaml#/components/schemas/FxIntent - settlement_mode: - description: Settlement strategy for the transfer. - $ref: ../../external/settlement_mode.yaml#/components/schemas/SettlementMode - fee_treatment: - description: How fees should be applied to source/destination amounts. - type: string - enum: - - unspecified - - add_to_source - - deduct_from_destination - attributes: - description: Additional key-value attributes used by downstream processors. - type: object - additionalProperties: - type: string - comment: - description: Optional free-form comment attached to the payment intent. - type: string - customer: - description: Optional customer information attached to the payment intent. - $ref: ./payment.yaml#/components/schemas/Customer - - FeeLine: - description: Single fee component included in a payment quote. - type: object - additionalProperties: false - properties: - ledgerAccountRef: - description: Ledger account reference receiving or charging this fee. - type: string - amount: - description: Monetary amount of the fee line. - $ref: ../common/money.yaml#/components/schemas/Money - lineType: - description: Fee line classification. - type: string - side: - description: Indicates whether the fee applies on debit or credit side. - type: string - meta: - description: Optional metadata for fee calculation traceability. - type: object - additionalProperties: - type: string - - QuoteFees: - description: Collection of fee lines for a quoted payment. - type: object - additionalProperties: false - properties: - lines: - description: List of individual quoted fee lines. - type: array - items: - $ref: ./payment.yaml#/components/schemas/FeeLine - - QuoteAmounts: - description: Amount breakdown returned with a payment quote. - type: object - additionalProperties: false - properties: - sourcePrincipal: - description: Principal amount debited from the source before fees. - $ref: ../common/money.yaml#/components/schemas/Money - sourceDebitTotal: - description: Total amount debited from the source including adjustments. - $ref: ../common/money.yaml#/components/schemas/Money - destinationSettlement: - description: Net amount expected to settle at the destination. - $ref: ../common/money.yaml#/components/schemas/Money - - FxQuote: - description: FX quote details used to price converted payment amounts. - type: object - additionalProperties: false - properties: - quoteRef: - description: Identifier of the FX quote. - type: string - baseCurrency: - description: Base currency of the quote pair. - type: string - quoteCurrency: - description: Quote currency of the quote pair. - type: string - side: - description: FX side used for pricing. - type: string - price: - description: Quoted FX price as a decimal string. - type: string - baseAmount: - description: Base currency amount covered by this quote. - $ref: ../common/money.yaml#/components/schemas/Money - quoteAmount: - description: Quote currency amount corresponding to the base amount. - $ref: ../common/money.yaml#/components/schemas/Money - expiresAtUnixMs: - description: Quote expiration timestamp in Unix milliseconds. - type: integer - format: int64 - pricedAtUnixMs: - description: Quote pricing timestamp in Unix milliseconds. - type: integer - format: int64 - provider: - description: Provider that supplied the FX quote. - type: string - rateRef: - description: Provider-specific rate reference identifier. - type: string - firm: - description: Indicates whether the FX quote is firm. - type: boolean - - FxRateCalculation: - description: Exchange-rate calculation used by an FX quote. - type: object - additionalProperties: false - required: - - requestedPair - - requestedSide - - pathKind - - marketRate - - effectiveRate - - overrideScope - - specialRateApplied - - legs - properties: - requestedPair: - description: Requested currency pair for the FX quote. - $ref: ./payment.yaml#/components/schemas/CurrencyPair - requestedSide: - description: FX side interpreted against the requested pair. - $ref: ../../external/fx_side.yaml#/components/schemas/FxSide - pathKind: - description: Whether the applied rate was obtained from a direct pair or a cross-rate path. - type: string - enum: - - direct - - cross - marketRate: - description: Market-path rate before quote-level overrides, as a decimal string. - type: string - effectiveRate: - description: Final rate applied to the quote after leg-level or whole-rate overrides, as a decimal string. - type: string - overrideScope: - description: Location of the rate override. `none` means no special rate was applied. - type: string - enum: - - none - - leg - - total - specialRateApplied: - description: Derived convenience flag equal to `overrideScope != none`. - type: boolean - overrideRef: - description: Opaque support/audit reference for whole-rate overrides. Clients must not parse it. - type: string - asOfUnixMs: - description: Calculation/path decision timestamp in Unix milliseconds. - type: integer - format: int64 - legs: - description: Resolved market path legs used to obtain the rate. - type: array - items: - $ref: ./payment.yaml#/components/schemas/FxRateCalculationLeg - - FxRateCalculationLeg: - description: One market-path leg used in an FX quote rate calculation. - type: object - additionalProperties: false - required: - - pair - - sourcePair - - invertedFromSource - - marketRate - - effectiveRate - - overrideApplied - properties: - pair: - description: Oriented pair multiplied in path order. - $ref: ./payment.yaml#/components/schemas/CurrencyPair - sourcePair: - description: Stored/provider pair before orientation adjustments. - $ref: ./payment.yaml#/components/schemas/CurrencyPair - invertedFromSource: - description: Whether the source pair was inverted to produce this oriented leg. - type: boolean - marketRate: - description: Market executable rate for this leg, as a decimal string. - type: string - effectiveRate: - description: Effective rate for this leg after leg-level override, as a decimal string. - type: string - provider: - description: Rate provider identifier. - type: string - rateRef: - description: Opaque support/audit reference for the source rate. Clients must not parse it. - type: string - asOfUnixMs: - description: Source rate timestamp for this leg in Unix milliseconds. - type: integer - format: int64 - overrideApplied: - description: Whether a leg-level override was applied to this leg. - type: boolean - overrideRef: - description: Opaque support/audit reference for the leg-level override. Clients must not parse it. - type: string - - SpecialRateInfo: - description: Indicates whether quotation pricing policy applied a non-market FX rate to the quote. - type: object - additionalProperties: false - required: - - applied - properties: - applied: - description: Derived from `fxRateCalculation.overrideScope != none` when `fxRateCalculation` is present. - type: boolean - - PaymentQuote: - description: Pricing result for a specific payment intent. - type: object - additionalProperties: false - required: - - executable - - state - properties: - quoteRef: - description: Unique identifier of the payment quote. - type: string - intentRef: - description: Reference to the payment intent used for quoting. - type: string - state: - description: Backend quotation execution state. `executable` means the quote can be submitted for execution. `blocked`, `indicative`, `expired`, and `unspecified` must not be executed as-is. - type: string - enum: - - unspecified - - indicative - - executable - - blocked - - expired - executable: - description: Explicit execution flag. `false` means the quote must not be sent to `/payments/by-quote` or `/payments/by-multiquote`. - type: boolean - blockReason: - description: Reason the quote is not executable when `state` is `blocked`. Omitted for executable, indicative, and expired quotes. - type: string - enum: - - route_unavailable - - limit_blocked - - risk_blocked - - insufficient_liquidity - - price_stale - - amount_too_small - - amount_too_large - amounts: - description: Amount breakdown for source and destination values. - $ref: ./payment.yaml#/components/schemas/QuoteAmounts - fees: - description: Fee breakdown included in the quote. - $ref: ./payment.yaml#/components/schemas/QuoteFees - fxQuote: - description: Associated FX quote when conversion is part of the payment. - $ref: ./payment.yaml#/components/schemas/FxQuote - fxRateCalculation: - description: Explanation of how the FX quote rate was obtained. - $ref: ./payment.yaml#/components/schemas/FxRateCalculation - specialRate: - description: Indicates whether quotation pricing policy applied a special FX rate while pricing this quote. - $ref: ./payment.yaml#/components/schemas/SpecialRateInfo - - PaymentFailureCode: - description: | - Broad machine-readable category for a payment or operation that reports a failure - condition. This field is a public category, not the orchestration service's - canonical failure code. A card payout terminated after a definitive AML denial - uses `failure_restriction`; its canonical orchestration code is `card.aml_denied`. - - The enumeration is closed and safe to use for failure handling after checking the - payment state. The state remains the lifecycle authority: `failure_restriction` - can accompany non-terminal `needs_attention` while manual review or additional - checks are pending. `failure_policy` is reserved and is not currently returned by - the API; policy blocks are reported as `failure_restriction`. - - The published cause-to-category-to-reason list is at - https://app.sendico.io/docs/en/payment-failures/ - type: string - enum: - - failure_unspecified - - failure_balance - - failure_ledger - - failure_fx - - failure_chain - - failure_fees - - failure_policy - - failure_validation - - failure_restriction - example: failure_restriction - - PaymentFailureReason: - description: | - Stable lowercase machine-readable reason token intended for client handling. A - card payout terminated after a definitive AML denial uses `aml_denied`. Detailed - AML evidence, including the matched blacklist reason, and raw gateway, provider, - or RPC messages are not exposed here. - - Unlike `failureCode`, this token set is open: compliance policy reason codes and - provider-derived reasons can add values without a contract change. Treat unknown - tokens as the category-level failure and keep a fallback message. - - Default reasons per category: `insufficient_funds`; `ledger_operation_failed`, - `ledger_operation_cancelled`, `ledger_access_violation`, - `ledger_validation_failed`; `fee_fx_conversion_failed`; `fee_posting_failed`; - `gateway_execution_failed`, `gateway_execution_rejected`, `gateway_execution_cancelled`, - `gateway_transfer_failed`, `gateway_transfer_cancelled`, `card_payout_failed`, - `card_payout_cancelled`, `provider_declined`, `provider_limit`, - `provider_unavailable`, `provider_status_unknown`, `provider_unknown`, - `velocity_limit_exceeded`, `limits_rejected`; `validation_failed`, - `quote_precision_identity_missing`, `quote_precision_asset_identity_mismatch`, - `quote_precision_asset_scale_missing`, `quote_precision_amount_scale_exceeded`, - `card_validation_failed`, `card_scheme_unsupported`, `card_expired`, - `amount_below_minimum`, `amount_above_maximum`, `duplicate_transaction`, - `currency_unsupported`; `card_scheme_disabled`, `aml_denied`, `policy_blocked`, - `review_required`, `more_checks_required`, `risk_restricted`, - `operation_restricted`, `card_product_unsupported`; `assessment_failed`; - `failure_unspecified` for an unclassified cause. - - Additional currently known override reasons are `do_not_honor`, `invalid_card`, - `invalid_card_number`, `invalid_cardholder_name`, `cardholder_name_mismatch`, - `card_details_invalid`, `card_expiry_required`, `invalid_amount`, - `provider_cancelled`, `disabled_card_scheme`, - `unsupported_gateway_card_scheme`, and `recipient_tax_identifier_required`. - Structured bank validation can add normalized reasons ending in `_required` or - `_invalid`; compliance policy reason codes are also an open set. - - The published cause-to-category-to-reason list, including the additional - provider, submit-validation, and bank-validation tokens, is at - https://app.sendico.io/docs/en/payment-failures/ - type: string - pattern: '^[a-z0-9_]+$' - example: aml_denied - - PaymentOperation: - description: | - Execution step record captured during payment processing. For a card payout - terminated by AML screening, `failureCode` is `failure_restriction` and - `failureReason` is `aml_denied`. - type: object - additionalProperties: false - properties: - stepRef: - description: Identifier of the orchestration step. - type: string - code: - description: Operation code representing the executed action. - type: string - state: - description: Current state of the operation. - type: string - label: - description: Human-readable operation label. - type: string - comment: - description: Optional comment attached to the execution step. - type: string - money: - description: Planned and executed monetary snapshots for the operation. - $ref: ./payment.yaml#/components/schemas/PaymentOperationMoney - operationRef: - description: External operation reference identifier reported by the gateway. - type: string - gateway: - description: Gateway microservice type handling the operation. - type: string - failureCode: - $ref: ./payment.yaml#/components/schemas/PaymentFailureCode - failureReason: - $ref: ./payment.yaml#/components/schemas/PaymentFailureReason - startedAt: - description: RFC 3339 timestamp when execution of the operation started. - type: string - format: date-time - completedAt: - description: RFC 3339 timestamp when execution of the operation completed. - type: string - format: date-time - - PaymentOperationMoney: - description: Planned and executed monetary snapshots associated with the operation. - type: object - additionalProperties: false - properties: - planned: - description: Monetary values planned for the operation before execution. - $ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot - executed: - description: Monetary values observed after the operation was executed. - $ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot - - PaymentOperationMoneySnapshot: - description: Monetary snapshot containing base and converted amounts for an operation state. - type: object - additionalProperties: false - properties: - amount: - description: Primary money amount associated with the operation snapshot. - $ref: ../common/money.yaml#/components/schemas/Money - convertedAmount: - description: Secondary amount for conversion operations in the same snapshot. - $ref: ../common/money.yaml#/components/schemas/Money - - Payment: - description: | - Persisted payment aggregate with status, quote, and operation history. A definitive - card-payout AML denial terminates the payment in the failed state and is reported as - `failureCode: failure_restriction` with `failureReason: aml_denied` at both payment - and failed-operation level. - type: object - additionalProperties: false - properties: - paymentRef: - description: Unique payment reference identifier. - type: string - state: - description: Current lifecycle state of the payment. - $ref: ../../external/payment_state.yaml#/components/schemas/PaymentState - comment: - description: Optional comment copied from the original payment intent. - type: string - source: - description: Source endpoint snapshot captured from intent. - $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint - destination: - description: Destination endpoint snapshot captured from intent. - $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint - failureCode: - $ref: ./payment.yaml#/components/schemas/PaymentFailureCode - failureReason: - $ref: ./payment.yaml#/components/schemas/PaymentFailureReason - operations: - description: Chronological list of operations executed for this payment. - type: array - items: - $ref: ./payment.yaml#/components/schemas/PaymentOperation - lastQuote: - description: Most recent payment quote used or generated for the payment. - $ref: ./payment.yaml#/components/schemas/PaymentQuote - createdAt: - description: RFC 3339 timestamp when the payment was created. - type: string - format: date-time - completedAt: - description: RFC 3339 timestamp when the payment reached a terminal state. Omitted while the payment is still in progress. - type: string - format: date-time - meta: - description: Additional metadata captured for the payment. - type: object - additionalProperties: - type: string diff --git a/backend/contracts/sendico/models/payment_method/payment_method.yaml b/backend/contracts/sendico/models/payment_method/payment_method.yaml deleted file mode 100644 index 7b475f0..0000000 --- a/backend/contracts/sendico/models/payment_method/payment_method.yaml +++ /dev/null @@ -1,29 +0,0 @@ -components: - schemas: - PaymentMethod: - allOf: - - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound - - $ref: ../common/describable.yaml#/components/schemas/Describable - - type: object - additionalProperties: false - required: - - ownerKind - - ownerRef - - type - - isMain - properties: - ownerKind: - type: string - enum: - - ACCOUNT - - RECIPIENT - ownerRef: - $ref: ../objectid.yaml#/components/schemas/ObjectId - type: - $ref: ../../external/payment_method_type.yaml#/components/schemas/PaymentMethodType - data: - description: Payment-method-type-specific payload. `bankAccount` methods use the same Russian bank-account data shape as `BankAccountEndpoint`. - type: object - additionalProperties: true - isMain: - type: boolean diff --git a/backend/contracts/sendico/models/permission_bound.yaml b/backend/contracts/sendico/models/permission_bound.yaml deleted file mode 100644 index c574a13..0000000 --- a/backend/contracts/sendico/models/permission_bound.yaml +++ /dev/null @@ -1,23 +0,0 @@ -components: - schemas: - PermissionBound: - description: Association between an organization and a permission, including archival state. - allOf: - - $ref: ./storable.yaml#/components/schemas/Storable - - type: object - description: Permission binding details scoped to an organization. - additionalProperties: false - required: - - organizationRef - - permissionRef - - isArchived - properties: - organizationRef: - description: Identifier of the organization that owns this permission binding. - $ref: ./objectid.yaml#/components/schemas/ObjectId - permissionRef: - description: Identifier of the permission granted to the organization. - $ref: ./objectid.yaml#/components/schemas/ObjectId - isArchived: - description: Indicates whether this object is archived and inactive. - type: boolean diff --git a/backend/contracts/sendico/models/recipient/recipient.yaml b/backend/contracts/sendico/models/recipient/recipient.yaml deleted file mode 100644 index 5ab3d56..0000000 --- a/backend/contracts/sendico/models/recipient/recipient.yaml +++ /dev/null @@ -1,30 +0,0 @@ -components: - schemas: - Recipient: - allOf: - - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound - - $ref: ../common/describable.yaml#/components/schemas/Describable - - type: object - additionalProperties: false - required: - - email - - status - - type - properties: - email: - type: string - format: email - avatarUrl: - type: string - nullable: true - status: - type: string - enum: - - ready - - registered - - notRegistered - type: - type: string - enum: - - internal - - external diff --git a/backend/contracts/sendico/models/storable.yaml b/backend/contracts/sendico/models/storable.yaml deleted file mode 100644 index 325b291..0000000 --- a/backend/contracts/sendico/models/storable.yaml +++ /dev/null @@ -1,22 +0,0 @@ -components: - schemas: - Storable: - description: Common persistence metadata included on stored entities. - type: object - additionalProperties: false - required: - - id - - createdAt - - updatedAt - properties: - id: - description: Unique identifier assigned to the stored entity. - $ref: ./objectid.yaml#/components/schemas/ObjectId - createdAt: - description: RFC 3339 timestamp indicating when the entity was created. - type: string - format: date-time - updatedAt: - description: RFC 3339 timestamp indicating the most recent entity update. - type: string - format: date-time diff --git a/backend/contracts/sendico/models/wallet/wallet_asset.yaml b/backend/contracts/sendico/models/wallet/wallet_asset.yaml deleted file mode 100644 index 6d1a1a6..0000000 --- a/backend/contracts/sendico/models/wallet/wallet_asset.yaml +++ /dev/null @@ -1,19 +0,0 @@ -components: - schemas: - WalletAsset: - description: Canonical managed-wallet asset identified by chain, token symbol, and optional contract address. - type: object - additionalProperties: false - required: - - chain - - tokenSymbol - properties: - chain: - description: Canonical blockchain network for the asset. - $ref: ../../external/chain_network.yaml#/components/schemas/ChainNetwork - tokenSymbol: - description: Upper-case asset symbol. - type: string - contractAddress: - description: Normalized contract address for token assets. Omitted for native assets. - type: string diff --git a/backend/edge/shared/interface/api/config.go b/backend/edge/shared/interface/api/config.go deleted file mode 100644 index ce81552..0000000 --- a/backend/edge/shared/interface/api/config.go +++ /dev/null @@ -1,15 +0,0 @@ -package api - -import ( - mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" -) - -type Config struct { - Mw *mwa.Config `yaml:"middleware"` - AuthStorage *AuthStorageConfig `yaml:"auth_storage"` - Verification *VerificationConfig `yaml:"verification"` -} - -func (c *Config) SharedConfig() *Config { - return c -} diff --git a/backend/edge/shared/interface/api/srequest/login.go b/backend/edge/shared/interface/api/srequest/login.go deleted file mode 100644 index 9ae8534..0000000 --- a/backend/edge/shared/interface/api/srequest/login.go +++ /dev/null @@ -1,9 +0,0 @@ -package srequest - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type Login struct { - model.SessionIdentifier `json:",inline"` - model.LoginData `json:"login"` - ClientSecret string `json:"clientSecret,omitempty"` -} diff --git a/backend/edge/shared/interface/api/srequest/refresh.go b/backend/edge/shared/interface/api/srequest/refresh.go deleted file mode 100644 index cfe5d60..0000000 --- a/backend/edge/shared/interface/api/srequest/refresh.go +++ /dev/null @@ -1,5 +0,0 @@ -package srequest - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type AccessTokenRefresh = model.ClientRefreshToken diff --git a/backend/edge/shared/interface/api/srequest/rotate.go b/backend/edge/shared/interface/api/srequest/rotate.go deleted file mode 100644 index 0b98fd5..0000000 --- a/backend/edge/shared/interface/api/srequest/rotate.go +++ /dev/null @@ -1,5 +0,0 @@ -package srequest - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type TokenRefreshRotate = model.ClientRefreshToken diff --git a/backend/edge/shared/interface/api/sresponse/response.go b/backend/edge/shared/interface/api/sresponse/response.go deleted file mode 100644 index 849b790..0000000 --- a/backend/edge/shared/interface/api/sresponse/response.go +++ /dev/null @@ -1,14 +0,0 @@ -package sresponse - -import ( - "net/http" - - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type ( - HandlerFunc = func(r *http.Request) http.HandlerFunc - AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc - PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc -) diff --git a/backend/edge/shared/server/server.go b/backend/edge/shared/server/server.go deleted file mode 100644 index 5d35149..0000000 --- a/backend/edge/shared/server/server.go +++ /dev/null @@ -1,12 +0,0 @@ -package server - -import ( - edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - internalserver "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/server" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/server" -) - -func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) { - return internalserver.Create(logger, file, debug, runtime) -} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens.go b/backend/edge/shared/storage/refreshtokens/refreshtokens.go deleted file mode 100644 index 4fc018a..0000000 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens.go +++ /dev/null @@ -1,19 +0,0 @@ -package refreshtokens - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type DB interface { - template.DB[*model.RefreshToken] - Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error - RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) - RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error - GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) - GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) - GetClient(ctx context.Context, clientID string) (*model.Client, error) -} diff --git a/backend/edge/shared/storage/verification/context_ref.go b/backend/edge/shared/storage/verification/context_ref.go deleted file mode 100644 index ef4b029..0000000 --- a/backend/edge/shared/storage/verification/context_ref.go +++ /dev/null @@ -1,22 +0,0 @@ -package verification - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -func normalizeContextRef(purpose model.VerificationPurpose, contextRef string) (string, error) { - _ = purpose - return strings.TrimSpace(contextRef), nil -} - -func appendContextRefFilter(filters []builder.Query, purpose model.VerificationPurpose, contextRef string) []builder.Query { - _ = purpose - if strings.TrimSpace(contextRef) != "" { - filters = append(filters, repository.Filter("contextRef", strings.TrimSpace(contextRef))) - } - return filters -} diff --git a/backend/internal/architecture/contracts_test.go b/backend/internal/architecture/contracts_test.go deleted file mode 100644 index dab6966..0000000 --- a/backend/internal/architecture/contracts_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package architecture - -import ( - "gopkg.in/yaml.v3" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestPublicContractReferencesAreSelfContained(t *testing.T) { - root := filepath.Join("..", "..", "contracts", "sendico") - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() || filepath.Ext(path) != ".yaml" { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - return err - } - var visit func(*yaml.Node) - visit = func(n *yaml.Node) { - if n.Kind == yaml.MappingNode { - for i := 0; i+1 < len(n.Content); i += 2 { - if n.Content[i].Value == "$ref" { - ref := strings.SplitN(n.Content[i+1].Value, "#", 2)[0] - if ref == "" { - continue - } - target := filepath.Clean(filepath.Join(filepath.Dir(path), ref)) - relative, err := filepath.Rel(root, target) - if err != nil || strings.HasPrefix(relative, "..") || strings.Contains(ref, "://") { - t.Errorf("%s: nonlocal reference %s", path, ref) - continue - } - if _, err := os.Stat(target); err != nil { - t.Errorf("%s: missing reference %s", path, ref) - } - } - } - } - for _, child := range n.Content { - visit(child) - } - } - visit(&doc) - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/backend/pkg/db/account/account.go b/backend/pkg/db/account/account.go deleted file mode 100644 index 369b04f..0000000 --- a/backend/pkg/db/account/account.go +++ /dev/null @@ -1,14 +0,0 @@ -package account - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -// DB is the interface which must be implemented by all db drivers -type DB interface { - template.DB[*model.Account] - GetByEmail(ctx context.Context, email string) (*model.Account, error) -} diff --git a/backend/pkg/db/config.go b/backend/pkg/db/config.go deleted file mode 100644 index 52d626d..0000000 --- a/backend/pkg/db/config.go +++ /dev/null @@ -1,13 +0,0 @@ -package db - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type DBDriver string - -const ( - Mongo DBDriver = "mongodb" -) - -type Config = model.DriverConfig[DBDriver] diff --git a/backend/pkg/db/internal/mongo/accountdb/user.go b/backend/pkg/db/internal/mongo/accountdb/user.go deleted file mode 100644 index 76f9697..0000000 --- a/backend/pkg/db/internal/mongo/accountdb/user.go +++ /dev/null @@ -1,13 +0,0 @@ -package accountdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { - var account model.Account - return &account, db.FindOne(ctx, repository.Filter("login", email), &account) -} diff --git a/backend/pkg/mlogger/factory/mlogger.go b/backend/pkg/mlogger/factory/mlogger.go deleted file mode 100644 index 0f8a709..0000000 --- a/backend/pkg/mlogger/factory/mlogger.go +++ /dev/null @@ -1,10 +0,0 @@ -package mlogger - -import ( - lt "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - li "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/internal/mlogger" -) - -func NewLogger(debug bool) lt.Logger { - return li.NewLoggerImp(debug) -} diff --git a/backend/pkg/version/factory/factory.go b/backend/pkg/version/factory/factory.go deleted file mode 100644 index d805562..0000000 --- a/backend/pkg/version/factory/factory.go +++ /dev/null @@ -1,10 +0,0 @@ -package version - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/version" - versionimp "git.sendico.io/tech/sxchnge/bff/pkg/version/internal" -) - -func Create(info *version.Info) version.Printer { - return versionimp.Create(info) -} diff --git a/backend/product/README.md b/backend/product/README.md new file mode 100644 index 0000000..96f0039 --- /dev/null +++ b/backend/product/README.md @@ -0,0 +1,38 @@ +# SXCHNGE product BFF + +This Go module is the customer-facing executable. `main.go` composes the copied +runtime and local customer service. Customers, auth, sessions and verification +own their models and supporting code; generic infrastructure comes from the +sibling `shared` library module. + +## Run + +Requires Go 1.27 and an SXCHNGE-owned MongoDB replica set for transactions. +Copy `config.example.yml` to an untracked `config.yml` in this directory. Set: + +- `SXCHNGE_MONGO_URI`: URI for SXCHNGE's database. +- `SXCHNGE_JWT_SECRET`: random signing key, at least 32 bytes for HS256. +- `SXCHNGE_DOMAIN`: local hostname, such as `localhost:8082`. +- `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment. +- `SXCHNGE_API_ENDPOINT`: `/api/v1`. + +From the repository root, run `make run-backend`, or run `go run .` here. +Flags: `-config.file`, `-version`, `-debug`. Configure exact browser origins in +YAML; browser refresh cookies require HTTPS. An empty local database needs no +organization, role, policy or seed; the runtime creates its indexes. + +The runtime mounts health, login, API login, refresh, rotation and logout routes. +Customer/application-client records must exist for login. The customer service +supports atomic account/activation creation and deletion with session revocation. +Signup/profile HTTP wiring and verification delivery/completion remain follow-up +work. The frontend still uses dummy services. + +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; +copy only the wire definitions consumed by an implemented adapter. + +## Validate + +`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker +for disposable Mongo replica sets. Root `make test` and `make lint-backend` also +validate `shared`. See [source provenance](../PROVENANCE.md). diff --git a/backend/edge/shared/interface/api/api.go b/backend/product/app/api/api.go similarity index 65% rename from backend/edge/shared/interface/api/api.go rename to backend/product/app/api/api.go index 480518d..8e156a4 100644 --- a/backend/edge/shared/interface/api/api.go +++ b/backend/product/app/api/api.go @@ -3,10 +3,10 @@ package api import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/app/urls" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" ) type API interface { diff --git a/backend/product/app/api/config.go b/backend/product/app/api/config.go new file mode 100644 index 0000000..78b650c --- /dev/null +++ b/backend/product/app/api/config.go @@ -0,0 +1,16 @@ +package api + +import ( + mwa "git.sendico.io/tech/sxchnge/product/auth/config" + verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config" +) + +type Config struct { + Mw *mwa.Config `yaml:"middleware"` + AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"` + Verification *verificationconfig.Config `yaml:"verification"` +} + +func (c *Config) SharedConfig() *Config { + return c +} diff --git a/backend/edge/shared/interface/api/register.go b/backend/product/app/api/register.go similarity index 70% rename from backend/edge/shared/interface/api/register.go rename to backend/product/app/api/register.go index d20e924..4361edd 100644 --- a/backend/edge/shared/interface/api/register.go +++ b/backend/product/app/api/register.go @@ -1,9 +1,9 @@ package api import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "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 { diff --git a/backend/edge/shared/interface/api/runtime.go b/backend/product/app/api/runtime.go similarity index 93% rename from backend/edge/shared/interface/api/runtime.go rename to backend/product/app/api/runtime.go index 439b4fb..1490caf 100644 --- a/backend/edge/shared/interface/api/runtime.go +++ b/backend/product/app/api/runtime.go @@ -1,6 +1,6 @@ package api -import "git.sendico.io/tech/sxchnge/bff/pkg/mservice" +import "git.sendico.io/tech/sxchnge/shared/service" type ConfigProvider interface { SharedConfig() *Config diff --git a/backend/pkg/api/routers/health.go b/backend/product/app/health/health.go similarity index 67% rename from backend/pkg/api/routers/health.go rename to backend/product/app/health/health.go index c03e723..ba328d2 100644 --- a/backend/pkg/api/routers/health.go +++ b/backend/product/app/health/health.go @@ -1,9 +1,9 @@ package routers import ( - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/internal/healthimp" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/product/app/health/internal" + "git.sendico.io/tech/sxchnge/product/app/health/status" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" ) diff --git a/backend/pkg/api/routers/internal/healthimp/health.go b/backend/product/app/health/internal/health.go similarity index 94% rename from backend/pkg/api/routers/internal/healthimp/health.go rename to backend/product/app/health/internal/health.go index 86ddb5d..c596af2 100644 --- a/backend/pkg/api/routers/internal/healthimp/health.go +++ b/backend/product/app/health/internal/health.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/product/app/health/status" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" "go.uber.org/zap" ) diff --git a/backend/pkg/api/routers/internal/healthimp/health_test.go b/backend/product/app/health/internal/health_test.go similarity index 96% rename from backend/pkg/api/routers/internal/healthimp/health_test.go rename to backend/product/app/health/internal/health_test.go index 038f860..e0b5f01 100644 --- a/backend/pkg/api/routers/internal/healthimp/health_test.go +++ b/backend/product/app/health/internal/health_test.go @@ -3,7 +3,7 @@ package healthimp import ( "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/product/app/health/status" "github.com/go-chi/chi/v5" "go.uber.org/zap" "go.uber.org/zap/zaptest/observer" diff --git a/backend/pkg/api/routers/internal/healthimp/status.go b/backend/product/app/health/internal/status.go similarity index 83% rename from backend/pkg/api/routers/internal/healthimp/status.go rename to backend/product/app/health/internal/status.go index 6ca268f..3e8099c 100644 --- a/backend/pkg/api/routers/internal/healthimp/status.go +++ b/backend/product/app/health/internal/status.go @@ -4,9 +4,9 @@ import ( "net/http" "sync" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/product/app/health/status" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type Status struct { diff --git a/backend/pkg/api/routers/health/status.go b/backend/product/app/health/status/status.go similarity index 100% rename from backend/pkg/api/routers/health/status.go rename to backend/product/app/health/status/status.go diff --git a/backend/edge/shared/internal/transport/http/middleware.go b/backend/product/app/http/middleware.go similarity index 84% rename from backend/edge/shared/internal/transport/http/middleware.go rename to backend/product/app/http/middleware.go index 6e8a502..c4c7c9d 100644 --- a/backend/edge/shared/internal/transport/http/middleware.go +++ b/backend/product/app/http/middleware.go @@ -5,19 +5,18 @@ import ( "os" "path" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage" - edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router" - mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + mr "git.sendico.io/tech/sxchnge/product/app/metrics" + "git.sendico.io/tech/sxchnge/product/auth/config" + httprouter "git.sendico.io/tech/sxchnge/product/auth/http" + "git.sendico.io/tech/sxchnge/product/auth/response" + authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/errors" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" cm "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -106,7 +105,7 @@ func CreateMiddleware( db db.Factory, apiRouter *chi.Mux, config *middleware.Config, - authStorage *edgeapi.AuthStorageConfig, + authStorage *middleware.AuthStorageConfig, debug bool, ) (*Middleware, error) { if config == nil { diff --git a/backend/edge/shared/internal/transport/http/middleware_test.go b/backend/product/app/http/middleware_test.go similarity index 98% rename from backend/edge/shared/internal/transport/http/middleware_test.go rename to backend/product/app/http/middleware_test.go index 9b5a6db..776367a 100644 --- a/backend/edge/shared/internal/transport/http/middleware_test.go +++ b/backend/product/app/http/middleware_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + amr "git.sendico.io/tech/sxchnge/product/app/health" + "git.sendico.io/tech/sxchnge/product/app/health/status" "github.com/go-chi/chi/v5" chimetrics "github.com/go-chi/metrics" "github.com/prometheus/client_golang/prometheus" diff --git a/backend/edge/shared/internal/transport/http/router/metrics/handler.go b/backend/product/app/metrics/handler.go similarity index 94% rename from backend/edge/shared/internal/transport/http/router/metrics/handler.go rename to backend/product/app/metrics/handler.go index d204243..902d3a7 100644 --- a/backend/edge/shared/internal/transport/http/router/metrics/handler.go +++ b/backend/product/app/metrics/handler.go @@ -3,7 +3,7 @@ package metrics import ( "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" "github.com/go-chi/metrics" "go.uber.org/zap" diff --git a/backend/edge/shared/internal/transport/http/router/metrics/router.go b/backend/product/app/metrics/router.go similarity index 84% rename from backend/edge/shared/internal/transport/http/router/metrics/router.go rename to backend/product/app/metrics/router.go index 254071e..12d2c96 100644 --- a/backend/edge/shared/internal/transport/http/router/metrics/router.go +++ b/backend/product/app/metrics/router.go @@ -1,7 +1,7 @@ package metrics import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" ) diff --git a/backend/pkg/server/factory.go b/backend/product/app/process/factory.go similarity index 68% rename from backend/pkg/server/factory.go rename to backend/product/app/process/factory.go index fe572db..b605361 100644 --- a/backend/pkg/server/factory.go +++ b/backend/product/app/process/factory.go @@ -1,5 +1,5 @@ package server -import "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" +import "git.sendico.io/tech/sxchnge/shared/logging" type ServerFactoryT = func(logger mlogger.Logger, file string, debug bool) (Application, error) diff --git a/backend/pkg/server/internal/instance.go b/backend/product/app/process/internal/instance.go similarity index 88% rename from backend/pkg/server/internal/instance.go rename to backend/product/app/process/internal/instance.go index 318dc52..8dd5cb5 100644 --- a/backend/pkg/server/internal/instance.go +++ b/backend/product/app/process/internal/instance.go @@ -1,8 +1,8 @@ package serverimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/server" + "git.sendico.io/tech/sxchnge/product/app/process" + "git.sendico.io/tech/sxchnge/shared/logging" "go.uber.org/zap" ) diff --git a/backend/pkg/server/internal/server.go b/backend/product/app/process/internal/server.go similarity index 88% rename from backend/pkg/server/internal/server.go rename to backend/product/app/process/internal/server.go index a190f56..0adc273 100644 --- a/backend/pkg/server/internal/server.go +++ b/backend/product/app/process/internal/server.go @@ -7,10 +7,10 @@ import ( "os/signal" "syscall" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - lf "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" - "git.sendico.io/tech/sxchnge/bff/pkg/server" - "git.sendico.io/tech/sxchnge/bff/pkg/version" + "git.sendico.io/tech/sxchnge/product/app/process" + "git.sendico.io/tech/sxchnge/shared/logging" + lf "git.sendico.io/tech/sxchnge/shared/logging/factory" + "git.sendico.io/tech/sxchnge/shared/version" "github.com/google/uuid" "go.uber.org/zap" ) diff --git a/backend/pkg/server/main/run.go b/backend/product/app/process/run/run.go similarity index 51% rename from backend/pkg/server/main/run.go rename to backend/product/app/process/run/run.go index 483a7da..85066d6 100644 --- a/backend/pkg/server/main/run.go +++ b/backend/product/app/process/run/run.go @@ -1,9 +1,9 @@ package server import ( - sd "git.sendico.io/tech/sxchnge/bff/pkg/server" - serverimp "git.sendico.io/tech/sxchnge/bff/pkg/server/internal" - "git.sendico.io/tech/sxchnge/bff/pkg/version" + sd "git.sendico.io/tech/sxchnge/product/app/process" + serverimp "git.sendico.io/tech/sxchnge/product/app/process/internal" + "git.sendico.io/tech/sxchnge/shared/version" ) func RunServer(rootLoggerName string, av version.Printer, factory sd.ServerFactoryT) error { diff --git a/backend/pkg/server/server.go b/backend/product/app/process/server.go similarity index 100% rename from backend/pkg/server/server.go rename to backend/product/app/process/server.go diff --git a/backend/internal/profile/runtime.go b/backend/product/app/profile/runtime.go similarity index 79% rename from backend/internal/profile/runtime.go rename to backend/product/app/profile/runtime.go index 119749f..b8bbba1 100644 --- a/backend/internal/profile/runtime.go +++ b/backend/product/app/profile/runtime.go @@ -2,8 +2,8 @@ package profile import ( - "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/product/customers" ) const Accounts = "accounts" diff --git a/backend/edge/shared/internal/runtime/api.go b/backend/product/app/runtime/api.go similarity index 74% rename from backend/edge/shared/internal/runtime/api.go rename to backend/product/app/runtime/api.go index e20747b..9b82f60 100644 --- a/backend/edge/shared/internal/runtime/api.go +++ b/backend/product/app/runtime/api.go @@ -3,13 +3,13 @@ package runtime import ( "context" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http" - amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/app/api" + amr "git.sendico.io/tech/sxchnge/product/app/health" + httptransport "git.sendico.io/tech/sxchnge/product/app/http" + "git.sendico.io/tech/sxchnge/product/app/urls" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" ) type Microservices = []mservice.MicroService diff --git a/backend/edge/shared/internal/runtime/bootstrap.go b/backend/product/app/runtime/bootstrap.go similarity index 86% rename from backend/edge/shared/internal/runtime/bootstrap.go rename to backend/product/app/runtime/bootstrap.go index bdc136b..01eb05a 100644 --- a/backend/edge/shared/internal/runtime/bootstrap.go +++ b/backend/product/app/runtime/bootstrap.go @@ -4,13 +4,13 @@ import ( "context" "os" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http" - amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/app/api" + amr "git.sendico.io/tech/sxchnge/product/app/health" + httptransport "git.sendico.io/tech/sxchnge/product/app/http" + "git.sendico.io/tech/sxchnge/product/app/urls" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/runtime/bootstrap_test.go b/backend/product/app/runtime/bootstrap_test.go similarity index 83% rename from backend/edge/shared/internal/runtime/bootstrap_test.go rename to backend/product/app/runtime/bootstrap_test.go index 91b76f9..43a4ca9 100644 --- a/backend/edge/shared/internal/runtime/bootstrap_test.go +++ b/backend/product/app/runtime/bootstrap_test.go @@ -2,6 +2,7 @@ package runtime_test import ( "context" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "net/http" "net/http/httptest" "net/url" @@ -9,11 +10,10 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime" - "git.sendico.io/tech/sxchnge/bff/internal/profile" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/product/app/profile" + sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime" + "git.sendico.io/tech/sxchnge/shared/database" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go/modules/mongodb" @@ -35,10 +35,10 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) { query.Set("directConnection", "true") endpoint.RawQuery = query.Encode() uri = endpoint.String() - factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_runtime"}}) + factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": uri, "database": "sxchnge_runtime"}}) require.NoError(t, err) t.Cleanup(factory.CloseConnection) - data, err := os.ReadFile("../../../../config.example.yml") + data, err := os.ReadFile("../../config.example.yml") require.NoError(t, err) var example struct { API api.Config `yaml:"api"` diff --git a/backend/edge/shared/internal/runtime/lifecycle.go b/backend/product/app/runtime/lifecycle.go similarity index 93% rename from backend/edge/shared/internal/runtime/lifecycle.go rename to backend/product/app/runtime/lifecycle.go index 9a331b2..141c483 100644 --- a/backend/edge/shared/internal/runtime/lifecycle.go +++ b/backend/product/app/runtime/lifecycle.go @@ -3,7 +3,7 @@ package runtime import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" + "git.sendico.io/tech/sxchnge/product/app/health/status" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/runtime/services.go b/backend/product/app/runtime/services.go similarity index 79% rename from backend/edge/shared/internal/runtime/services.go rename to backend/product/app/runtime/services.go index bc3748c..d0d9ef7 100644 --- a/backend/edge/shared/internal/runtime/services.go +++ b/backend/product/app/runtime/services.go @@ -1,10 +1,10 @@ package runtime import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/product/app/health/status" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/server/config_test.go b/backend/product/app/server/internal/config_test.go similarity index 84% rename from backend/edge/shared/internal/server/config_test.go rename to backend/product/app/server/internal/config_test.go index 88eab9d..e2d4e8e 100644 --- a/backend/edge/shared/internal/server/config_test.go +++ b/backend/product/app/server/internal/config_test.go @@ -2,8 +2,8 @@ package server import ( "errors" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/shared/errors" "os" "testing" ) @@ -17,7 +17,7 @@ func TestIncompleteConfigReturnsProjectError(t *testing.T) { } } func TestExampleConfigMatchesCopiedRuntime(t *testing.T) { - data, err := os.ReadFile("../../../../config.example.yml") + data, err := os.ReadFile("../../../config.example.yml") if err != nil { t.Fatal(err) } diff --git a/backend/edge/shared/internal/server/server.go b/backend/product/app/server/internal/server.go similarity index 92% rename from backend/edge/shared/internal/server/server.go rename to backend/product/app/server/internal/server.go index f62da2a..a1821ae 100644 --- a/backend/edge/shared/internal/server/server.go +++ b/backend/product/app/server/internal/server.go @@ -7,13 +7,13 @@ import ( "os" "time" - ac "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration" + ac "git.sendico.io/tech/sxchnge/product/app/api" + sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime" + "git.sendico.io/tech/sxchnge/shared/database" + mduration "git.sendico.io/tech/sxchnge/shared/duration" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "go.uber.org/zap" "gopkg.in/yaml.v3" diff --git a/backend/edge/shared/internal/server/server_test.go b/backend/product/app/server/internal/server_test.go similarity index 88% rename from backend/edge/shared/internal/server/server_test.go rename to backend/product/app/server/internal/server_test.go index 641780d..790305c 100644 --- a/backend/edge/shared/internal/server/server_test.go +++ b/backend/product/app/server/internal/server_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" + "git.sendico.io/tech/sxchnge/product/app/api" "go.uber.org/zap" ) diff --git a/backend/product/app/server/server.go b/backend/product/app/server/server.go new file mode 100644 index 0000000..1ba0501 --- /dev/null +++ b/backend/product/app/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + edgeapi "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/product/app/process" + internalserver "git.sendico.io/tech/sxchnge/product/app/server/internal" + "git.sendico.io/tech/sxchnge/shared/logging" +) + +func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) { + return internalserver.Create(logger, file, debug, runtime) +} diff --git a/backend/pkg/domainprovider/domain_provider.go b/backend/product/app/urls/domain_provider.go similarity index 76% rename from backend/pkg/domainprovider/domain_provider.go rename to backend/product/app/urls/domain_provider.go index cd92f13..ca5eab4 100644 --- a/backend/pkg/domainprovider/domain_provider.go +++ b/backend/product/app/urls/domain_provider.go @@ -1,8 +1,8 @@ package domainprovider import ( - dimp "git.sendico.io/tech/sxchnge/bff/pkg/domainprovider/imp" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + dimp "git.sendico.io/tech/sxchnge/product/app/urls/internal" + "git.sendico.io/tech/sxchnge/shared/logging" ) type DomainProvider interface { diff --git a/backend/pkg/domainprovider/imp/domain_provider.go b/backend/product/app/urls/internal/domain_provider.go similarity index 95% rename from backend/pkg/domainprovider/imp/domain_provider.go rename to backend/product/app/urls/internal/domain_provider.go index 312485b..0c1bc70 100644 --- a/backend/pkg/domainprovider/imp/domain_provider.go +++ b/backend/product/app/urls/internal/domain_provider.go @@ -4,7 +4,7 @@ import ( "net/url" "os" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" "go.uber.org/zap" ) diff --git a/backend/edge/shared/appversion/version.go b/backend/product/app/version/version.go similarity index 79% rename from backend/edge/shared/appversion/version.go rename to backend/product/app/version/version.go index 6fcdbb3..8d2af5b 100644 --- a/backend/edge/shared/appversion/version.go +++ b/backend/product/app/version/version.go @@ -1,8 +1,8 @@ package appversion import ( - "git.sendico.io/tech/sxchnge/bff/pkg/version" - vf "git.sendico.io/tech/sxchnge/bff/pkg/version/factory" + "git.sendico.io/tech/sxchnge/shared/version" + vf "git.sendico.io/tech/sxchnge/shared/version/factory" ) // Build information. Populated at build-time. diff --git a/backend/internal/architecture/boundary_test.go b/backend/product/architecture/boundary_test.go similarity index 80% rename from backend/internal/architecture/boundary_test.go rename to backend/product/architecture/boundary_test.go index 6e552f0..ecbac73 100644 --- a/backend/internal/architecture/boundary_test.go +++ b/backend/product/architecture/boundary_test.go @@ -28,6 +28,9 @@ func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) { if err != nil { return err } + if strings.HasPrefix(filepath.ToSlash(path), "../../shared/") && strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/") && !strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/shared/") { + t.Errorf("shared library imports an executable module: %s in %s", name, path) + } for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} { if strings.Contains(name, blocked) { t.Errorf("%s imports private provider transport or shared runtime: %s", path, name) diff --git a/backend/internal/architecture/customer_test.go b/backend/product/architecture/customer_test.go similarity index 89% rename from backend/internal/architecture/customer_test.go rename to backend/product/architecture/customer_test.go index 594627c..19dd588 100644 --- a/backend/internal/architecture/customer_test.go +++ b/backend/product/architecture/customer_test.go @@ -11,8 +11,7 @@ import ( ) func TestLocalCustomerModelHasNoPlatformTenancy(t *testing.T) { - // Provider organization fields are allowed in the isolated public wire snapshot, - // never in the local customer model, stores or service contracts. + // Local customer models and storage must stay free of provider tenancy. forbidden := map[string]bool{"organization": true, "organizationref": true, "orgref": true, "orgdb": true, "role": true, "roles": true, "roledescription": true, "rolemanager": true, "policy": true, "policies": true, "policydescription": true, "policydb": true, "permissionbound": true, "signup_provisioning": true} err := filepath.WalkDir("../..", func(path string, entry fs.DirEntry, err error) error { if err != nil { diff --git a/backend/internal/architecture/errors_test.go b/backend/product/architecture/errors_test.go similarity index 95% rename from backend/internal/architecture/errors_test.go rename to backend/product/architecture/errors_test.go index 56a6edb..2642768 100644 --- a/backend/internal/architecture/errors_test.go +++ b/backend/product/architecture/errors_test.go @@ -57,7 +57,7 @@ func TestErrorConstructorsUseProjectModel(t *testing.T) { return true } if (imports[ident.Name] == "errors" && sel.Sel.Name == "New") || (imports[ident.Name] == "fmt" && sel.Sel.Name == "Errorf") { - t.Errorf("%s: use backend/pkg/merrors", fset.Position(call.Pos())) + t.Errorf("%s: use backend/shared/errors", fset.Position(call.Pos())) } return true }) diff --git a/backend/edge/shared/auth/client/client.go b/backend/product/auth/client/client.go similarity index 69% rename from backend/edge/shared/auth/client/client.go rename to backend/product/auth/client/client.go index 7b7f395..fc9467d 100644 --- a/backend/edge/shared/auth/client/client.go +++ b/backend/product/auth/client/client.go @@ -3,16 +3,16 @@ package client import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "strings" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/errors" ) var ErrUnknownClientID = merrors.Define("unknown client id") -func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*model.Client, error) { +func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*sessionmodel.Client, error) { clientID = strings.TrimSpace(clientID) if clientID == "" { return nil, ErrUnknownClientID diff --git a/backend/edge/shared/auth/client/client_test.go b/backend/product/auth/client/client_test.go similarity index 81% rename from backend/edge/shared/auth/client/client_test.go rename to backend/product/auth/client/client_test.go index dfdc8bb..6f40c0c 100644 --- a/backend/edge/shared/auth/client/client_test.go +++ b/backend/product/auth/client/client_test.go @@ -3,20 +3,20 @@ package client import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/store" ) type resolveClientDB struct { refreshtokens.DB calls int - client *model.Client + client *sessionmodel.Client err error } -func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { +func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) { db.calls++ return db.client, db.err } @@ -37,7 +37,7 @@ func TestResolveRejectsBlankClientIDWithoutStorageLookup(t *testing.T) { } func TestResolveTrimsClientID(t *testing.T) { - want := &model.Client{ClientID: "client-1"} + want := &sessionmodel.Client{ClientID: "client-1"} db := &resolveClientDB{client: want} client, err := Resolve(context.Background(), db, " client-1 ") diff --git a/backend/edge/shared/interface/api/auth_config.go b/backend/product/auth/config/auth_config.go similarity index 86% rename from backend/edge/shared/interface/api/auth_config.go rename to backend/product/auth/config/auth_config.go index e155092..76ca98f 100644 --- a/backend/edge/shared/interface/api/auth_config.go +++ b/backend/product/auth/config/auth_config.go @@ -1,4 +1,4 @@ -package api +package middleware type AuthStorageConfig struct { AccountsCollection string `yaml:"accounts_collection"` @@ -34,8 +34,3 @@ func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig { } return res } - -type VerificationConfig struct { - AllowedPurposes []string `yaml:"allowed_purposes"` - RateLimits *VerificationRateLimitsConfig `yaml:"rate_limits"` -} diff --git a/backend/edge/shared/interface/middleware/cors_test.go b/backend/product/auth/config/cors_test.go similarity index 100% rename from backend/edge/shared/interface/middleware/cors_test.go rename to backend/product/auth/config/cors_test.go diff --git a/backend/edge/shared/interface/middleware/login_rate_limit.go b/backend/product/auth/config/login_rate_limit.go similarity index 94% rename from backend/edge/shared/interface/middleware/login_rate_limit.go rename to backend/product/auth/config/login_rate_limit.go index 705958a..b828670 100644 --- a/backend/edge/shared/interface/middleware/login_rate_limit.go +++ b/backend/product/auth/config/login_rate_limit.go @@ -3,7 +3,7 @@ package middleware import ( "time" - sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" + sharedlimit "git.sendico.io/tech/sxchnge/shared/ratelimit" ) const ( diff --git a/backend/edge/shared/interface/middleware/login_rate_limit_test.go b/backend/product/auth/config/login_rate_limit_test.go similarity index 97% rename from backend/edge/shared/interface/middleware/login_rate_limit_test.go rename to backend/product/auth/config/login_rate_limit_test.go index 85ac262..b5d154d 100644 --- a/backend/edge/shared/interface/middleware/login_rate_limit_test.go +++ b/backend/product/auth/config/login_rate_limit_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) { diff --git a/backend/edge/shared/interface/middleware/middleware.go b/backend/product/auth/config/middleware.go similarity index 98% rename from backend/edge/shared/interface/middleware/middleware.go rename to backend/product/auth/config/middleware.go index 86fded4..db181dc 100644 --- a/backend/edge/shared/interface/middleware/middleware.go +++ b/backend/product/auth/config/middleware.go @@ -4,7 +4,7 @@ import ( "os" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) type CORSSettings struct { diff --git a/backend/edge/shared/interface/middleware/token_test.go b/backend/product/auth/config/token_test.go similarity index 100% rename from backend/edge/shared/interface/middleware/token_test.go rename to backend/product/auth/config/token_test.go diff --git a/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go b/backend/product/auth/http/authorized/customer_test.go similarity index 59% rename from backend/edge/shared/internal/transport/http/router/authorized/customer_test.go rename to backend/product/auth/http/authorized/customer_test.go index 26c25bb..979ae33 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go +++ b/backend/product/auth/http/authorized/customer_test.go @@ -2,18 +2,19 @@ package authorized import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/config" + emodel "git.sendico.io/tech/sxchnge/product/auth/model" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/shared/errors" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" "github.com/stretchr/testify/require" @@ -23,11 +24,11 @@ import ( type customerDB struct { account.DB - customer model.Account + customer customermodel.Account requested bson.ObjectID } -func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error { +func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *customermodel.Account) error { d.requested = id if id != d.customer.ID { return merrors.NoData("customer not found") @@ -35,34 +36,34 @@ func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Acco *result = d.customer return nil } -func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) { - return &model.Client{ClientID: "web"}, nil +func (d *sessionDB) GetClient(context.Context, string) (*sessionmodel.Client, error) { + return &sessionmodel.Client{ClientID: "web"}, nil } func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) { - for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} { + for _, status := range []customermodel.AccountStatus{customermodel.AccountActive, customermodel.AccountBlocked, customermodel.AccountPendingVerification} { t.Run(string(status), func(t *testing.T) { accounts := &customerDB{} accounts.customer.ID = bson.NewObjectID() accounts.customer.Status = status routes := chi.NewRouter() signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")} - router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature) + router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature) called := false - router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc { + router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc { called = true require.Equal(t, accounts.customer.ID, c.ID) return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } }) ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil) - _, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"})) + _, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"})) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil) req.Header.Set("Authorization", "Bearer "+token) recorder := httptest.NewRecorder() routes.ServeHTTP(recorder, req) require.Equal(t, accounts.customer.ID, accounts.requested) - if status == model.AccountActive { + if status == customermodel.AccountActive { require.Equal(t, http.StatusNoContent, recorder.Code) require.True(t, called) } else { diff --git a/backend/edge/shared/internal/transport/http/router/authorized/handler.go b/backend/product/auth/http/authorized/handler.go similarity index 90% rename from backend/edge/shared/internal/transport/http/router/authorized/handler.go rename to backend/product/auth/http/authorized/handler.go index 0abafd0..d670d26 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/handler.go +++ b/backend/product/auth/http/authorized/handler.go @@ -2,19 +2,19 @@ package authorized import ( "errors" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" "strings" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" - "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/ipguard" + emodel "git.sendico.io/tech/sxchnge/product/auth/model" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/sessions/metrics" + "git.sendico.io/tech/sxchnge/shared/errors" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging/fields" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/jwtauth/v5" "go.uber.org/zap" ) @@ -120,7 +120,7 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin if t.Pending { return response.Unauthorized(ar.logger, ar.service, "additional verification required") } - var a model.Account + var a customermodel.Account if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil { if errors.Is(err, merrors.ErrNoData) { ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef)) @@ -144,7 +144,7 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) { hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { - var a model.Account + var a customermodel.Account if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil { if errors.Is(err, merrors.ErrNoData) { ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef)) @@ -152,7 +152,7 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin } return response.Internal(ar.logger, ar.service, err) } - if a.Status == model.AccountBlocked || a.IsArchived() { + if a.Status == customermodel.AccountBlocked || a.IsArchived() { return response.Unauthorized(ar.logger, ar.service, "customer is inactive") } return handler(r, &a, t) diff --git a/backend/edge/shared/internal/transport/http/router/authorized/router.go b/backend/product/auth/http/authorized/router.go similarity index 73% rename from backend/edge/shared/internal/transport/http/router/authorized/router.go rename to backend/product/auth/http/authorized/router.go index a7af5b4..17b1495 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/router.go +++ b/backend/product/auth/http/authorized/router.go @@ -3,17 +3,17 @@ package authorized import ( "net/http" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/config" + re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" + emodel "git.sendico.io/tech/sxchnge/product/auth/model" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/product/sessions/metrics" + "git.sendico.io/tech/sxchnge/product/sessions/store" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/fields" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" "go.uber.org/zap" diff --git a/backend/edge/shared/internal/transport/http/router/authorized/session_test.go b/backend/product/auth/http/authorized/session_test.go similarity index 82% rename from backend/edge/shared/internal/transport/http/router/authorized/session_test.go rename to backend/product/auth/http/authorized/session_test.go index 8463636..cb3c715 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/session_test.go +++ b/backend/product/auth/http/authorized/session_test.go @@ -2,27 +2,27 @@ package authorized import ( "context" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "testing" - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + emodel "git.sendico.io/tech/sxchnge/product/auth/model" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type sessionDB struct { refreshtokens.DB - token *model.RefreshToken + token *sessionmodel.RefreshToken err error revokeAllID string } -func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *model.SessionIdentifier) (*model.RefreshToken, error) { +func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) { return db.token, db.err } @@ -35,7 +35,7 @@ func TestValidateSessionFailsClosed(t *testing.T) { accountRef := bson.NewObjectID() token := &emodel.AccountToken{ AccountRef: accountRef, - SessionIdentifier: model.SessionIdentifier{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "web", DeviceID: "device-a", SessionVersion: "version-a", @@ -49,7 +49,7 @@ func TestValidateSessionFailsClosed(t *testing.T) { mutate func(*emodel.AccountToken) wantStatus int }{ - {name: "active", db: &sessionDB{token: &model.RefreshToken{}}, wantStatus: 0}, + {name: "active", db: &sessionDB{token: &sessionmodel.RefreshToken{}}, wantStatus: 0}, {name: "revoked", db: &sessionDB{err: merrors.ErrNoData}, wantStatus: http.StatusUnauthorized}, {name: "storage unavailable", db: &sessionDB{err: merrors.Internal("database unavailable")}, wantStatus: http.StatusInternalServerError}, { @@ -93,7 +93,7 @@ func TestLogoutAllRevokesEveryDevice(t *testing.T) { router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} token := &emodel.AccountToken{ AccountRef: bson.NewObjectID(), - SessionIdentifier: model.SessionIdentifier{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "web", DeviceID: "device-a", SessionVersion: "version-a", diff --git a/backend/edge/shared/internal/transport/http/router/dispatcher.go b/backend/product/auth/http/dispatcher.go similarity index 67% rename from backend/edge/shared/internal/transport/http/router/dispatcher.go rename to backend/product/auth/http/dispatcher.go index 733109b..2a44854 100644 --- a/backend/edge/shared/internal/transport/http/router/dispatcher.go +++ b/backend/product/auth/http/dispatcher.go @@ -5,17 +5,17 @@ import ( "github.com/go-chi/chi/v5" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - rauthorized "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/authorized" - rpublic "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/public" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/config" + rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" + rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/product/verification/store" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" + "git.sendico.io/tech/sxchnge/shared/service" ) type Dispatcher struct { diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go b/backend/product/auth/http/endpoint/endpoint.go similarity index 64% rename from backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go rename to backend/product/auth/http/endpoint/endpoint.go index 5e73352..8f7642d 100644 --- a/backend/edge/shared/internal/transport/http/router/endpoint/endpoint.go +++ b/backend/product/auth/http/endpoint/endpoint.go @@ -2,18 +2,19 @@ package endpoint import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" ) type ( RegistratorT = func(chi.Router, string, http.HandlerFunc) - ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc + ResponderFunc = func(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account, accessToken *sresponse.TokenData) http.HandlerFunc ) type HttpEndpointRouter struct { diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/install.go b/backend/product/auth/http/endpoint/install.go similarity index 90% rename from backend/edge/shared/internal/transport/http/router/endpoint/install.go rename to backend/product/auth/http/endpoint/install.go index 863e3f5..2c94d88 100644 --- a/backend/edge/shared/internal/transport/http/router/endpoint/install.go +++ b/backend/product/auth/http/endpoint/install.go @@ -4,9 +4,9 @@ import ( "net/http" "path" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/response" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/transport/http/router/endpoint/token.go b/backend/product/auth/http/endpoint/token.go similarity index 63% rename from backend/edge/shared/internal/transport/http/router/endpoint/token.go rename to backend/product/auth/http/endpoint/token.go index 2c6d14b..b6df9e8 100644 --- a/backend/edge/shared/internal/transport/http/router/endpoint/token.go +++ b/backend/product/auth/http/endpoint/token.go @@ -1,15 +1,16 @@ package endpoint import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + emodel "git.sendico.io/tech/sxchnge/product/auth/model" + "git.sendico.io/tech/sxchnge/product/auth/response" "github.com/go-chi/jwtauth/v5" ) -func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *model.Account, session model.SessionIdentifier) (sresponse.TokenData, error) { +func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *customermodel.Account, session sessionmodel.SessionIdentifier) (sresponse.TokenData, error) { ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey) _, res, err := ja.Encode(emodel.Account2ClaimsForSession(user, er.config.Expiration.Account, session)) token := sresponse.TokenData{ @@ -19,7 +20,7 @@ func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *model.Account, s return token, err } -func (er *HttpEndpointRouter) CreatePendingToken(user *model.Account, ttlMinutes int) (sresponse.TokenData, error) { +func (er *HttpEndpointRouter) CreatePendingToken(user *customermodel.Account, ttlMinutes int) (sresponse.TokenData, error) { ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey) _, res, err := ja.Encode(emodel.PendingAccount2Claims(user, ttlMinutes)) token := sresponse.TokenData{ diff --git a/backend/edge/shared/internal/transport/http/router/public/login.go b/backend/product/auth/http/public/login.go similarity index 84% rename from backend/edge/shared/internal/transport/http/router/public/login.go rename to backend/product/auth/http/public/login.go index f6b3c7f..5807249 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login.go +++ b/backend/product/auth/http/public/login.go @@ -5,19 +5,20 @@ import ( "crypto/subtle" "encoding/json" "errors" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "strings" - clientauth "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/client" - "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask" + clientauth "git.sendico.io/tech/sxchnge/product/auth/client" + "git.sendico.io/tech/sxchnge/product/auth/ipguard" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/mask" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) @@ -27,12 +28,12 @@ const apiLoginClientAuthMethod = "client_secret_post" const dummyPasswordHash = "$2y$10$pmvNFN5zyooml6Oqh.cGFenBF/indHDE.dyLwjNJW1.Tk2Suvdvve" -func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, bool, http.HandlerFunc) { +func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*customermodel.Account, bool, http.HandlerFunc) { // Get the account database entry trimmedLogin := strings.TrimSpace(req.Login) account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin)) if errors.Is(err, merrors.ErrNoData) || (err == nil && account == nil) { - (&model.Account{Password: dummyPasswordHash}).MatchPassword(req.Password) + (&customermodel.Account{Password: dummyPasswordHash}).MatchPassword(req.Password) pr.logger.Debug("Login rejected: invalid credentials") return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") } @@ -53,7 +54,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L return account, false, nil } -func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc { +func (pr *PublicRouter) respondPendingLogin(account *customermodel.Account) http.HandlerFunc { pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes) if err != nil { pr.logger.Warn("Failed to generate pending token", zap.Error(err)) @@ -72,7 +73,7 @@ func hasGrantType(grants []string, target string) bool { return false } -func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc { +func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *sessionmodel.Client) http.HandlerFunc { if client == nil { pr.logger.Debug("Client not found, rejecting authorization", zap.String("client_id", clientID)) return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) @@ -94,7 +95,7 @@ func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, return nil } -func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*model.Client, http.HandlerFunc) { +func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*sessionmodel.Client, http.HandlerFunc) { client, err := clientauth.Resolve(ctx, pr.rtdb, clientID) if errors.Is(err, clientauth.ErrUnknownClientID) { pr.logger.Debug("Login rejected: unknown client id", zap.String("client_id", clientID)) @@ -107,7 +108,7 @@ func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*model return client, nil } -func (pr *PublicRouter) validateClientAccountBinding(client *model.Client, account *model.Account) http.HandlerFunc { +func (pr *PublicRouter) validateClientAccountBinding(client *sessionmodel.Client, account *customermodel.Account) http.HandlerFunc { if client == nil || client.AccountRef == nil { return nil } @@ -118,7 +119,7 @@ func (pr *PublicRouter) validateClientAccountBinding(client *model.Client, accou return nil } -func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, client *model.Client) http.HandlerFunc { +func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, client *sessionmodel.Client) http.HandlerFunc { if client == nil { return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) } @@ -131,7 +132,7 @@ func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, cl return nil } -func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { +func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *customermodel.Account) http.HandlerFunc { client, h := pr.loadClient(ctx, req.ClientID) if h != nil { return h @@ -165,7 +166,7 @@ func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, return nil } -func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, clientID string) (*model.Client, http.HandlerFunc) { +func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, clientID string) (*sessionmodel.Client, http.HandlerFunc) { clientID = strings.TrimSpace(clientID) if clientID == "" { return nil, nil @@ -180,7 +181,7 @@ func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, return client, nil } -func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { +func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *customermodel.Account) http.HandlerFunc { if req.ClientID == "" || req.DeviceID == "" { return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required") } diff --git a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go b/backend/product/auth/http/public/login_rate_limit.go similarity index 91% rename from backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go rename to backend/product/auth/http/public/login_rate_limit.go index 483031b..e7520e6 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit.go +++ b/backend/product/auth/http/public/login_rate_limit.go @@ -2,18 +2,18 @@ package public import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" "strconv" "strings" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/ipguard" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "go.uber.org/zap" @@ -40,7 +40,7 @@ type loginRateScope struct { func (pr *PublicRouter) authenticateRateLimited( r *http.Request, req *srequest.Login, -) (*model.Account, http.HandlerFunc) { +) (*customermodel.Account, http.HandlerFunc) { if pr.attempts == nil { account, _, h := pr.authenticateAccount(r.Context(), req) return account, h diff --git a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go b/backend/product/auth/http/public/login_rate_limit_test.go similarity index 93% rename from backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go rename to backend/product/auth/http/public/login_rate_limit_test.go index 3412f0c..25785c9 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_rate_limit_test.go +++ b/backend/product/auth/http/public/login_rate_limit_test.go @@ -2,6 +2,7 @@ package public import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" "net/http/httptest" "strconv" @@ -11,11 +12,10 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) @@ -86,8 +86,8 @@ func testRateLimitedRouter( func loginRequest(login, password, remoteAddr string) (*http.Request, *srequest.Login) { req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil) req.RemoteAddr = remoteAddr - return req, &srequest.Login{LoginData: model.LoginData{ - UserDataBase: model.UserDataBase{Login: login}, + return req, &srequest.Login{LoginData: customermodel.LoginData{ + UserDataBase: customermodel.UserDataBase{Login: login}, Password: password, }} } @@ -199,8 +199,8 @@ func TestLoginEndpointReturnsStableRateLimitResponse(t *testing.T) { } func TestInvalidCredentialsDoNotDiscloseAccountExistence(t *testing.T) { - request := &srequest.Login{LoginData: model.LoginData{ - UserDataBase: model.UserDataBase{Login: "user@example.com"}, + request := &srequest.Login{LoginData: customermodel.LoginData{ + UserDataBase: customermodel.UserDataBase{Login: "user@example.com"}, Password: "wrong password", }} @@ -307,10 +307,10 @@ func TestLoginRateLimitFailsClosedWhenFailureCannotBeRecorded(t *testing.T) { } func TestDummyPasswordHashRemainsValid(t *testing.T) { - if !(&model.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") { + if !(&customermodel.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") { t.Fatal("dummy password hash is not a valid bcrypt hash") } - if (&model.Account{Password: dummyPasswordHash}).MatchPassword("different") { + if (&customermodel.Account{Password: dummyPasswordHash}).MatchPassword("different") { t.Fatal("dummy password hash unexpectedly matched another password") } } diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test.go b/backend/product/auth/http/public/login_test.go similarity index 88% rename from backend/edge/shared/internal/transport/http/router/public/login_test.go rename to backend/product/auth/http/public/login_test.go index 097a0c5..c73d490 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_test.go +++ b/backend/product/auth/http/public/login_test.go @@ -3,16 +3,17 @@ package public import ( "context" "errors" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "strings" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "go.uber.org/zap" ) @@ -49,8 +50,8 @@ func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) { } got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{ - LoginData: model.LoginData{ - UserDataBase: model.UserDataBase{ + LoginData: customermodel.LoginData{ + UserDataBase: customermodel.UserDataBase{ Login: "user@example.com", }, Password: "correct horse battery staple", @@ -110,7 +111,7 @@ func testRejectsUnknownClientID( } func TestLoginRejectsPendingVerificationAccount(t *testing.T) { - account := testAccountWithStatus(t, model.AccountPendingVerification) + account := testAccountWithStatus(t, customermodel.AccountPendingVerification) router := &PublicRouter{ logger: zap.NewNop(), db: &testAccountDB{result: account}, @@ -134,7 +135,7 @@ func TestLoginRejectsPendingVerificationAccount(t *testing.T) { } func TestLoginRejectsBlockedAccount(t *testing.T) { - account := testAccountWithStatus(t, model.AccountBlocked) + account := testAccountWithStatus(t, customermodel.AccountBlocked) router := &PublicRouter{ logger: zap.NewNop(), db: &testAccountDB{result: account}, @@ -163,7 +164,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { deviceID := "device-1" rtdb := &testRefreshTokenDB{ - client: &model.Client{ + client: &sessionmodel.Client{ ClientID: clientID, ClientSecret: "secret", GrantTypes: []string{"password"}, @@ -214,7 +215,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { t.Fatal("expected first login to return refresh token") } - err := rtdb.Revoke(context.Background(), *account.GetID(), &model.SessionIdentifier{ + err := rtdb.Revoke(context.Background(), *account.GetID(), &sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }) @@ -237,8 +238,8 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { t.Fatal("expected second login to issue a different refresh token after revocation") } - _, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + _, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -248,8 +249,8 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { t.Fatalf("expected old refresh token to be unusable, got %v", err) } - _, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + _, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go b/backend/product/auth/http/public/login_test_support_test.go similarity index 79% rename from backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go rename to backend/product/auth/http/public/login_test_support_test.go index df513c2..2a8d6af 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go +++ b/backend/product/auth/http/public/login_test_support_test.go @@ -3,12 +3,13 @@ package public import ( "context" "encoding/json" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "sync" "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -49,17 +50,17 @@ func decodeLoginResponse(t *testing.T, body []byte) testLoginResponse { return resp } -func testActiveAccount(t *testing.T) *model.Account { +func testActiveAccount(t *testing.T) *customermodel.Account { t.Helper() - return testAccountWithStatus(t, model.AccountActive) + return testAccountWithStatus(t, customermodel.AccountActive) } -func testAccountWithStatus(t *testing.T, status model.AccountStatus) *model.Account { +func testAccountWithStatus(t *testing.T, status customermodel.AccountStatus) *customermodel.Account { t.Helper() - account := &model.Account{ - AccountPublic: model.AccountPublic{ - UserDataBase: model.UserDataBase{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + UserDataBase: customermodel.UserDataBase{ Login: "user@example.com", }, }, @@ -75,27 +76,27 @@ func testAccountWithStatus(t *testing.T, status model.AccountStatus) *model.Acco type testAccountDB struct { mu sync.Mutex - result *model.Account + result *customermodel.Account err error getByEmailCalls int } -func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { +func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*customermodel.Account, error) { db.mu.Lock() defer db.mu.Unlock() db.getByEmailCalls++ return db.result, db.err } -func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error { +func (db *testAccountDB) Create(ctx context.Context, object *customermodel.Account) error { return merrors.NotImplemented("stub") } -func (db *testAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error { +func (db *testAccountDB) InsertMany(ctx context.Context, objects []*customermodel.Account) error { return merrors.NotImplemented("stub") } -func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.Account) error { +func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *customermodel.Account) error { if db.err != nil { return db.err } @@ -106,7 +107,7 @@ func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, resul return nil } -func (db *testAccountDB) Update(ctx context.Context, object *model.Account) error { +func (db *testAccountDB) Update(ctx context.Context, object *customermodel.Account) error { return merrors.NotImplemented("stub") } @@ -126,21 +127,21 @@ func (db *testAccountDB) DeleteCascade(ctx context.Context, objectRef bson.Objec return merrors.NotImplemented("stub") } -func (db *testAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error { +func (db *testAccountDB) FindOne(ctx context.Context, query builder.Query, result *customermodel.Account) error { return merrors.NotImplemented("stub") } type testRefreshTokenDB struct { - client *model.Client + client *sessionmodel.Client clientErr error - tokens []*model.RefreshToken + tokens []*sessionmodel.RefreshToken } -func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { +func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) { return db.client, db.clientErr } -func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error { +func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error { for _, token := range db.tokens { if token.AccountRef == nil || *token.AccountRef != accountRef { continue @@ -153,7 +154,7 @@ func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.Object return nil } -func (db *testRefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) { +func (db *testRefreshTokenDB) RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error) { for _, stored := range db.tokens { if stored.ClientID == token.ClientID && stored.DeviceID == token.DeviceID && stored.RefreshToken == token.RefreshToken && !stored.IsRevoked { stored.IsRevoked = true @@ -167,7 +168,7 @@ func (db *testRefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.Obj return merrors.NotImplemented("stub") } -func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) { +func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) { for _, token := range db.tokens { if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked { return cloneRefreshToken(token), nil @@ -176,7 +177,7 @@ func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.Ob return nil, merrors.ErrNoData } -func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) { +func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) { for _, token := range db.tokens { if token.ClientID == t.ClientID && token.DeviceID == t.DeviceID && token.RefreshToken == t.RefreshToken { if token.IsRevoked { @@ -188,7 +189,7 @@ func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefre return nil, merrors.ErrNoData } -func (db *testRefreshTokenDB) Create(ctx context.Context, object *model.RefreshToken) error { +func (db *testRefreshTokenDB) Create(ctx context.Context, object *sessionmodel.RefreshToken) error { for _, token := range db.tokens { if token.AccountRef == nil || object.AccountRef == nil { continue @@ -218,15 +219,15 @@ func (db *testRefreshTokenDB) Create(ctx context.Context, object *model.RefreshT return nil } -func (db *testRefreshTokenDB) InsertMany(ctx context.Context, objects []*model.RefreshToken) error { +func (db *testRefreshTokenDB) InsertMany(ctx context.Context, objects []*sessionmodel.RefreshToken) error { return merrors.NotImplemented("stub") } -func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.RefreshToken) error { +func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *sessionmodel.RefreshToken) error { return merrors.NotImplemented("stub") } -func (db *testRefreshTokenDB) Update(ctx context.Context, object *model.RefreshToken) error { +func (db *testRefreshTokenDB) Update(ctx context.Context, object *sessionmodel.RefreshToken) error { return merrors.NotImplemented("stub") } @@ -246,7 +247,7 @@ func (db *testRefreshTokenDB) DeleteCascade(ctx context.Context, objectRef bson. return merrors.NotImplemented("stub") } -func (db *testRefreshTokenDB) FindOne(ctx context.Context, query builder.Query, result *model.RefreshToken) error { +func (db *testRefreshTokenDB) FindOne(ctx context.Context, query builder.Query, result *sessionmodel.RefreshToken) error { return merrors.NotImplemented("stub") } @@ -270,7 +271,7 @@ func (db *testRefreshTokenDB) activeTokenCountForSession(accountRef bson.ObjectI return count } -func cloneRefreshToken(token *model.RefreshToken) *model.RefreshToken { +func cloneRefreshToken(token *sessionmodel.RefreshToken) *sessionmodel.RefreshToken { if token == nil { return nil } diff --git a/backend/edge/shared/internal/transport/http/router/public/logout.go b/backend/product/auth/http/public/logout.go similarity index 87% rename from backend/edge/shared/internal/transport/http/router/public/logout.go rename to backend/product/auth/http/public/logout.go index 99ad108..c4fa2e1 100644 --- a/backend/edge/shared/internal/transport/http/router/public/logout.go +++ b/backend/product/auth/http/public/logout.go @@ -4,11 +4,11 @@ import ( "encoding/json" "net/http" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/product/sessions/metrics" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/transport/http/router/public/logout_test.go b/backend/product/auth/http/public/logout_test.go similarity index 85% rename from backend/edge/shared/internal/transport/http/router/public/logout_test.go rename to backend/product/auth/http/public/logout_test.go index 9b162f0..36a3bc8 100644 --- a/backend/edge/shared/internal/transport/http/router/public/logout_test.go +++ b/backend/product/auth/http/public/logout_test.go @@ -2,6 +2,7 @@ package public import ( "context" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "strings" @@ -9,11 +10,10 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -22,12 +22,12 @@ import ( type logoutDB struct { refreshtokens.DB mu sync.Mutex - tokens map[string]*model.RefreshToken + tokens map[string]*sessionmodel.RefreshToken lookupErr error revokeErr error } -func (db *logoutDB) RevokeToken(_ context.Context, request *model.ClientRefreshToken) (bool, error) { +func (db *logoutDB) RevokeToken(_ context.Context, request *sessionmodel.ClientRefreshToken) (bool, error) { db.mu.Lock() defer db.mu.Unlock() if db.lookupErr != nil { @@ -44,10 +44,10 @@ func (db *logoutDB) RevokeToken(_ context.Context, request *model.ClientRefreshT return true, nil } -func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *model.RefreshToken { - return &model.RefreshToken{ - ClientRefreshToken: model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID}, +func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *sessionmodel.RefreshToken { + return &sessionmodel.RefreshToken{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID}, RefreshToken: value, }, AccountRef: &accountRef, @@ -65,7 +65,7 @@ func executeLogout(router *PublicRouter, deviceID, refreshToken string) int { func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) { accountRef := bson.NewObjectID() - db := &logoutDB{tokens: map[string]*model.RefreshToken{ + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), "device-b": newLogoutToken(accountRef, "device-b", "refresh-b"), }} @@ -87,7 +87,7 @@ func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) { func TestConcurrentLogoutIsIdempotent(t *testing.T) { accountRef := bson.NewObjectID() - db := &logoutDB{tokens: map[string]*model.RefreshToken{ + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "refresh-a"), }} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} @@ -112,7 +112,7 @@ func TestConcurrentLogoutIsIdempotent(t *testing.T) { func TestDelayedLogoutDoesNotRevokeReplacementSession(t *testing.T) { accountRef := bson.NewObjectID() - db := &logoutDB{tokens: map[string]*model.RefreshToken{ + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{ "device-a": newLogoutToken(accountRef, "device-a", "new-refresh"), }} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} @@ -129,7 +129,7 @@ func TestLogoutAcceptsAnAlreadyExpiredSession(t *testing.T) { accountRef := bson.NewObjectID() expired := newLogoutToken(accountRef, "device-a", "expired-refresh") expired.ExpiresAt = time.Now().Add(-time.Hour) - db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": expired}} + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": expired}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} if status := executeLogout(router, "device-a", "expired-refresh"); status != http.StatusOK { @@ -151,7 +151,7 @@ func TestLogoutFailsClosedWhenSessionStoreIsUnavailable(t *testing.T) { func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) { accountRef := bson.NewObjectID() token := newLogoutToken(accountRef, "device-a", "cookie-refresh") - db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": token}} + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": token}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) @@ -171,7 +171,7 @@ func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) { } func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing.T) { - db := &logoutDB{tokens: map[string]*model.RefreshToken{}} + db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{}} router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}` request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body)) @@ -187,7 +187,7 @@ func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing. func TestBrowserLogoutKeepsCookieWhenRevocationFails(t *testing.T) { db := &logoutDB{ - tokens: map[string]*model.RefreshToken{}, + tokens: map[string]*sessionmodel.RefreshToken{}, lookupErr: merrors.Internal("database unavailable"), } router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts} diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh.go b/backend/product/auth/http/public/refresh.go similarity index 84% rename from backend/edge/shared/internal/transport/http/router/public/refresh.go rename to backend/product/auth/http/public/refresh.go index fcacefe..53f31a0 100644 --- a/backend/edge/shared/internal/transport/http/router/public/refresh.go +++ b/backend/product/auth/http/public/refresh.go @@ -6,11 +6,11 @@ import ( "net/http" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go b/backend/product/auth/http/public/refresh_test.go similarity index 89% rename from backend/edge/shared/internal/transport/http/router/public/refresh_test.go rename to backend/product/auth/http/public/refresh_test.go index 2a8dabf..7a8810a 100644 --- a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go +++ b/backend/product/auth/http/public/refresh_test.go @@ -2,15 +2,16 @@ package public import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "strings" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/sessions/browser" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -22,11 +23,11 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, clientID := "web" deviceID := "browser-1" rtdb := &testRefreshTokenDB{ - client: &model.Client{ClientID: clientID}, - tokens: []*model.RefreshToken{ + client: &sessionmodel.Client{ClientID: clientID}, + tokens: []*sessionmodel.RefreshToken{ { - ClientRefreshToken: model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, SessionVersion: "version-1", @@ -137,7 +138,7 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) { } func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) { - for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} { + for _, status := range []customermodel.AccountStatus{customermodel.AccountBlocked, customermodel.AccountPendingVerification} { t.Run(string(status), func(t *testing.T) { router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour)) router.db.(*testAccountDB).result.Status = status diff --git a/backend/edge/shared/internal/transport/http/router/public/respond.go b/backend/product/auth/http/public/respond.go similarity index 72% rename from backend/edge/shared/internal/transport/http/router/public/respond.go rename to backend/product/auth/http/public/respond.go index 720212b..17a669a 100644 --- a/backend/edge/shared/internal/transport/http/router/public/respond.go +++ b/backend/product/auth/http/public/respond.go @@ -2,21 +2,22 @@ package public import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" - rtokens "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/tokens" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/response" + rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.uber.org/zap" ) func (pr *PublicRouter) refreshAndRespondLogin( ctx context.Context, r *http.Request, - session *model.SessionIdentifier, - account *model.Account, + session *sessionmodel.SessionIdentifier, + account *customermodel.Account, ) http.HandlerFunc { refreshToken, err := rtokens.PrepareRefreshToken( ctx, diff --git a/backend/edge/shared/internal/transport/http/router/public/rotate.go b/backend/product/auth/http/public/rotate.go similarity index 86% rename from backend/edge/shared/internal/transport/http/router/public/rotate.go rename to backend/product/auth/http/public/rotate.go index e7b5883..a72cbb9 100644 --- a/backend/edge/shared/internal/transport/http/router/public/rotate.go +++ b/backend/product/auth/http/public/rotate.go @@ -5,9 +5,9 @@ import ( "errors" "net/http" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) diff --git a/backend/edge/shared/internal/transport/http/router/public/router.go b/backend/product/auth/http/public/router.go similarity index 71% rename from backend/edge/shared/internal/transport/http/router/public/router.go rename to backend/product/auth/http/public/router.go index d6b537f..4c8e084 100644 --- a/backend/edge/shared/internal/transport/http/router/public/router.go +++ b/backend/product/auth/http/public/router.go @@ -3,16 +3,16 @@ package public import ( "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/config" + re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/product/verification/store" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" ) diff --git a/backend/edge/shared/internal/transport/http/router/public/validate.go b/backend/product/auth/http/public/validate.go similarity index 83% rename from backend/edge/shared/internal/transport/http/router/public/validate.go rename to backend/product/auth/http/public/validate.go index 0d4b93e..b8fab90 100644 --- a/backend/edge/shared/internal/transport/http/router/public/validate.go +++ b/backend/product/auth/http/public/validate.go @@ -3,15 +3,16 @@ package public import ( "context" "errors" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.uber.org/zap" ) @@ -21,7 +22,7 @@ func isExpectedRefreshValidationError(err error) bool { return errors.Is(err, merrors.ErrUnauthorized) } -func validateToken(token string, rt *model.RefreshToken) string { +func validateToken(token string, rt *sessionmodel.RefreshToken) string { if rt.AccountRef == nil { return "missing account reference" } @@ -38,7 +39,7 @@ func validateToken(token string, rt *model.RefreshToken) string { return "" } -func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, *model.RefreshToken, error) { +func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*customermodel.Account, *sresponse.TokenData, *sessionmodel.RefreshToken, error) { credential, err := browser.RefreshCredential(r, req.RefreshToken) if err != nil { return nil, nil, nil, err @@ -79,7 +80,7 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques return nil, nil, nil, merrors.Unauthorized(reason) } - var account model.Account + var account customermodel.Account if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) { pr.logger.Debug("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef)) return nil, nil, nil, merrors.Unauthorized("user not found") diff --git a/backend/edge/shared/internal/transport/http/router/router.go b/backend/product/auth/http/router.go similarity index 72% rename from backend/edge/shared/internal/transport/http/router/router.go rename to backend/product/auth/http/router.go index eff32e1..2580c5b 100644 --- a/backend/edge/shared/internal/transport/http/router/router.go +++ b/backend/product/auth/http/router.go @@ -1,9 +1,9 @@ package router import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/product/auth/response" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/service" ) type APIRouter interface { diff --git a/backend/edge/shared/http/ipguard/ipguard.go b/backend/product/auth/ipguard/ipguard.go similarity index 100% rename from backend/edge/shared/http/ipguard/ipguard.go rename to backend/product/auth/ipguard/ipguard.go diff --git a/backend/edge/shared/http/ipguard/ipguard_test.go b/backend/product/auth/ipguard/ipguard_test.go similarity index 100% rename from backend/edge/shared/http/ipguard/ipguard_test.go rename to backend/product/auth/ipguard/ipguard_test.go diff --git a/backend/edge/shared/interface/model/token.go b/backend/product/auth/model/token.go similarity index 82% rename from backend/edge/shared/interface/model/token.go rename to backend/product/auth/model/token.go index ac94199..5988758 100644 --- a/backend/edge/shared/interface/model/token.go +++ b/backend/product/auth/model/token.go @@ -2,12 +2,13 @@ package model import ( "fmt" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration" + "git.sendico.io/tech/sxchnge/product/auth/config" + mduration "git.sendico.io/tech/sxchnge/shared/duration" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -16,12 +17,12 @@ type AccountToken struct { Login string Name string Locale string - model.SessionIdentifier + sessionmodel.SessionIdentifier Expiration time.Time Pending bool } -func createAccountToken(a *model.Account, expiration int, session model.SessionIdentifier) AccountToken { +func createAccountToken(a *customermodel.Account, expiration int, session sessionmodel.SessionIdentifier) AccountToken { return AccountToken{ AccountRef: *a.GetID(), Login: a.Login, @@ -110,7 +111,7 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) { return &at, nil } -func Account2ClaimsForSession(a *model.Account, expiration int, session model.SessionIdentifier) middleware.MapClaims { +func Account2ClaimsForSession(a *customermodel.Account, expiration int, session sessionmodel.SessionIdentifier) middleware.MapClaims { t := createAccountToken(a, expiration, session) return middleware.MapClaims{ paramNameID: t.AccountRef.Hex(), @@ -125,8 +126,8 @@ func Account2ClaimsForSession(a *model.Account, expiration int, session model.Se } } -func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims { - t := createAccountToken(a, expirationMinutes/60, model.SessionIdentifier{}) +func PendingAccount2Claims(a *customermodel.Account, expirationMinutes int) middleware.MapClaims { + t := createAccountToken(a, expirationMinutes/60, sessionmodel.SessionIdentifier{}) t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute) t.Pending = true return middleware.MapClaims{ diff --git a/backend/edge/shared/interface/model/token_test.go b/backend/product/auth/model/token_test.go similarity index 76% rename from backend/edge/shared/interface/model/token_test.go rename to backend/product/auth/model/token_test.go index 741ba90..1c24e9d 100644 --- a/backend/edge/shared/interface/model/token_test.go +++ b/backend/product/auth/model/token_test.go @@ -1,19 +1,19 @@ package model import ( - "testing" - - basemodel "git.sendico.io/tech/sxchnge/bff/pkg/model" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "go.mongodb.org/mongo-driver/v2/bson" + "testing" ) func TestAccountTokenPreservesSessionIdentity(t *testing.T) { - account := &basemodel.Account{} + account := &customermodel.Account{} account.SetID(bson.NewObjectID()) account.Login = "security@example.com" account.Name = "Security" account.Locale = "en" - session := basemodel.SessionIdentifier{ + session := sessionmodel.SessionIdentifier{ ClientID: "web", DeviceID: "device-a", SessionVersion: "session-version-a", diff --git a/backend/product/auth/request/login.go b/backend/product/auth/request/login.go new file mode 100644 index 0000000..cc5feb0 --- /dev/null +++ b/backend/product/auth/request/login.go @@ -0,0 +1,11 @@ +package srequest + +import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" + +import customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + +type Login struct { + sessionmodel.SessionIdentifier `json:",inline"` + customermodel.LoginData `json:"login"` + ClientSecret string `json:"clientSecret,omitempty"` +} diff --git a/backend/product/auth/request/refresh.go b/backend/product/auth/request/refresh.go new file mode 100644 index 0000000..7de953e --- /dev/null +++ b/backend/product/auth/request/refresh.go @@ -0,0 +1,5 @@ +package srequest + +import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" + +type AccessTokenRefresh = sessionmodel.ClientRefreshToken diff --git a/backend/product/auth/request/rotate.go b/backend/product/auth/request/rotate.go new file mode 100644 index 0000000..ac440b0 --- /dev/null +++ b/backend/product/auth/request/rotate.go @@ -0,0 +1,5 @@ +package srequest + +import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" + +type TokenRefreshRotate = sessionmodel.ClientRefreshToken diff --git a/backend/edge/shared/interface/api/srequest/validateable.go b/backend/product/auth/request/validateable.go similarity index 100% rename from backend/edge/shared/interface/api/srequest/validateable.go rename to backend/product/auth/request/validateable.go diff --git a/backend/edge/shared/interface/api/srequest/verification.go b/backend/product/auth/request/verification.go similarity index 55% rename from backend/edge/shared/interface/api/srequest/verification.go rename to backend/product/auth/request/verification.go index 551e5ca..1dda9a2 100644 --- a/backend/edge/shared/interface/api/srequest/verification.go +++ b/backend/product/auth/request/verification.go @@ -1,6 +1,6 @@ package srequest -import "git.sendico.io/tech/sxchnge/bff/pkg/model" +import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" type VerificationCodeRequest struct { Purpose string `json:"purpose"` @@ -10,6 +10,6 @@ type VerificationCodeRequest struct { type CodeVerificationRequest struct { VerificationCodeRequest `json:",inline"` - Code string `json:"code"` - SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"` + Code string `json:"code"` + SessionIdentifier sessionmodel.SessionIdentifier `json:"sessionIdentifier"` } diff --git a/backend/edge/shared/interface/api/sresponse/account.go b/backend/product/auth/response/account.go similarity index 68% rename from backend/edge/shared/interface/api/sresponse/account.go rename to backend/product/auth/response/account.go index ee2c821..a292273 100644 --- a/backend/edge/shared/interface/api/sresponse/account.go +++ b/backend/product/auth/response/account.go @@ -1,11 +1,11 @@ package sresponse import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type AccountData struct { @@ -22,14 +22,14 @@ type accountResponse struct { Account AccountData `json:"account"` } -func NewAccountData(account *model.Account) *AccountData { +func NewAccountData(account *customermodel.Account) *AccountData { return &AccountData{ ID: account.ID.Hex(), Name: account.Name, LastName: account.LastName, Login: account.Login, Locale: account.Locale, AvatarURL: account.AvatarURL, } } -func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc { +func Account(logger mlogger.Logger, account *customermodel.Account, accessToken *TokenData) http.HandlerFunc { return response.Ok( logger, &accountResponse{ diff --git a/backend/edge/shared/interface/api/sresponse/authresp.go b/backend/product/auth/response/authresp.go similarity index 100% rename from backend/edge/shared/interface/api/sresponse/authresp.go rename to backend/product/auth/response/authresp.go diff --git a/backend/edge/shared/interface/api/sresponse/file.go b/backend/product/auth/response/file.go similarity index 68% rename from backend/edge/shared/interface/api/sresponse/file.go rename to backend/product/auth/response/file.go index bd07d78..ab198ce 100644 --- a/backend/edge/shared/interface/api/sresponse/file.go +++ b/backend/product/auth/response/file.go @@ -3,8 +3,8 @@ package sresponse import ( "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type fileUploaded struct { diff --git a/backend/edge/shared/interface/api/sresponse/login.go b/backend/product/auth/response/login.go similarity index 65% rename from backend/edge/shared/interface/api/sresponse/login.go rename to backend/product/auth/response/login.go index c987f10..e26d9c3 100644 --- a/backend/edge/shared/interface/api/sresponse/login.go +++ b/backend/product/auth/response/login.go @@ -1,12 +1,12 @@ package sresponse import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type loginResponse struct { @@ -14,7 +14,7 @@ type loginResponse struct { RefreshToken TokenData `json:"refreshToken"` } -func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc { +func Login(logger mlogger.Logger, account *customermodel.Account, accessToken, refreshToken *TokenData) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { responseRefreshToken := *refreshToken if browser.UsesCookie(r) { diff --git a/backend/edge/shared/interface/api/sresponse/login_pending.go b/backend/product/auth/response/login_pending.go similarity index 61% rename from backend/edge/shared/interface/api/sresponse/login_pending.go rename to backend/product/auth/response/login_pending.go index d0333d0..ab676a9 100644 --- a/backend/edge/shared/interface/api/sresponse/login_pending.go +++ b/backend/product/auth/response/login_pending.go @@ -1,11 +1,11 @@ package sresponse import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type pendingLoginResponse struct { @@ -14,7 +14,7 @@ type pendingLoginResponse struct { Target string `json:"target"` } -func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc { +func LoginPending(logger mlogger.Logger, account *customermodel.Account, pendingToken *TokenData, target string) http.HandlerFunc { return response.Accepted( logger, &pendingLoginResponse{ diff --git a/backend/edge/shared/interface/api/sresponse/login_test.go b/backend/product/auth/response/login_test.go similarity index 84% rename from backend/edge/shared/interface/api/sresponse/login_test.go rename to backend/product/auth/response/login_test.go index 9866324..4637341 100644 --- a/backend/edge/shared/interface/api/sresponse/login_test.go +++ b/backend/product/auth/response/login_test.go @@ -1,14 +1,14 @@ package sresponse import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "net/http" "net/http/httptest" "strings" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/browser" "github.com/stretchr/testify/require" "go.uber.org/zap" ) @@ -21,7 +21,7 @@ func TestBrowserLoginMovesRefreshCredentialToHttpOnlyCookie(t *testing.T) { req.Header.Set("X-Forwarded-Proto", "https") recorder := httptest.NewRecorder() - Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req) + Login(zap.NewNop(), &customermodel.Account{}, access, refresh).ServeHTTP(recorder, req) require.Equal(t, http.StatusOK, recorder.Code) require.NotContains(t, recorder.Body.String(), refresh.Token) @@ -39,14 +39,14 @@ func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://native.test/api/v1/accounts/login", nil) recorder := httptest.NewRecorder() - Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req) + Login(zap.NewNop(), &customermodel.Account{}, access, refresh).ServeHTTP(recorder, req) require.Contains(t, recorder.Body.String(), refresh.Token) require.Empty(t, recorder.Header().Get("Set-Cookie")) } func TestCustomerResponseExcludesStorageAndPlatformFields(t *testing.T) { - account := &model.Account{Password: "private-password-hash", Status: model.AccountActive} + account := &customermodel.Account{Password: "private-password-hash", Status: customermodel.AccountActive} account.Login = "customer@example.test" account.Name = "Customer" recorder := httptest.NewRecorder() diff --git a/backend/product/auth/response/response.go b/backend/product/auth/response/response.go new file mode 100644 index 0000000..502f575 --- /dev/null +++ b/backend/product/auth/response/response.go @@ -0,0 +1,14 @@ +package sresponse + +import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + "net/http" + + emodel "git.sendico.io/tech/sxchnge/product/auth/model" +) + +type ( + HandlerFunc = func(r *http.Request) http.HandlerFunc + AccountHandlerFunc = func(r *http.Request, account *customermodel.Account, accessToken *TokenData) http.HandlerFunc + PendingAccountHandlerFunc = func(r *http.Request, account *customermodel.Account, token *emodel.AccountToken) http.HandlerFunc +) diff --git a/backend/edge/shared/interface/api/sresponse/result.go b/backend/product/auth/response/result.go similarity index 85% rename from backend/edge/shared/interface/api/sresponse/result.go rename to backend/product/auth/response/result.go index 3ce4b12..48b5ba1 100644 --- a/backend/edge/shared/interface/api/sresponse/result.go +++ b/backend/product/auth/response/result.go @@ -3,8 +3,8 @@ package sresponse import ( "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/http/response" + "git.sendico.io/tech/sxchnge/shared/logging" ) type resultAuth struct { diff --git a/backend/edge/shared/interface/api/sresponse/token.go b/backend/product/auth/response/token.go similarity index 100% rename from backend/edge/shared/interface/api/sresponse/token.go rename to backend/product/auth/response/token.go diff --git a/backend/edge/shared/interface/api/sresponse/verification.go b/backend/product/auth/response/verification.go similarity index 100% rename from backend/edge/shared/interface/api/sresponse/verification.go rename to backend/product/auth/response/verification.go diff --git a/backend/edge/shared/auth/storage/stores.go b/backend/product/auth/storage/stores.go similarity index 84% rename from backend/edge/shared/auth/storage/stores.go rename to backend/product/auth/storage/stores.go index 30edf34..1e84b5f 100644 --- a/backend/edge/shared/auth/storage/stores.go +++ b/backend/product/auth/storage/stores.go @@ -1,14 +1,15 @@ package storage import ( - edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/product/customers/store/mongo" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/product/verification/store" + "git.sendico.io/tech/sxchnge/shared/database" + edgemongo "git.sendico.io/tech/sxchnge/shared/database/dependencies" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" "go.uber.org/zap" ) @@ -26,13 +27,13 @@ type TokenStores struct { type resolvedConfig struct { deps *edgemongo.Dependencies - storage edgeapi.AuthStorageConfig + storage middleware.AuthStorageConfig } func OpenStores( logger mlogger.Logger, dbf db.Factory, - config *edgeapi.AuthStorageConfig, + config *middleware.AuthStorageConfig, ) (*Stores, error) { resolved, err := resolve(logger, dbf, config) if err != nil { @@ -48,7 +49,7 @@ func OpenStores( zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection), ) - adb, err := dbf.NewAccountDBWithCollection(resolved.storage.AccountsCollection) + adb, err := accountdb.CreateWithCollection(logger, resolved.deps.Database, resolved.storage.AccountsCollection) if err != nil { logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", resolved.storage.AccountsCollection)) return nil, err @@ -76,7 +77,7 @@ func OpenStores( func OpenTokenStores( logger mlogger.Logger, dbf db.Factory, - config *edgeapi.AuthStorageConfig, + config *middleware.AuthStorageConfig, ) (*TokenStores, error) { resolved, err := resolve(logger, dbf, config) if err != nil { @@ -95,7 +96,7 @@ func OpenTokenStores( func resolve( logger mlogger.Logger, dbf db.Factory, - config *edgeapi.AuthStorageConfig, + config *middleware.AuthStorageConfig, ) (*resolvedConfig, error) { deps, err := edgemongo.ResolveDependencies(dbf) if err != nil { diff --git a/backend/config.example.yml b/backend/product/config.example.yml similarity index 100% rename from backend/config.example.yml rename to backend/product/config.example.yml diff --git a/backend/edge/product/interface/accountservice/internal/customer_test.go b/backend/product/customers/internal/customer_test.go similarity index 70% rename from backend/edge/product/interface/accountservice/internal/customer_test.go rename to backend/product/customers/internal/customer_test.go index b6f0387..16fec93 100644 --- a/backend/edge/product/interface/accountservice/internal/customer_test.go +++ b/backend/product/customers/internal/customer_test.go @@ -2,25 +2,26 @@ package accountserviceimp import ( "context" - "net/url" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/auth/config" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" + "git.sendico.io/tech/sxchnge/product/verification/store" + "git.sendico.io/tech/sxchnge/shared/database" + edgemongo "git.sendico.io/tech/sxchnge/shared/database/dependencies" + "git.sendico.io/tech/sxchnge/shared/errors" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go/modules/mongodb" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" + "net/url" + "testing" + "time" ) -func customer(login string) *model.Account { - return (&model.AccountData{LoginData: model.LoginData{UserDataBase: model.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: model.Describable{Name: "Customer"}}).ToAccount() +func customer(login string) *customermodel.Account { + return (&customermodel.AccountData{LoginData: customermodel.LoginData{UserDataBase: customermodel.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: sharedmodel.Describable{Name: "Customer"}}).ToAccount() } // Uses the actual copied repositories against an empty SXCHNGE database. @@ -39,26 +40,26 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) { query.Set("directConnection", "true") endpoint.RawQuery = query.Encode() uri = endpoint.String() - factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_customers"}}) + factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": uri, "database": "sxchnge_customers"}}) require.NoError(t, err) t.Cleanup(factory.CloseConnection) // Custom collections prove lifecycle and login storage select the same data. - config := &api.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"} + config := &middleware.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"} svc, err := NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, config) require.NoError(t, err) first, second := customer("first@example.test"), customer("second@example.test") firstToken, err := svc.CreateAccount(ctx, first) require.NoError(t, err) require.NotEmpty(t, firstToken) - require.Equal(t, model.AccountPendingVerification, first.Status) + require.Equal(t, customermodel.AccountPendingVerification, first.Status) require.True(t, first.MatchPassword("CustomerPassword1!")) _, err = svc.CreateAccount(ctx, second) require.NoError(t, err) require.NotEqual(t, first.ID, second.ID) // Activation tokens cannot be used as another customer's token. - _, err = svc.vdb.Consume(ctx, second.ID, model.PurposeAccountActivation, "", firstToken) + _, err = svc.vdb.Consume(ctx, second.ID, verificationmodel.PurposeAccountActivation, "", firstToken) require.Error(t, err) - _, err = svc.vdb.Consume(ctx, first.ID, model.PurposeAccountActivation, "", firstToken) + _, err = svc.vdb.Consume(ctx, first.ID, verificationmodel.PurposeAccountActivation, "", firstToken) require.NoError(t, err) _, err = svc.CreateAccount(ctx, customer("first@example.test")) require.ErrorIs(t, err, merrors.ErrDataConflict) @@ -68,14 +69,14 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) { require.ErrorIs(t, err, merrors.ErrInvalidArg) require.ErrorIs(t, svc.DeleteAccount(ctx, bson.NilObjectID), merrors.ErrInvalidArg) - for _, c := range []*model.Account{first, second} { - require.NoError(t, svc.sessions.Create(ctx, &model.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: model.ClientRefreshToken{SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}})) + for _, c := range []*customermodel.Account{first, second} { + require.NoError(t, svc.sessions.Create(ctx, &sessionmodel.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: sessionmodel.ClientRefreshToken{SessionIdentifier: sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}})) } require.NoError(t, svc.DeleteAccount(ctx, first.ID)) - var stored model.Account + var stored customermodel.Account require.ErrorIs(t, svc.accountDB.Get(ctx, first.ID, &stored), merrors.ErrNoData) require.NoError(t, svc.accountDB.Get(ctx, second.ID, &stored)) - session := &model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"} + session := &sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"} _, err = svc.sessions.GetSession(ctx, first.ID, session) require.Error(t, err) _, err = svc.sessions.GetSession(ctx, second.ID, session) diff --git a/backend/edge/product/interface/accountservice/internal/service.go b/backend/product/customers/internal/service.go similarity index 80% rename from backend/edge/product/interface/accountservice/internal/service.go rename to backend/product/customers/internal/service.go index ef82c0e..cf73553 100644 --- a/backend/edge/product/interface/accountservice/internal/service.go +++ b/backend/product/customers/internal/service.go @@ -4,21 +4,21 @@ import ( "context" "errors" "fmt" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "time" "unicode" - authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/config" + authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" + "git.sendico.io/tech/sxchnge/product/customers/store" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/product/verification/store" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -33,7 +33,7 @@ type service struct { transactions transaction.Factory } -func validateUserRequest(u *model.Account) error { +func validateUserRequest(u *customermodel.Account) error { if u == nil { return merrors.InvalidArgument("account is required") } @@ -96,19 +96,19 @@ func (s *service) ValidatePassword( return nil } -func (s *service) ValidateAccount(acct *model.Account) error { +func (s *service) ValidateAccount(acct *customermodel.Account) error { if err := validateUserRequest(acct); err != nil { s.logger.Warn("Invalid signup account received", zap.Error(err)) return err } if err := s.ValidatePassword(acct.Password, nil); err != nil { - s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct)) + s.logger.Warn("Password validation failed", zap.Error(err), mzap.Email(acct.Login)) return err } if err := acct.HashPassword(); err != nil { - s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct)) + s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Email(acct.Login)) return err } @@ -117,7 +117,7 @@ func (s *service) ValidateAccount(acct *model.Account) error { func (s *service) CreateAccount( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (string, error) { if acct == nil { return "", merrors.InvalidArgument("account is required") @@ -129,9 +129,9 @@ func (s *service) CreateAccount( if err := s.ValidateAccount(candidate); err != nil { return "", err } - candidate.Status = model.AccountPendingVerification + candidate.Status = customermodel.AccountPendingVerification // The customer and activation token either both commit or neither does. - var saved *model.Account + var saved *customermodel.Account result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { saved = candidate.Copy() if err := s.accountDB.Create(tx, saved); err != nil { @@ -158,14 +158,14 @@ func (s *service) CreateAccount( func (s *service) VerifyAccount( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (string, error) { if acct == nil || acct.ID.IsZero() { return "", merrors.InvalidArgument("customer reference is required") } token, err := s.vdb.Create( ctx, - verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, ""). + verification.NewLinkRequest(*acct.GetID(), verificationmodel.PurposeAccountActivation, ""). WithTTL(time.Hour*24), ) if err != nil { @@ -200,21 +200,21 @@ func (s *service) DeleteAccount( func (s *service) ResetPassword( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (string, error) { if acct == nil || acct.ID.IsZero() { return "", merrors.InvalidArgument("customer reference is required") } return s.vdb.Create( ctx, - verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, ""). + verification.NewOTPRequest(*acct.GetID(), verificationmodel.PurposePasswordReset, ""). WithTTL(time.Hour), ) } func (s *service) UpdateLogin( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, newLogin string, ) (string, error) { if acct == nil || acct.ID.IsZero() { @@ -222,7 +222,7 @@ func (s *service) UpdateLogin( } return s.vdb.Create( ctx, - verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin). + verification.NewOTPRequest(*acct.GetID(), verificationmodel.PurposeEmailChange, newLogin). WithTTL(time.Hour), ) } @@ -231,7 +231,7 @@ func NewAccountService( l mlogger.Logger, dbf db.Factory, config *middleware.PasswordConfig, - storageConfig *api.AuthStorageConfig, + storageConfig *middleware.AuthStorageConfig, ) (*service, error) { logger := l.Named("account_service") diff --git a/backend/edge/product/interface/accountservice/internal/service_test.go b/backend/product/customers/internal/service_test.go similarity index 82% rename from backend/edge/product/interface/accountservice/internal/service_test.go rename to backend/product/customers/internal/service_test.go index 467981d..9a1119b 100644 --- a/backend/edge/product/interface/accountservice/internal/service_test.go +++ b/backend/product/customers/internal/service_test.go @@ -1,10 +1,11 @@ package accountserviceimp import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "testing" - apiconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + apiconfig "git.sendico.io/tech/sxchnge/product/auth/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -93,14 +94,14 @@ func TestValidateAccount(t *testing.T) { } t.Run("ValidAccount", func(t *testing.T) { - account := &model.Account{ - AccountPublic: model.AccountPublic{ - AccountBase: model.AccountBase{ - Describable: model.Describable{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + AccountBase: customermodel.AccountBase{ + Describable: sharedmodel.Describable{ Name: "Test User", }, }, - UserDataBase: model.UserDataBase{ + UserDataBase: customermodel.UserDataBase{ Login: "test@example.com", }, }, @@ -116,9 +117,9 @@ func TestValidateAccount(t *testing.T) { }) t.Run("AccountMissingName", func(t *testing.T) { - account := &model.Account{ - AccountPublic: model.AccountPublic{ - UserDataBase: model.UserDataBase{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + UserDataBase: customermodel.UserDataBase{ Login: "test@example.com", }, }, @@ -131,10 +132,10 @@ func TestValidateAccount(t *testing.T) { }) t.Run("AccountMissingLogin", func(t *testing.T) { - account := &model.Account{ - AccountPublic: model.AccountPublic{ - AccountBase: model.AccountBase{ - Describable: model.Describable{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + AccountBase: customermodel.AccountBase{ + Describable: sharedmodel.Describable{ Name: "Test User", }, }, @@ -148,14 +149,14 @@ func TestValidateAccount(t *testing.T) { }) t.Run("AccountMissingPassword", func(t *testing.T) { - account := &model.Account{ - AccountPublic: model.AccountPublic{ - AccountBase: model.AccountBase{ - Describable: model.Describable{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + AccountBase: customermodel.AccountBase{ + Describable: sharedmodel.Describable{ Name: "Test User", }, }, - UserDataBase: model.UserDataBase{ + UserDataBase: customermodel.UserDataBase{ Login: "test@example.com", }, }, @@ -168,14 +169,14 @@ func TestValidateAccount(t *testing.T) { }) t.Run("AccountInvalidPassword", func(t *testing.T) { - account := &model.Account{ - AccountPublic: model.AccountPublic{ - AccountBase: model.AccountBase{ - Describable: model.Describable{ + account := &customermodel.Account{ + AccountPublic: customermodel.AccountPublic{ + AccountBase: customermodel.AccountBase{ + Describable: sharedmodel.Describable{ Name: "Test User", }, }, - UserDataBase: model.UserDataBase{ + UserDataBase: customermodel.UserDataBase{ Login: "test@example.com", }, }, diff --git a/backend/pkg/model/account.go b/backend/product/customers/model/account.go similarity index 71% rename from backend/pkg/model/account.go rename to backend/product/customers/model/account.go index a7d331a..6655217 100644 --- a/backend/pkg/model/account.go +++ b/backend/product/customers/model/account.go @@ -1,8 +1,9 @@ package model import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/record" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" + "git.sendico.io/tech/sxchnge/shared/service" "golang.org/x/crypto/bcrypt" ) @@ -17,11 +18,11 @@ const ( ) type AccountBase struct { - storable.Base `bson:",inline" json:",inline"` - ArchivableBase `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - LastName string `bson:"lastName" json:"lastName"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` + storable.Base `bson:",inline" json:",inline"` + sharedmodel.ArchivableBase `bson:",inline" json:",inline"` + sharedmodel.Describable `bson:",inline" json:",inline"` + LastName string `bson:"lastName" json:"lastName"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` } func (*AccountBase) Collection() string { diff --git a/backend/pkg/model/userdata.go b/backend/product/customers/model/userdata.go similarity index 69% rename from backend/pkg/model/userdata.go rename to backend/product/customers/model/userdata.go index 8a56bd1..22d1b36 100644 --- a/backend/pkg/model/userdata.go +++ b/backend/product/customers/model/userdata.go @@ -1,5 +1,7 @@ package model +import sharedmodel "git.sendico.io/tech/sxchnge/shared/model" + type UserDataBase struct { Login string `bson:"login" json:"login"` Locale string `bson:"locale" json:"locale"` @@ -11,9 +13,9 @@ type LoginData struct { } type AccountData struct { - LoginData `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - LastName string `bson:"lastName" json:"lastName"` + LoginData `bson:",inline" json:",inline"` + sharedmodel.Describable `bson:",inline" json:",inline"` + LastName string `bson:"lastName" json:"lastName"` } func (ad *AccountData) ToAccount() *Account { diff --git a/backend/product/customers/store/account.go b/backend/product/customers/store/account.go new file mode 100644 index 0000000..59dfaf9 --- /dev/null +++ b/backend/product/customers/store/account.go @@ -0,0 +1,14 @@ +package account + +import ( + "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + + "git.sendico.io/tech/sxchnge/shared/database/template" +) + +// DB is the interface which must be implemented by all db drivers +type DB interface { + template.DB[*customermodel.Account] + GetByEmail(ctx context.Context, email string) (*customermodel.Account, error) +} diff --git a/backend/pkg/db/internal/mongo/accountdb/db.go b/backend/product/customers/store/mongo/db.go similarity index 67% rename from backend/pkg/db/internal/mongo/accountdb/db.go rename to backend/product/customers/store/mongo/db.go index c83bf6b..58dd464 100644 --- a/backend/pkg/db/internal/mongo/accountdb/db.go +++ b/backend/product/customers/store/mongo/db.go @@ -1,19 +1,19 @@ package accountdb import ( + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "strings" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/template" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" ) type AccountDB struct { - template.DBImp[*model.Account] + template.DBImp[*customermodel.Account] } func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) { @@ -28,7 +28,7 @@ func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection } p := &AccountDB{ - DBImp: *template.Create[*model.Account](logger, collection, db), + DBImp: *template.Create[*customermodel.Account](logger, collection, db), } if err := p.Repository.CreateIndex(&ri.Definition{ diff --git a/backend/product/customers/store/mongo/user.go b/backend/product/customers/store/mongo/user.go new file mode 100644 index 0000000..c59bd68 --- /dev/null +++ b/backend/product/customers/store/mongo/user.go @@ -0,0 +1,13 @@ +package accountdb + +import ( + "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + + "git.sendico.io/tech/sxchnge/shared/database/repository" +) + +func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*customermodel.Account, error) { + var account customermodel.Account + return &account, db.FindOne(ctx, repository.Filter("login", email), &account) +} diff --git a/backend/edge/product/interface/accountservice/types.go b/backend/product/customers/types.go similarity index 70% rename from backend/edge/product/interface/accountservice/types.go rename to backend/product/customers/types.go index fa2807f..fa3b0e7 100644 --- a/backend/edge/product/interface/accountservice/types.go +++ b/backend/product/customers/types.go @@ -2,13 +2,12 @@ package accountservice import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" - accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api" - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/auth/config" + accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -25,7 +24,7 @@ type AccountService interface { // 1) generate reset password token ResetPassword( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (verificationToken string, err error) // CreateAccount will: @@ -33,17 +32,17 @@ type AccountService interface { // 2) issue an activation token CreateAccount( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (verificationToken string, err error) VerifyAccount( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, ) (verificationToken string, err error) UpdateLogin( ctx context.Context, - acct *model.Account, + acct *customermodel.Account, newLogin string, ) (verificationToken string, err error) @@ -58,7 +57,7 @@ func NewAccountService( logger mlogger.Logger, dbf db.Factory, config *middleware.PasswordConfig, - storageConfig *api.AuthStorageConfig, + storageConfig *middleware.AuthStorageConfig, ) (AccountService, error) { return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig) } diff --git a/backend/go.mod b/backend/product/go.mod similarity index 94% rename from backend/go.mod rename to backend/product/go.mod index faf3ea6..ce09755 100644 --- a/backend/go.mod +++ b/backend/product/go.mod @@ -1,26 +1,7 @@ -module git.sendico.io/tech/sxchnge/bff +module git.sendico.io/tech/sxchnge/product go 1.27.0 -require ( - github.com/go-chi/chi/v5 v5.3.2 - github.com/go-chi/cors v1.2.2 - github.com/go-chi/jwtauth/v5 v5.4.0 - github.com/go-chi/metrics v0.1.1 - github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/uuid v1.6.0 - github.com/mattn/go-colorable v0.1.15 - github.com/prometheus/client_golang v1.24.1 - github.com/stretchr/testify v1.12.1 - github.com/testcontainers/testcontainers-go v0.44.0 - github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 - go.mongodb.org/mongo-driver/v2 v2.9.0 - go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.56.0 - gopkg.in/yaml.v3 v3.0.1 - moul.io/chizap v1.0.3 -) - require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -43,6 +24,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect @@ -52,6 +34,7 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect @@ -91,3 +74,23 @@ require ( golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +require ( + git.sendico.io/tech/sxchnge/shared v0.0.0 + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/jwtauth/v5 v5.4.0 + github.com/go-chi/metrics v0.1.1 + github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.24.1 + github.com/stretchr/testify v1.12.1 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 + go.mongodb.org/mongo-driver/v2 v2.9.0 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.56.0 + gopkg.in/yaml.v3 v3.0.1 + moul.io/chizap v1.0.3 +) + +replace git.sendico.io/tech/sxchnge/shared => ../shared diff --git a/backend/go.sum b/backend/product/go.sum similarity index 100% rename from backend/go.sum rename to backend/product/go.sum diff --git a/backend/product/main.go b/backend/product/main.go new file mode 100644 index 0000000..e0e04a9 --- /dev/null +++ b/backend/product/main.go @@ -0,0 +1,21 @@ +// Adapted from Sendico api/edge/product/main.go; see ../PROVENANCE.md. +package main + +import ( + application "git.sendico.io/tech/sxchnge/product/app/process" + smain "git.sendico.io/tech/sxchnge/product/app/process/run" + "git.sendico.io/tech/sxchnge/product/app/profile" + "git.sendico.io/tech/sxchnge/product/app/server" + "git.sendico.io/tech/sxchnge/product/app/version" + "git.sendico.io/tech/sxchnge/shared/logging" + "os" +) + +func factory(logger mlogger.Logger, file string, debug bool) (application.Application, error) { + return server.Create(logger, file, debug, profile.Runtime()) +} +func main() { + if smain.RunServer("sxchnge.bff", appversion.Create(), factory) != nil { + os.Exit(1) + } +} diff --git a/backend/edge/shared/auth/browser/browser.go b/backend/product/sessions/browser/browser.go similarity index 98% rename from backend/edge/shared/auth/browser/browser.go rename to backend/product/sessions/browser/browser.go index 14bc11d..29ec15a 100644 --- a/backend/edge/shared/auth/browser/browser.go +++ b/backend/product/sessions/browser/browser.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) const ( diff --git a/backend/edge/shared/auth/browser/browser_test.go b/backend/product/sessions/browser/browser_test.go similarity index 100% rename from backend/edge/shared/auth/browser/browser_test.go rename to backend/product/sessions/browser/browser_test.go diff --git a/backend/edge/shared/auth/sessionmetrics/metrics.go b/backend/product/sessions/metrics/metrics.go similarity index 100% rename from backend/edge/shared/auth/sessionmetrics/metrics.go rename to backend/product/sessions/metrics/metrics.go diff --git a/backend/pkg/model/client.go b/backend/product/sessions/model/client.go similarity index 89% rename from backend/pkg/model/client.go rename to backend/product/sessions/model/client.go index dd43e40..4958803 100644 --- a/backend/pkg/model/client.go +++ b/backend/product/sessions/model/client.go @@ -1,8 +1,8 @@ package model import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/model/refresh.go b/backend/product/sessions/model/refresh.go similarity index 88% rename from backend/pkg/model/refresh.go rename to backend/product/sessions/model/refresh.go index bc9c8ee..9c6cb31 100644 --- a/backend/pkg/model/refresh.go +++ b/backend/product/sessions/model/refresh.go @@ -3,8 +3,8 @@ package model import ( "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/model/sessionid.go b/backend/product/sessions/model/sessionid.go similarity index 100% rename from backend/pkg/model/sessionid.go rename to backend/product/sessions/model/sessionid.go diff --git a/backend/edge/shared/storage/refreshtokens/client.go b/backend/product/sessions/store/client.go similarity index 58% rename from backend/edge/shared/storage/refreshtokens/client.go rename to backend/product/sessions/store/client.go index 844d327..9810fc5 100644 --- a/backend/edge/shared/storage/refreshtokens/client.go +++ b/backend/product/sessions/store/client.go @@ -2,11 +2,10 @@ package refreshtokens import ( "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" ) -func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) { - var client model.Client +func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) { + var client sessionmodel.Client return &client, db.clients.FindOneByFilter(ctx, filterByClientId(clientID), &client) } diff --git a/backend/edge/shared/storage/refreshtokens/crud.go b/backend/product/sessions/store/crud.go similarity index 84% rename from backend/edge/shared/storage/refreshtokens/crud.go rename to backend/product/sessions/store/crud.go index cef1040..ef99024 100644 --- a/backend/edge/shared/storage/refreshtokens/crud.go +++ b/backend/product/sessions/store/crud.go @@ -3,19 +3,19 @@ package refreshtokens import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging/fields" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) -func (db *RefreshTokenDB) Create(ctx context.Context, rt *model.RefreshToken) error { +func (db *RefreshTokenDB) Create(ctx context.Context, rt *sessionmodel.RefreshToken) error { if rt.LastUsedAt.IsZero() { rt.LastUsedAt = time.Now() } @@ -54,7 +54,7 @@ func (db *RefreshTokenDB) Create(ctx context.Context, rt *model.RefreshToken) er return nil } -func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *model.RefreshToken) (bool, error) { +func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *sessionmodel.RefreshToken) (bool, error) { patch := repository.Patch(). Set(repository.Field(TokenField), rt.RefreshToken). Set(repository.Field(ExpiresAtField), rt.ExpiresAt). @@ -64,7 +64,7 @@ func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *model.RefreshTo Set(repository.Field(IsRevokedField), rt.IsRevoked). Set(repository.Field(SessionVersionField), rt.SessionVersion) - var stored model.RefreshToken + var stored sessionmodel.RefreshToken matched, err := db.Repository.PatchOneByFilter( ctx, filterSlotByAccount(*rt.AccountRef, &rt.SessionIdentifier), @@ -86,7 +86,7 @@ func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *model.RefreshTo return true, nil } -func (db *RefreshTokenDB) Update(ctx context.Context, rt *model.RefreshToken) error { +func (db *RefreshTokenDB) Update(ctx context.Context, rt *sessionmodel.RefreshToken) error { rt.LastUsedAt = time.Now() // Use Patch instead of Update to avoid race conditions @@ -107,8 +107,8 @@ func (db *RefreshTokenDB) Delete(ctx context.Context, tokenRef bson.ObjectID) er return db.DBImp.Delete(ctx, tokenRef) } -func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error { - var rt model.RefreshToken +func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error { + var rt sessionmodel.RefreshToken f := filterByAccount(accountRef, session) if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil { if errors.Is(err, merrors.ErrNoData) { @@ -127,7 +127,7 @@ func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, return db.Patch(ctx, *rt.GetID(), patch) } -func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) { +func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error) { query := filter(&token.SessionIdentifier). And(repository.Query().Comparison(repository.Field(TokenField), builder.Eq, token.RefreshToken)) patch := repository.Patch(). @@ -137,8 +137,8 @@ func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRe return modified > 0, err } -func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) { - var rt model.RefreshToken +func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) { + var rt sessionmodel.RefreshToken if err := db.Repository.FindOneByFilter(ctx, filterByAccount(accountRef, session), &rt); err != nil { return nil, err } @@ -148,8 +148,8 @@ func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.Object return &rt, nil } -func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) { - var rt model.RefreshToken +func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) { + var rt sessionmodel.RefreshToken f := filter(&t.SessionIdentifier).And(repository.Query().Filter(repository.Field("token"), t.RefreshToken)) if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil { if !errors.Is(err, merrors.ErrNoData) { diff --git a/backend/edge/shared/storage/refreshtokens/crud_test.go b/backend/product/sessions/store/crud_test.go similarity index 85% rename from backend/edge/shared/storage/refreshtokens/crud_test.go rename to backend/product/sessions/store/crud_test.go index 7a2173a..4e5e97c 100644 --- a/backend/edge/shared/storage/refreshtokens/crud_test.go +++ b/backend/product/sessions/store/crud_test.go @@ -3,15 +3,15 @@ package refreshtokens import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/template" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -58,17 +58,17 @@ func (r *createRepository) Insert(_ context.Context, object storable.Storable, _ func newCreateDB(repo repository.Repository) *RefreshTokenDB { return &RefreshTokenDB{ - DBImp: template.DBImp[*model.RefreshToken]{ + DBImp: template.DBImp[*sessionmodel.RefreshToken]{ Logger: zap.NewNop(), Repository: repo, }, } } -func versionedToken(accountRef bson.ObjectID, version, value string) *model.RefreshToken { - return &model.RefreshToken{ - ClientRefreshToken: model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ +func versionedToken(accountRef bson.ObjectID, version, value string) *sessionmodel.RefreshToken { + return &sessionmodel.RefreshToken{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "web-app", DeviceID: "browser", SessionVersion: version, @@ -150,7 +150,7 @@ func TestCreatePreservesUnrelatedInsertConflict(t *testing.T) { } func TestCredentialFilterKeepsSessionVersion(t *testing.T) { - query := filterByAccount(bson.NewObjectID(), &model.SessionIdentifier{ + query := filterByAccount(bson.NewObjectID(), &sessionmodel.SessionIdentifier{ ClientID: "web-app", DeviceID: "browser", SessionVersion: "version-2", diff --git a/backend/edge/shared/storage/refreshtokens/db.go b/backend/product/sessions/store/db.go similarity index 83% rename from backend/edge/shared/storage/refreshtokens/db.go rename to backend/product/sessions/store/db.go index f6be858..8b9549a 100644 --- a/backend/edge/shared/storage/refreshtokens/db.go +++ b/backend/product/sessions/store/db.go @@ -1,14 +1,14 @@ package refreshtokens import ( + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/repository" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/template" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" ) @@ -19,7 +19,7 @@ type Collections struct { } type RefreshTokenDB struct { - template.DBImp[*model.RefreshToken] + template.DBImp[*sessionmodel.RefreshToken] clients repository.Repository collection Collections } @@ -41,7 +41,7 @@ func (c Collections) WithDefaults() Collections { func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) { collections = collections.WithDefaults() p := &RefreshTokenDB{ - DBImp: *template.Create[*model.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db), + DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db), clients: repository.CreateMongoRepository(db, collections.Clients), collection: collections, } diff --git a/backend/edge/shared/storage/refreshtokens/fields.go b/backend/product/sessions/store/fields.go similarity index 100% rename from backend/edge/shared/storage/refreshtokens/fields.go rename to backend/product/sessions/store/fields.go diff --git a/backend/edge/shared/storage/refreshtokens/filters.go b/backend/product/sessions/store/filters.go similarity index 62% rename from backend/edge/shared/storage/refreshtokens/filters.go rename to backend/product/sessions/store/filters.go index 6a04ccf..d204277 100644 --- a/backend/edge/shared/storage/refreshtokens/filters.go +++ b/backend/product/sessions/store/filters.go @@ -1,9 +1,9 @@ package refreshtokens import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + 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/builder" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -11,7 +11,7 @@ func filterByClientId(clientID string) builder.Query { return repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID) } -func filterSlot(session *model.SessionIdentifier) builder.Query { +func filterSlot(session *sessionmodel.SessionIdentifier) builder.Query { filter := filterByClientId(session.ClientID) filter.And( repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, session.DeviceID), @@ -20,7 +20,7 @@ func filterSlot(session *model.SessionIdentifier) builder.Query { return filter } -func filter(session *model.SessionIdentifier) builder.Query { +func filter(session *sessionmodel.SessionIdentifier) builder.Query { filter := filterSlot(session) if session.SessionVersion != "" { filter.And(repository.Query().Comparison(repository.Field("sessionVersion"), builder.Eq, session.SessionVersion)) @@ -28,10 +28,10 @@ func filter(session *model.SessionIdentifier) builder.Query { return filter } -func filterByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query { +func filterByAccount(accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) builder.Query { return filter(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef)) } -func filterSlotByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query { +func filterSlotByAccount(accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) builder.Query { return filterSlot(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef)) } diff --git a/backend/product/sessions/store/refreshtokens.go b/backend/product/sessions/store/refreshtokens.go new file mode 100644 index 0000000..4928367 --- /dev/null +++ b/backend/product/sessions/store/refreshtokens.go @@ -0,0 +1,19 @@ +package refreshtokens + +import ( + "context" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" + + "git.sendico.io/tech/sxchnge/shared/database/template" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type DB interface { + template.DB[*sessionmodel.RefreshToken] + Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error + RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error) + RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error + GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) + GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) + GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) +} diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go b/backend/product/sessions/store/refreshtokens_indexes_test.go similarity index 92% rename from backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go rename to backend/product/sessions/store/refreshtokens_indexes_test.go index 9ac328b..44ec66d 100644 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens_indexes_test.go +++ b/backend/product/sessions/store/refreshtokens_indexes_test.go @@ -8,9 +8,9 @@ import ( "fmt" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go b/backend/product/sessions/store/refreshtokens_misc_test.go similarity index 84% rename from backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go rename to backend/product/sessions/store/refreshtokens_misc_test.go index f822226..3a0c9fb 100644 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens_misc_test.go +++ b/backend/product/sessions/store/refreshtokens_misc_test.go @@ -6,11 +6,11 @@ package refreshtokens_test import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" @@ -35,8 +35,8 @@ func TestRefreshTokenDB_ClientManagement(t *testing.T) { err = db.Create(ctx, mobileToken) require.NoError(t, err) - webCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + webCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "web-app", DeviceID: "device1", }, @@ -48,8 +48,8 @@ func TestRefreshTokenDB_ClientManagement(t *testing.T) { assert.Equal(t, "web-app", retrievedToken.ClientID) assert.Equal(t, "device1", retrievedToken.DeviceID) - mobileCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + mobileCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "mobile-app", DeviceID: "device2", }, @@ -79,15 +79,15 @@ func TestRefreshTokenDB_SecurityScenarios(t *testing.T) { err := db.Create(ctx, refreshToken) require.NoError(t, err) - session := &model.SessionIdentifier{ + session := &sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, } err = db.Revoke(ctx, userID, session) require.NoError(t, err) - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -100,8 +100,8 @@ func TestRefreshTokenDB_SecurityScenarios(t *testing.T) { }) t.Run("Invalid_Token_Attempts", func(t *testing.T) { - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: "invalid-client", DeviceID: "invalid-device", }, @@ -131,13 +131,13 @@ func TestRefreshTokenDB_ExpiredTokenHandling(t *testing.T) { err := db.Create(ctx, refreshToken) require.NoError(t, err) - var storedToken model.RefreshToken + var storedToken sessionmodel.RefreshToken err = db.Get(ctx, *refreshToken.GetID(), &storedToken) require.NoError(t, err) assert.True(t, storedToken.ExpiresAt.Before(time.Now())) - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -166,8 +166,8 @@ func TestRefreshTokenDB_ConcurrentAccess(t *testing.T) { err := db.Create(ctx, refreshToken) require.NoError(t, err) - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -210,7 +210,7 @@ func TestRefreshTokenDB_EdgeCases(t *testing.T) { err = db.Delete(ctx, tokenID) require.NoError(t, err) - var result model.RefreshToken + var result sessionmodel.RefreshToken err = db.Get(ctx, tokenID, &result) assert.Error(t, err) assert.True(t, errors.Is(err, merrors.ErrNoData)) @@ -218,7 +218,7 @@ func TestRefreshTokenDB_EdgeCases(t *testing.T) { t.Run("Revoke_Non_Existent_Token", func(t *testing.T) { userID := bson.NewObjectID() - session := &model.SessionIdentifier{ + session := &sessionmodel.SessionIdentifier{ ClientID: "non-existent-client", DeviceID: "non-existent-device", } @@ -239,8 +239,8 @@ func TestRefreshTokenDB_EdgeCases(t *testing.T) { err = db.RevokeAll(ctx, userID, deviceID) require.NoError(t, err) - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go b/backend/product/sessions/store/refreshtokens_session_replacement_test.go similarity index 83% rename from backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go rename to backend/product/sessions/store/refreshtokens_session_replacement_test.go index 0277457..288e84c 100644 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_replacement_test.go +++ b/backend/product/sessions/store/refreshtokens_session_replacement_test.go @@ -6,12 +6,12 @@ package refreshtokens_test import ( "context" "errors" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" @@ -45,8 +45,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { assert.Equal(t, firstTokenID, *secondToken.GetID()) // First token should no longer work - firstCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + firstCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, SessionVersion: "session-version-1", @@ -57,8 +57,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { assert.Error(t, err) // Second token should work - secondCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + secondCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, SessionVersion: "session-version-2", @@ -87,7 +87,7 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { err = db.RevokeAll(ctx, userID, "") require.NoError(t, err) - var revoked model.RefreshToken + var revoked sessionmodel.RefreshToken err = db.Get(ctx, *firstToken.GetID(), &revoked) require.NoError(t, err) assert.True(t, revoked.IsRevoked) @@ -97,8 +97,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { err = db.Create(ctx, reissueToken) require.NoError(t, err) - newCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + newCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -108,8 +108,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { require.NoError(t, err) // Old token must remain unusable - oldCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + oldCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -141,14 +141,14 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { require.NotNil(t, firstToken.GetID()) firstTokenID := *firstToken.GetID() - session := &model.SessionIdentifier{ + session := &sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, } err = db.Revoke(ctx, userID, session) require.NoError(t, err) - var revoked model.RefreshToken + var revoked sessionmodel.RefreshToken err = db.Get(ctx, firstTokenID, &revoked) require.NoError(t, err) assert.True(t, revoked.IsRevoked) @@ -159,8 +159,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { require.NotNil(t, secondToken.GetID()) assert.NotEqual(t, firstTokenID, *secondToken.GetID()) - firstCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + firstCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -170,8 +170,8 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) { assert.Error(t, err) assert.True(t, errors.Is(err, merrors.ErrNoData)) - secondCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + secondCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go b/backend/product/sessions/store/refreshtokens_session_test.go similarity index 86% rename from backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go rename to backend/product/sessions/store/refreshtokens_session_test.go index 607baf9..d3ce92a 100644 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens_session_test.go +++ b/backend/product/sessions/store/refreshtokens_session_test.go @@ -5,13 +5,12 @@ package refreshtokens_test import ( "context" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" + "testing" + "time" ) func TestRefreshTokenDB_TokenRotation(t *testing.T) { @@ -35,8 +34,8 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { time.Sleep(10 * time.Millisecond) // Use token - should update LastUsedAt - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -59,8 +58,8 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { assert.Error(t, err) // New token should work - newCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + newCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -85,9 +84,9 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { time.Sleep(10 * time.Millisecond) - replacement := &model.RefreshToken{ - ClientRefreshToken: model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + replacement := &sessionmodel.RefreshToken{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -105,7 +104,7 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { require.NotNil(t, replacement.GetID()) assert.Equal(t, firstTokenID, *replacement.GetID()) - var stored model.RefreshToken + var stored sessionmodel.RefreshToken err = db.Get(ctx, firstTokenID, &stored) require.NoError(t, err) assert.False(t, stored.LastUsedAt.IsZero()) @@ -128,8 +127,8 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { time.Sleep(10 * time.Millisecond) - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -140,7 +139,7 @@ func TestRefreshTokenDB_TokenRotation(t *testing.T) { require.NoError(t, err) assert.True(t, retrievedToken.LastUsedAt.After(initialLastUsedAt)) - var stored model.RefreshToken + var stored sessionmodel.RefreshToken err = db.Get(ctx, *refreshToken.GetID(), &stored) require.NoError(t, err) assert.True(t, stored.LastUsedAt.After(initialLastUsedAt)) diff --git a/backend/edge/shared/storage/refreshtokens/refreshtokens_test.go b/backend/product/sessions/store/refreshtokens_test.go similarity index 86% rename from backend/edge/shared/storage/refreshtokens/refreshtokens_test.go rename to backend/product/sessions/store/refreshtokens_test.go index 76a7150..a3b707e 100644 --- a/backend/edge/shared/storage/refreshtokens/refreshtokens_test.go +++ b/backend/product/sessions/store/refreshtokens_test.go @@ -8,13 +8,13 @@ import ( "crypto/sha1" "errors" "fmt" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/errors" + factory "git.sendico.io/tech/sxchnge/shared/logging/factory" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -69,10 +69,10 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.D return db, database, cleanup } -func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *model.RefreshToken { - return &model.RefreshToken{ - ClientRefreshToken: model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ +func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *sessionmodel.RefreshToken { + return &sessionmodel.RefreshToken{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -106,8 +106,8 @@ func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) { require.NoError(t, err) // Step 2: User uses refresh token to get new access token - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -122,7 +122,7 @@ func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) { assert.False(t, retrievedToken.IsRevoked) // Step 3: User logs out - revoke the token - session := &model.SessionIdentifier{ + session := &sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, } @@ -153,8 +153,8 @@ func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) { require.NoError(t, err) // Step 3: Try to use revoked token - should fail - crt := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + crt := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, @@ -197,8 +197,8 @@ func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) { require.NoError(t, err) // Phone should still work - phoneCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + phoneCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: "phone-ios", }, @@ -208,8 +208,8 @@ func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) { require.NoError(t, err) // Tablet and desktop should be revoked - tabletCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + tabletCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: "tablet-android", }, @@ -218,8 +218,8 @@ func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) { _, err = db.GetByCRT(ctx, tabletCRT) assert.Error(t, err) - desktopCRT := &model.ClientRefreshToken{ - SessionIdentifier: model.SessionIdentifier{ + desktopCRT := &sessionmodel.ClientRefreshToken{ + SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: "desktop-windows", }, diff --git a/backend/edge/shared/storage/refreshtokens/revoke.go b/backend/product/sessions/store/revoke.go similarity index 84% rename from backend/edge/shared/storage/refreshtokens/revoke.go rename to backend/product/sessions/store/revoke.go index a704af8..0d0232c 100644 --- a/backend/edge/shared/storage/refreshtokens/revoke.go +++ b/backend/product/sessions/store/revoke.go @@ -4,8 +4,8 @@ import ( "context" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/edge/shared/auth/tokens/prepare_test.go b/backend/product/sessions/tokens/prepare_test.go similarity index 84% rename from backend/edge/shared/auth/tokens/prepare_test.go rename to backend/product/sessions/tokens/prepare_test.go index 524778e..d8fe687 100644 --- a/backend/edge/shared/auth/tokens/prepare_test.go +++ b/backend/product/sessions/tokens/prepare_test.go @@ -2,21 +2,22 @@ package tokens import ( "context" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "testing" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/product/sessions/store" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type recordingRefreshTokenDB struct { refreshtokens.DB - token *model.RefreshToken + token *sessionmodel.RefreshToken } -func (db *recordingRefreshTokenDB) Create(_ context.Context, token *model.RefreshToken) error { +func (db *recordingRefreshTokenDB) Create(_ context.Context, token *sessionmodel.RefreshToken) error { db.token = token return nil } @@ -72,7 +73,7 @@ func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) { request.Header.Set("X-Real-IP", tt.realIP) } - account := &model.Account{} + account := &customermodel.Account{} account.SetID(bson.NewObjectID()) db := &recordingRefreshTokenDB{} _, err := PrepareRefreshToken( @@ -81,7 +82,7 @@ func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) { db, 32, 24, - &model.SessionIdentifier{ClientID: "client", DeviceID: "device"}, + &sessionmodel.SessionIdentifier{ClientID: "client", DeviceID: "device"}, account, zap.NewNop(), ) diff --git a/backend/edge/shared/auth/tokens/tokens.go b/backend/product/sessions/tokens/tokens.go similarity index 73% rename from backend/edge/shared/auth/tokens/tokens.go rename to backend/product/sessions/tokens/tokens.go index 9b7037c..fc682eb 100644 --- a/backend/edge/shared/auth/tokens/tokens.go +++ b/backend/product/sessions/tokens/tokens.go @@ -4,16 +4,17 @@ import ( "context" "crypto/rand" "encoding/base64" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" + sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "io" "net/http" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/product/auth/ipguard" + "git.sendico.io/tech/sxchnge/product/sessions/store" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.uber.org/zap" ) @@ -32,10 +33,10 @@ func PrepareRefreshToken( rtdb refreshtokens.DB, length int, refreshExpiration int, - session *model.SessionIdentifier, - account *model.Account, + session *sessionmodel.SessionIdentifier, + account *customermodel.Account, logger mlogger.Logger, -) (*model.RefreshToken, error) { +) (*sessionmodel.RefreshToken, error) { refreshToken, err := generateRefreshTokenData(length) if err != nil { logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account)) @@ -55,9 +56,9 @@ func PrepareRefreshToken( ipAddress = clientIP.String() } - token := &model.RefreshToken{ + token := &sessionmodel.RefreshToken{ AccountRef: account.GetID(), - ClientRefreshToken: model.ClientRefreshToken{ + ClientRefreshToken: sessionmodel.ClientRefreshToken{ SessionIdentifier: versionedSession, RefreshToken: refreshToken, }, diff --git a/backend/product/verification/config/config.go b/backend/product/verification/config/config.go new file mode 100644 index 0000000..043f635 --- /dev/null +++ b/backend/product/verification/config/config.go @@ -0,0 +1,6 @@ +package config + +type Config struct { + AllowedPurposes []string `yaml:"allowed_purposes"` + RateLimits *VerificationRateLimitsConfig `yaml:"rate_limits"` +} diff --git a/backend/edge/shared/interface/api/verification_rate_limit.go b/backend/product/verification/config/verification_rate_limit.go similarity index 85% rename from backend/edge/shared/interface/api/verification_rate_limit.go rename to backend/product/verification/config/verification_rate_limit.go index f19bf11..7ca7474 100644 --- a/backend/edge/shared/interface/api/verification_rate_limit.go +++ b/backend/product/verification/config/verification_rate_limit.go @@ -1,9 +1,9 @@ -package api +package config import ( "time" - sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" + sharedlimit "git.sendico.io/tech/sxchnge/shared/ratelimit" ) type VerificationRateLimitsConfig struct { @@ -23,7 +23,7 @@ func DefaultVerificationRateLimits() VerificationRateLimits { } } -func (c *VerificationConfig) ResolveRateLimits() (VerificationRateLimits, error) { +func (c *Config) ResolveRateLimits() (VerificationRateLimits, error) { if c == nil || c.RateLimits == nil { return DefaultVerificationRateLimits(), nil } diff --git a/backend/edge/shared/interface/api/verification_rate_limit_test.go b/backend/product/verification/config/verification_rate_limit_test.go similarity index 72% rename from backend/edge/shared/interface/api/verification_rate_limit_test.go rename to backend/product/verification/config/verification_rate_limit_test.go index 1b7180b..4278fc8 100644 --- a/backend/edge/shared/interface/api/verification_rate_limit_test.go +++ b/backend/product/verification/config/verification_rate_limit_test.go @@ -1,16 +1,16 @@ -package api +package config import ( "errors" "testing" "time" - sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" + sharedlimit "git.sendico.io/tech/sxchnge/shared/ratelimit" ) func TestVerificationRateLimitsDefaults(t *testing.T) { - got, err := (*VerificationConfig)(nil).ResolveRateLimits() + got, err := (*Config)(nil).ResolveRateLimits() if err != nil { t.Fatalf("resolve defaults: %v", err) } @@ -21,7 +21,7 @@ func TestVerificationRateLimitsDefaults(t *testing.T) { } func TestVerificationRateLimitsRejectInvalidPolicy(t *testing.T) { - cfg := &VerificationConfig{RateLimits: &VerificationRateLimitsConfig{ + cfg := &Config{RateLimits: &VerificationRateLimitsConfig{ PerDestination: sharedlimit.WindowConfig{Limit: 5, Window: "1h"}, PerIP: sharedlimit.WindowConfig{Limit: 0, Window: "1h"}, }} diff --git a/backend/pkg/model/verificaton.go b/backend/product/verification/model/verificaton.go similarity index 95% rename from backend/pkg/model/verificaton.go rename to backend/product/verification/model/verificaton.go index 698e977..388691f 100644 --- a/backend/pkg/model/verificaton.go +++ b/backend/product/verification/model/verificaton.go @@ -4,8 +4,8 @@ import ( "fmt" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/edge/shared/storage/verification/consume.go b/backend/product/verification/store/consume.go similarity index 83% rename from backend/edge/shared/storage/verification/consume.go rename to backend/product/verification/store/consume.go index a87bdcc..e9bf840 100644 --- a/backend/edge/shared/storage/verification/consume.go +++ b/backend/product/verification/store/consume.go @@ -3,9 +3,9 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -13,10 +13,10 @@ import ( func (db *verificationDB) Consume( ct context.Context, accountRef bson.ObjectID, - purpose model.VerificationPurpose, + purpose verificationmodel.VerificationPurpose, contextRef string, rawToken string, -) (*model.VerificationToken, error) { +) (*verificationmodel.VerificationToken, error) { scope, err := newConsumeScope(accountRef, purpose, contextRef, rawToken) if err != nil { return nil, err @@ -57,7 +57,7 @@ func (db *verificationDB) finishConsume( scope consumeScope, result any, err error, -) (*model.VerificationToken, error) { +) (*verificationmodel.VerificationToken, error) { if err != nil { db.Logger.Debug("Verification consume finished with error", scope.logFields(zap.Error(err))..., @@ -65,7 +65,7 @@ func (db *verificationDB) finishConsume( return nil, err } - token, ok := result.(*model.VerificationToken) + token, ok := result.(*verificationmodel.VerificationToken) if !ok { db.Logger.Warn("Verification consume returned unexpected result type", scope.logFields(zap.String("result_type", ""))..., diff --git a/backend/edge/shared/storage/verification/consume_logging.go b/backend/product/verification/store/consume_logging.go similarity index 88% rename from backend/edge/shared/storage/verification/consume_logging.go rename to backend/product/verification/store/consume_logging.go index 30c5c96..9acddbc 100644 --- a/backend/edge/shared/storage/verification/consume_logging.go +++ b/backend/product/verification/store/consume_logging.go @@ -1,11 +1,10 @@ package verification import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.uber.org/zap" + "strings" ) const hashPreviewChars = 12 @@ -32,7 +31,7 @@ func tokenIsDigitsOnly(value string) bool { return true } -func tokenStateFields(token *model.VerificationToken) []zap.Field { +func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field { fields := []zap.Field{ mzap.StorableRef(token), mzap.ObjRef("token_account_ref", token.AccountRef), diff --git a/backend/edge/shared/storage/verification/consume_lookup.go b/backend/product/verification/store/consume_lookup.go similarity index 87% rename from backend/edge/shared/storage/verification/consume_lookup.go rename to backend/product/verification/store/consume_lookup.go index a6c10c2..0bfa186 100644 --- a/backend/edge/shared/storage/verification/consume_lookup.go +++ b/backend/product/verification/store/consume_lookup.go @@ -3,13 +3,13 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + mutil "git.sendico.io/tech/sxchnge/shared/database/query" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -19,7 +19,7 @@ type consumeMissError struct { tokenIDs []bson.ObjectID } -func newConsumeMissError(tokens []model.VerificationToken) consumeMissError { +func newConsumeMissError(tokens []verificationmodel.VerificationToken) consumeMissError { tokenIDs := make([]bson.ObjectID, 0, len(tokens)) for i := range tokens { if tokens[i].ID != bson.NilObjectID { @@ -43,7 +43,7 @@ func (e consumeMissError) Unwrap() error { func (db *verificationDB) lookupConsumeToken( ctx context.Context, scope consumeScope, -) (*model.VerificationToken, error) { +) (*verificationmodel.VerificationToken, error) { scopeFilter := scope.scopeFilter() db.Logger.Debug("Verification consume scope filter prepared", scope.logFields()...) @@ -66,7 +66,7 @@ func (db *verificationDB) lookupConsumeToken( func (db *verificationDB) lookupConsumeMagicToken( ctx context.Context, scope consumeScope, -) (*model.VerificationToken, error) { +) (*verificationmodel.VerificationToken, error) { magicFilter := repository.Query().And( append( scope.scopeFilters(), @@ -75,7 +75,7 @@ func (db *verificationDB) lookupConsumeMagicToken( ) db.Logger.Debug("Verification consume magic path lookup", scope.logFields()...) - var direct model.VerificationToken + var direct verificationmodel.VerificationToken err := db.FindOne(ctx, magicFilter, &direct) switch { case err == nil: @@ -98,11 +98,11 @@ func (db *verificationDB) lookupConsumeOTPToken( ctx context.Context, scope consumeScope, scopeFilter builder.Query, -) (*model.VerificationToken, error) { +) (*verificationmodel.VerificationToken, error) { db.Logger.Debug("Verification consume OTP fallback lookup started", scope.logFields(zap.Any("scope_filter", scopeFilter.BuildQuery()))..., ) - tokens, err := mutil.GetObjects[model.VerificationToken]( + tokens, err := mutil.GetObjects[verificationmodel.VerificationToken]( ctx, db.Logger, scopeFilter, nil, db.Repository, ) if err != nil { diff --git a/backend/edge/shared/storage/verification/consume_scope.go b/backend/product/verification/store/consume_scope.go similarity index 84% rename from backend/edge/shared/storage/verification/consume_scope.go rename to backend/product/verification/store/consume_scope.go index 7481c34..ea4a1f7 100644 --- a/backend/edge/shared/storage/verification/consume_scope.go +++ b/backend/product/verification/store/consume_scope.go @@ -1,12 +1,12 @@ package verification import ( + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "strings" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -14,7 +14,7 @@ import ( type consumeScope struct { now time.Time accountRef bson.ObjectID - purpose model.VerificationPurpose + purpose verificationmodel.VerificationPurpose contextRef string rawToken string trimmedRawToken string @@ -24,7 +24,7 @@ type consumeScope struct { func newConsumeScope( accountRef bson.ObjectID, - purpose model.VerificationPurpose, + purpose verificationmodel.VerificationPurpose, contextRef string, rawToken string, ) (consumeScope, error) { @@ -60,7 +60,7 @@ func (scope consumeScope) logFields(extra ...zap.Field) []zap.Field { return append(fields, extra...) } -func (scope consumeScope) tokenLogFields(token *model.VerificationToken, extra ...zap.Field) []zap.Field { +func (scope consumeScope) tokenLogFields(token *verificationmodel.VerificationToken, extra ...zap.Field) []zap.Field { fields := scope.logFields(extra...) return append(fields, tokenStateFields(token)...) } @@ -91,7 +91,7 @@ func (scope consumeScope) scopeFilter() builder.Query { return repository.Query().And(scope.scopeFilters()...) } -func (scope consumeScope) consumeFilter(token *model.VerificationToken) builder.Query { +func (scope consumeScope) consumeFilter(token *verificationmodel.VerificationToken) builder.Query { filters := []builder.Query{ repository.IDFilter(token.ID), repository.Filter("purpose", scope.purpose), diff --git a/backend/edge/shared/storage/verification/consume_update.go b/backend/product/verification/store/consume_update.go similarity index 89% rename from backend/edge/shared/storage/verification/consume_update.go rename to backend/product/verification/store/consume_update.go index 488c42b..1f6f2a9 100644 --- a/backend/edge/shared/storage/verification/consume_update.go +++ b/backend/product/verification/store/consume_update.go @@ -2,17 +2,17 @@ package verification import ( "context" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.uber.org/zap" ) func (db *verificationDB) validateConsumeToken( scope consumeScope, - token *model.VerificationToken, + token *verificationmodel.VerificationToken, ) error { if token.UsedAt != nil { db.Logger.Debug("Verification consume static check failed: token already used", @@ -42,8 +42,8 @@ func (db *verificationDB) validateConsumeToken( func (db *verificationDB) consumeMatchedToken( ctx context.Context, scope consumeScope, - token *model.VerificationToken, -) (*model.VerificationToken, error) { + token *verificationmodel.VerificationToken, +) (*verificationmodel.VerificationToken, error) { db.Logger.Debug("Verification consume atomic update attempt", scope.logFields(mzap.StorableRef(token))..., ) @@ -76,7 +76,7 @@ func (db *verificationDB) consumeMatchedToken( func (db *verificationDB) incrementConsumeAttemptsAfterAtomicMiss( ctx context.Context, scope consumeScope, - token *model.VerificationToken, + token *verificationmodel.VerificationToken, ) { incremented, err := db.PatchMany( ctx, @@ -102,9 +102,9 @@ func (db *verificationDB) incrementConsumeAttemptsAfterAtomicMiss( func (db *verificationDB) consumeMissError( ctx context.Context, scope consumeScope, - token *model.VerificationToken, + token *verificationmodel.VerificationToken, ) error { - var fresh model.VerificationToken + var fresh verificationmodel.VerificationToken if err := db.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil { db.Logger.Warn("Verification consume failed to re-check token state", scope.logFields(mzap.StorableRef(token), zap.Error(err))..., diff --git a/backend/product/verification/store/context_ref.go b/backend/product/verification/store/context_ref.go new file mode 100644 index 0000000..088ba48 --- /dev/null +++ b/backend/product/verification/store/context_ref.go @@ -0,0 +1,22 @@ +package verification + +import ( + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" + "strings" + + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" +) + +func normalizeContextRef(purpose verificationmodel.VerificationPurpose, contextRef string) (string, error) { + _ = purpose + return strings.TrimSpace(contextRef), nil +} + +func appendContextRefFilter(filters []builder.Query, purpose verificationmodel.VerificationPurpose, contextRef string) []builder.Query { + _ = purpose + if strings.TrimSpace(contextRef) != "" { + filters = append(filters, repository.Filter("contextRef", strings.TrimSpace(contextRef))) + } + return filters +} diff --git a/backend/edge/shared/storage/verification/context_ref_test.go b/backend/product/verification/store/context_ref_test.go similarity index 78% rename from backend/edge/shared/storage/verification/context_ref_test.go rename to backend/product/verification/store/context_ref_test.go index 25ff1c2..990d2c8 100644 --- a/backend/edge/shared/storage/verification/context_ref_test.go +++ b/backend/product/verification/store/context_ref_test.go @@ -3,13 +3,12 @@ package verification import ( "context" "errors" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" + "testing" + "time" ) func TestCreate_BlankContextRefAllowed(t *testing.T) { @@ -33,11 +32,11 @@ func TestCreate_OldContextDoesNotBlockNewContext(t *testing.T) { require.NoError(t, err) assert.NotEqual(t, oldRaw, newRaw) - oldTok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-old", oldRaw) + oldTok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-old", oldRaw) require.NoError(t, err) assert.Equal(t, "ctx-old", oldTok.ContextRef) - newTok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-new", newRaw) + newTok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-new", newRaw) require.NoError(t, err) assert.Equal(t, "ctx-new", newTok.ContextRef) } @@ -50,11 +49,11 @@ func TestConsume_OTPForOneContextCannotConfirmAnotherContext(t *testing.T) { raw, err := db.Create(ctx, contextReq(accountRef, "ctx-a")) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-b", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-b", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenNotFound)) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx-a", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-a", raw) require.NoError(t, err) assert.Equal(t, "ctx-a", tok.ContextRef) } @@ -90,10 +89,10 @@ func TestCreate_ContextRefStoredForAnyPurpose(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour).WithContextRef("ctx")) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithContextRef("ctx")) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "ctx", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx", raw) require.NoError(t, err) assert.Equal(t, "ctx", tok.ContextRef) } diff --git a/backend/edge/shared/storage/verification/create.go b/backend/product/verification/store/create.go similarity index 92% rename from backend/edge/shared/storage/verification/create.go rename to backend/product/verification/store/create.go index 8a71bc6..61f76ef 100644 --- a/backend/edge/shared/storage/verification/create.go +++ b/backend/product/verification/store/create.go @@ -3,12 +3,12 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" ) type CreateResult struct { @@ -54,7 +54,7 @@ func newVerificationToken( request *Request, idempotencyKey string, hasIdempotency bool, -) (*model.VerificationToken, string, error) { +) (*verificationmodel.VerificationToken, string, error) { now := storedVerificationInstant(time.Now()) @@ -91,7 +91,7 @@ func newVerificationToken( } } - token := &model.VerificationToken{ + token := &verificationmodel.VerificationToken{ AccountRef: request.AccountRef, Purpose: request.Purpose, Target: request.Target, @@ -164,7 +164,7 @@ func (db *verificationDB) CreateWithResult( return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil } - var sameToken model.VerificationToken + var sameToken verificationmodel.VerificationToken err = db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken) switch { case err == nil: @@ -180,7 +180,7 @@ func (db *verificationDB) CreateWithResult( if request.Cooldown != nil { cutoff := now.Add(-*request.Cooldown) - var recent model.VerificationToken + var recent verificationmodel.VerificationToken err := db.FindOne(tx, cooldownActiveContextFilter(request, now, cutoff), &recent) switch { case err == nil: @@ -219,7 +219,7 @@ func (db *verificationDB) CreateWithResult( return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil } - var sameToken model.VerificationToken + var sameToken verificationmodel.VerificationToken findErr := db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken) switch { case findErr == nil: diff --git a/backend/edge/shared/storage/verification/db.go b/backend/product/verification/store/db.go similarity index 77% rename from backend/edge/shared/storage/verification/db.go rename to backend/product/verification/store/db.go index dd9df96..e495739 100644 --- a/backend/edge/shared/storage/verification/db.go +++ b/backend/product/verification/store/db.go @@ -1,21 +1,21 @@ package verification import ( + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "strings" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/template" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" ) type verificationDB struct { - template.DBImp[*model.VerificationToken] + template.DBImp[*verificationmodel.VerificationToken] tf transaction.Factory limits ratelimits.Store } @@ -45,7 +45,7 @@ func CreateWithCollection( collection = mservice.VerificationTokens } p := &verificationDB{ - DBImp: *template.Create[*model.VerificationToken](logger, mservice.Type(collection), db), + DBImp: *template.Create[*verificationmodel.VerificationToken](logger, mservice.Type(collection), db), tf: tf, } diff --git a/backend/edge/shared/storage/verification/errors.go b/backend/product/verification/store/errors.go similarity index 97% rename from backend/edge/shared/storage/verification/errors.go rename to backend/product/verification/store/errors.go index f3b7350..b07f8c2 100644 --- a/backend/edge/shared/storage/verification/errors.go +++ b/backend/product/verification/store/errors.go @@ -3,7 +3,7 @@ package verification import ( "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) var ( diff --git a/backend/edge/shared/storage/verification/hash.go b/backend/product/verification/store/hash.go similarity index 84% rename from backend/edge/shared/storage/verification/hash.go rename to backend/product/verification/store/hash.go index 1ec163d..2c91556 100644 --- a/backend/edge/shared/storage/verification/hash.go +++ b/backend/product/verification/store/hash.go @@ -6,27 +6,26 @@ import ( "encoding/base64" "encoding/hex" "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" ) type TokenHasher interface { - Hash(raw string, token *model.VerificationToken) string + Hash(raw string, token *verificationmodel.VerificationToken) string } type magicHasher struct{} -func (magicHasher) Hash(raw string, _ *model.VerificationToken) string { +func (magicHasher) Hash(raw string, _ *verificationmodel.VerificationToken) string { return tokenHash(raw) } type otpHasher struct{} -func (otpHasher) Hash(raw string, t *model.VerificationToken) string { +func (otpHasher) Hash(raw string, t *verificationmodel.VerificationToken) string { return otpHash(raw, *t.Salt) } -func hasherFor(t *model.VerificationToken) TokenHasher { +func hasherFor(t *verificationmodel.VerificationToken) TokenHasher { if t.Salt != nil { return otpHasher{} } diff --git a/backend/edge/shared/storage/verification/hash_test.go b/backend/product/verification/store/hash_test.go similarity index 100% rename from backend/edge/shared/storage/verification/hash_test.go rename to backend/product/verification/store/hash_test.go diff --git a/backend/edge/shared/storage/verification/idempotency.go b/backend/product/verification/store/idempotency.go similarity index 76% rename from backend/edge/shared/storage/verification/idempotency.go rename to backend/product/verification/store/idempotency.go index 82fcaee..22c3852 100644 --- a/backend/edge/shared/storage/verification/idempotency.go +++ b/backend/product/verification/store/idempotency.go @@ -3,13 +3,13 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" + mutil "git.sendico.io/tech/sxchnge/shared/database/query" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -51,8 +51,8 @@ func idempotencySeed(request *Request, idempotencyKey string) string { }, "|") } -func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *Request, idempotencyKey string) (*model.VerificationToken, bool, error) { - tokens, err := mutil.GetObjects[model.VerificationToken]( +func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *Request, idempotencyKey string) (*verificationmodel.VerificationToken, bool, error) { + tokens, err := mutil.GetObjects[verificationmodel.VerificationToken]( ctx, db.Logger, idempotencyContextFilter(request, idempotencyKey), @@ -67,7 +67,7 @@ func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request * return nil, false, err } - var replayed *model.VerificationToken + var replayed *verificationmodel.VerificationToken for i := range tokens { existingContextRef := strings.TrimSpace(tokens[i].ContextRef) // The lookup intentionally ignores contextRef. A reused idempotency key diff --git a/backend/edge/shared/storage/verification/issuance_limit.go b/backend/product/verification/store/issuance_limit.go similarity index 96% rename from backend/edge/shared/storage/verification/issuance_limit.go rename to backend/product/verification/store/issuance_limit.go index 1fa952b..af2367b 100644 --- a/backend/edge/shared/storage/verification/issuance_limit.go +++ b/backend/product/verification/store/issuance_limit.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) func (db *verificationDB) reserveIssuance(ctx context.Context, request *Request, now time.Time) error { diff --git a/backend/edge/shared/storage/verification/issuance_limit_test.go b/backend/product/verification/store/issuance_limit_test.go similarity index 96% rename from backend/edge/shared/storage/verification/issuance_limit_test.go rename to backend/product/verification/store/issuance_limit_test.go index a869bac..2123323 100644 --- a/backend/edge/shared/storage/verification/issuance_limit_test.go +++ b/backend/product/verification/store/issuance_limit_test.go @@ -3,13 +3,13 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "sync" "sync/atomic" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -52,7 +52,7 @@ func TestCreateWithResultDoesNotMutateTokensWhenIssuanceDenied(t *testing.T) { first, err := db.CreateWithResult(context.Background(), NewOTPRequest( accountRef, - model.PurposeLogin, + verificationmodel.PurposeLogin, "user@example.com", ).WithIdempotencyKey("issuance-first").WithIssuanceLimits(limit)) if err != nil || !first.Created { @@ -61,7 +61,7 @@ func TestCreateWithResultDoesNotMutateTokensWhenIssuanceDenied(t *testing.T) { _, err = db.CreateWithResult(context.Background(), NewOTPRequest( accountRef, - model.PurposeLogin, + verificationmodel.PurposeLogin, "user@example.com", ).WithIdempotencyKey("issuance-second").WithIssuanceLimits(limit)) if !errors.Is(err, ErrIssuanceRateLimited) { diff --git a/backend/edge/shared/storage/verification/memory_token_repository_test.go b/backend/product/verification/store/memory_token_repository_test.go similarity index 86% rename from backend/edge/shared/storage/verification/memory_token_repository_test.go rename to backend/product/verification/store/memory_token_repository_test.go index a5afafe..16f41a6 100644 --- a/backend/edge/shared/storage/verification/memory_token_repository_test.go +++ b/backend/product/verification/store/memory_token_repository_test.go @@ -3,42 +3,42 @@ package verification import ( "context" "errors" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "sync" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) type memoryTokenRepository struct { mu sync.Mutex - data map[bson.ObjectID]*model.VerificationToken + data map[bson.ObjectID]*verificationmodel.VerificationToken order []bson.ObjectID seq int } type memoryTokenRepositorySnapshot struct { - data map[bson.ObjectID]*model.VerificationToken + data map[bson.ObjectID]*verificationmodel.VerificationToken order []bson.ObjectID seq int } func newMemoryTokenRepository() *memoryTokenRepository { - return &memoryTokenRepository{data: make(map[bson.ObjectID]*model.VerificationToken)} + return &memoryTokenRepository{data: make(map[bson.ObjectID]*verificationmodel.VerificationToken)} } func (m *memoryTokenRepository) snapshot() memoryTokenRepositorySnapshot { m.mu.Lock() defer m.mu.Unlock() - data := make(map[bson.ObjectID]*model.VerificationToken, len(m.data)) + data := make(map[bson.ObjectID]*verificationmodel.VerificationToken, len(m.data)) for id, tok := range m.data { data[id] = cloneToken(tok) } @@ -53,7 +53,7 @@ func (m *memoryTokenRepository) restore(snapshot memoryTokenRepositorySnapshot) m.mu.Lock() defer m.mu.Unlock() - m.data = make(map[bson.ObjectID]*model.VerificationToken, len(snapshot.data)) + m.data = make(map[bson.ObjectID]*verificationmodel.VerificationToken, len(snapshot.data)) for id, tok := range snapshot.data { m.data[id] = cloneToken(tok) } @@ -64,7 +64,7 @@ func (m *memoryTokenRepository) restore(snapshot memoryTokenRepositorySnapshot) func (m *memoryTokenRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { m.mu.Lock() defer m.mu.Unlock() - tok, ok := obj.(*model.VerificationToken) + tok, ok := obj.(*verificationmodel.VerificationToken) if !ok { return merrors.InvalidDataType("expected VerificationToken") } @@ -113,7 +113,7 @@ func (m *memoryTokenRepository) Get(_ context.Context, id bson.ObjectID, result if !ok { return merrors.ErrNoData } - dst := result.(*model.VerificationToken) + dst := result.(*verificationmodel.VerificationToken) *dst = *cloneToken(tok) return nil } @@ -124,7 +124,7 @@ func (m *memoryTokenRepository) FindOneByFilter(_ context.Context, query builder for _, id := range m.order { tok := m.data[id] if tok != nil && matchToken(query, tok) { - dst := result.(*model.VerificationToken) + dst := result.(*verificationmodel.VerificationToken) *dst = *cloneToken(tok) return nil } @@ -139,7 +139,7 @@ func (m *memoryTokenRepository) FindFirstByFilter(ctx context.Context, query bui func (m *memoryTokenRepository) Update(_ context.Context, obj storable.Storable) error { m.mu.Lock() defer m.mu.Unlock() - tok := obj.(*model.VerificationToken) + tok := obj.(*verificationmodel.VerificationToken) id := tok.GetID() if id == nil { return merrors.InvalidArgument("id required") @@ -154,7 +154,7 @@ func (m *memoryTokenRepository) Update(_ context.Context, obj storable.Storable) func (m *memoryTokenRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) { m.mu.Lock() defer m.mu.Unlock() - tok, ok := obj.(*model.VerificationToken) + tok, ok := obj.(*verificationmodel.VerificationToken) if !ok { return false, merrors.InvalidDataType("expected VerificationToken") } @@ -212,7 +212,7 @@ func (m *memoryTokenRepository) PatchOneByFilter(_ context.Context, filter build } applyPatch(tok, patchDoc) if result != nil { - dst := result.(*model.VerificationToken) + dst := result.(*verificationmodel.VerificationToken) *dst = *cloneToken(tok) } return true, nil diff --git a/backend/edge/shared/storage/verification/request.go b/backend/product/verification/store/request.go similarity index 79% rename from backend/edge/shared/storage/verification/request.go rename to backend/product/verification/store/request.go index 72fab88..636db6e 100644 --- a/backend/edge/shared/storage/verification/request.go +++ b/backend/product/verification/store/request.go @@ -1,11 +1,11 @@ package verification import ( + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "strings" "time" - "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/ratelimit/store" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -37,7 +37,7 @@ const ( type Request struct { AccountRef bson.ObjectID - Purpose model.VerificationPurpose + Purpose verificationmodel.VerificationPurpose Target string ContextRef string Ttl time.Duration @@ -53,7 +53,7 @@ func (r *Request) WithIssuanceLimits(limits ...IssuanceLimit) *Request { return r } -func newRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string, kind TokenKind) *Request { +func newRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string, kind TokenKind) *Request { return &Request{ AccountRef: accountRef, Purpose: purpose, @@ -63,11 +63,11 @@ func newRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, tar } } -func NewLinkRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string) *Request { +func NewLinkRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string) *Request { return newRequest(accountRef, purpose, target, TokenKindLink) } -func NewOTPRequest(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string) *Request { +func NewOTPRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string) *Request { return newRequest(accountRef, purpose, target, TokenKindOTP) } diff --git a/backend/edge/shared/storage/verification/token_query_matcher_test.go b/backend/product/verification/store/token_query_matcher_test.go similarity index 84% rename from backend/edge/shared/storage/verification/token_query_matcher_test.go rename to backend/product/verification/store/token_query_matcher_test.go index 5cc511f..e3a8cd2 100644 --- a/backend/edge/shared/storage/verification/token_query_matcher_test.go +++ b/backend/product/verification/store/token_query_matcher_test.go @@ -1,14 +1,14 @@ package verification import ( + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) -func tokenFieldValue(tok *model.VerificationToken, field string) any { +func tokenFieldValue(tok *verificationmodel.VerificationToken, field string) any { switch field { case "_id": return tok.ID @@ -44,14 +44,14 @@ func tokenFieldValue(tok *model.VerificationToken, field string) any { } } -func matchToken(query builder.Query, tok *model.VerificationToken) bool { +func matchToken(query builder.Query, tok *verificationmodel.VerificationToken) bool { if query == nil { return true } return matchBsonD(query.BuildQuery(), tok) } -func matchBsonD(filter bson.D, tok *model.VerificationToken) bool { +func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool { for _, elem := range filter { if !matchElem(elem, tok) { return false @@ -60,7 +60,7 @@ func matchBsonD(filter bson.D, tok *model.VerificationToken) bool { return true } -func matchElem(elem bson.E, tok *model.VerificationToken) bool { +func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool { switch elem.Key { case "$and": arr, ok := elem.Value.(bson.A) @@ -151,8 +151,8 @@ func valuesEqual(a, b any) bool { return av == bv } return false - case model.VerificationPurpose: - if bv, ok := b.(model.VerificationPurpose); ok { + case verificationmodel.VerificationPurpose: + if bv, ok := b.(verificationmodel.VerificationPurpose); ok { return av == bv } return false @@ -202,7 +202,7 @@ func toTime(v any) (time.Time, bool) { return time.Time{}, false } -func applyPatch(tok *model.VerificationToken, patchDoc bson.D) { +func applyPatch(tok *verificationmodel.VerificationToken, patchDoc bson.D) { for _, op := range patchDoc { switch op.Key { case "$set": @@ -235,7 +235,7 @@ func applyPatch(tok *model.VerificationToken, patchDoc bson.D) { } } -func cloneToken(src *model.VerificationToken) *model.VerificationToken { +func cloneToken(src *verificationmodel.VerificationToken) *verificationmodel.VerificationToken { dst := *src if src.UsedAt != nil { t := *src.UsedAt diff --git a/backend/edge/shared/storage/verification/verification.go b/backend/product/verification/store/verification.go similarity index 56% rename from backend/edge/shared/storage/verification/verification.go rename to backend/product/verification/store/verification.go index b893a2c..491ada4 100644 --- a/backend/edge/shared/storage/verification/verification.go +++ b/backend/product/verification/store/verification.go @@ -2,14 +2,13 @@ package verification import ( "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "go.mongodb.org/mongo-driver/v2/bson" ) type DB interface { - // template.DB[*model.VerificationToken] + // template.DB[*verificationmodel.VerificationToken] Create(ctx context.Context, request *Request) (verificationToken string, err error) CreateWithResult(ctx context.Context, request *Request) (CreateResult, error) - Consume(ctx context.Context, accountRef bson.ObjectID, purpose model.VerificationPurpose, contextRef, verificationToken string) (*model.VerificationToken, error) + Consume(ctx context.Context, accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, contextRef, verificationToken string) (*verificationmodel.VerificationToken, error) } diff --git a/backend/edge/shared/storage/verification/verification_attempts_test.go b/backend/product/verification/store/verification_attempts_test.go similarity index 69% rename from backend/edge/shared/storage/verification/verification_attempts_test.go rename to backend/product/verification/store/verification_attempts_test.go index 1148a54..c97420e 100644 --- a/backend/edge/shared/storage/verification/verification_attempts_test.go +++ b/backend/product/verification/store/verification_attempts_test.go @@ -3,13 +3,12 @@ package verification import ( "context" "errors" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" + "testing" + "time" ) func TestConsume_MaxRetriesExceeded(t *testing.T) { @@ -17,7 +16,7 @@ func TestConsume_MaxRetriesExceeded(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - r := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithMaxRetries(2) + r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithMaxRetries(2) raw, err := db.Create(ctx, r) require.NoError(t, err) @@ -28,7 +27,7 @@ func TestConsume_MaxRetriesExceeded(t *testing.T) { } repo.mu.Unlock() - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded)) } @@ -38,7 +37,7 @@ func TestConsume_UnderMaxRetriesSucceeds(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - r := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithMaxRetries(3) + r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithMaxRetries(3) raw, err := db.Create(ctx, r) require.NoError(t, err) @@ -49,7 +48,7 @@ func TestConsume_UnderMaxRetriesSucceeds(t *testing.T) { } repo.mu.Unlock() - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -59,7 +58,7 @@ func TestConsume_NoMaxRetriesIgnoresAttempts(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) repo := db.Repository.(*memoryTokenRepository) @@ -69,7 +68,7 @@ func TestConsume_NoMaxRetriesIgnoresAttempts(t *testing.T) { } repo.mu.Unlock() - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -79,10 +78,10 @@ func TestConsume_WrongHashReturnsNotFound(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + _, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", "wrong-code") + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", "wrong-code") require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenNotFound)) } @@ -94,7 +93,7 @@ func TestConsume_WrongOTPAttemptsPersistAfterTransactionRollback(t *testing.T) { raw, err := db.Create( ctx, - NewOTPRequest(accountRef, model.PurposeEmailChange, "new@example.com"). + NewOTPRequest(accountRef, verificationmodel.PurposeEmailChange, "new@example.com"). WithTTL(time.Hour). WithMaxRetries(2), ) @@ -102,13 +101,13 @@ func TestConsume_WrongOTPAttemptsPersistAfterTransactionRollback(t *testing.T) { wrongCode := differentOTP(raw) for attempt := 1; attempt <= 2; attempt++ { - _, err = db.Consume(ctx, accountRef, model.PurposeEmailChange, "", wrongCode) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", wrongCode) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenNotFound)) assert.Equal(t, attempt, storedTokenAttempts(t, db)) } - _, err = db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded)) } @@ -119,10 +118,10 @@ func TestConsume_ContextMismatchReturnsNotFound(t *testing.T) { accountRef := bson.NewObjectID() otherAccount := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, otherAccount, model.PurposePasswordReset, "", raw) + _, err = db.Consume(ctx, otherAccount, verificationmodel.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenNotFound)) } diff --git a/backend/edge/shared/storage/verification/verification_idempotency_test.go b/backend/product/verification/store/verification_idempotency_test.go similarity index 66% rename from backend/edge/shared/storage/verification/verification_idempotency_test.go rename to backend/product/verification/store/verification_idempotency_test.go index 5ee9fab..d270a55 100644 --- a/backend/edge/shared/storage/verification/verification_idempotency_test.go +++ b/backend/product/verification/store/verification_idempotency_test.go @@ -3,13 +3,12 @@ package verification import ( "context" "errors" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" + "testing" + "time" ) func TestCreate_CooldownBlocksCreation(t *testing.T) { @@ -17,10 +16,10 @@ func TestCreate_CooldownBlocksCreation(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + _, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Minute) + r2 := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithCooldown(time.Minute) _, err = db.Create(ctx, r2) require.Error(t, err) assert.True(t, errors.Is(err, ErrCooldownActive)) @@ -31,21 +30,21 @@ func TestCreate_CooldownExpiresAllowsCreation(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - firstRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) time.Sleep(2 * time.Millisecond) - r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Millisecond) + r2 := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithCooldown(time.Millisecond) secondRaw, err := db.Create(ctx, r2) require.NoError(t, err) assert.NotEqual(t, firstRaw, secondRaw) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", firstRaw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", firstRaw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed)) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", secondRaw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", secondRaw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -55,10 +54,10 @@ func TestCreate_CooldownNilIgnored(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + _, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + _, err = db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) } @@ -67,12 +66,12 @@ func TestCreate_IdempotencyKeyReplayReturnsSameToken(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - firstReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") + firstReq := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") firstRaw, err := db.Create(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, firstRaw) - secondReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") + secondReq := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") secondRaw, err := db.Create(ctx, secondReq) require.NoError(t, err) assert.Equal(t, firstRaw, secondRaw) @@ -88,13 +87,13 @@ func TestCreateWithResult_IdempotencyReplayReportsNotCreated(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + firstReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") first, err := db.CreateWithResult(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, first.VerificationToken) assert.True(t, first.Created) - secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + secondReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") second, err := db.CreateWithResult(ctx, secondReq) require.NoError(t, err) assert.Equal(t, first.VerificationToken, second.VerificationToken) @@ -111,13 +110,13 @@ func TestCreateWithResult_IdempotencyReplayReusesExpiredStoredExpiry(t *testing. ctx := context.Background() accountRef := bson.NewObjectID() - firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", -time.Minute).WithIdempotencyKey("same-key") + firstReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", -time.Minute).WithIdempotencyKey("same-key") first, err := db.CreateWithResult(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, first.VerificationToken) require.True(t, first.ExpiresAt.Before(time.Now().UTC()), "test must create an already expired token") - secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") + secondReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") second, err := db.CreateWithResult(ctx, secondReq) require.NoError(t, err) assert.Equal(t, first.VerificationToken, second.VerificationToken) @@ -131,22 +130,22 @@ func TestCreate_IdempotencyScopeIncludesTarget(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - r1 := req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour).WithIdempotencyKey("same-key") + r1 := req(accountRef, verificationmodel.PurposeEmailChange, "a@example.com", time.Hour).WithIdempotencyKey("same-key") raw1, err := db.Create(ctx, r1) require.NoError(t, err) require.NotEmpty(t, raw1) - r2 := req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour).WithIdempotencyKey("same-key") + r2 := req(accountRef, verificationmodel.PurposeEmailChange, "b@example.com", time.Hour).WithIdempotencyKey("same-key") raw2, err := db.Create(ctx, r2) require.NoError(t, err) require.NotEmpty(t, raw2) assert.NotEqual(t, raw1, raw2) - t1, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw1) + t1, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw1) require.NoError(t, err) assert.Equal(t, "a@example.com", t1.Target) - t2, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw2) + t2, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw2) require.NoError(t, err) assert.Equal(t, "b@example.com", t2.Target) } @@ -156,7 +155,7 @@ func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - r := req(accountRef, model.PurposePasswordReset, "", time.Hour). + r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour). WithCooldown(time.Minute). WithIdempotencyKey("retry-safe") diff --git a/backend/edge/shared/storage/verification/verification_test.go b/backend/product/verification/store/verification_test.go similarity index 58% rename from backend/edge/shared/storage/verification/verification_test.go rename to backend/product/verification/store/verification_test.go index aed6ca7..9f994d1 100644 --- a/backend/edge/shared/storage/verification/verification_test.go +++ b/backend/product/verification/store/verification_test.go @@ -3,14 +3,13 @@ package verification import ( "context" "errors" - "strings" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" + "strings" + "testing" + "time" ) func TestCreate_ReturnsRawToken(t *testing.T) { @@ -18,7 +17,7 @@ func TestCreate_ReturnsRawToken(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) assert.NotEmpty(t, raw) } @@ -28,13 +27,13 @@ func TestCreate_TokenCanBeConsumed(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) - assert.Equal(t, model.PurposePasswordReset, tok.Purpose) + assert.Equal(t, verificationmodel.PurposePasswordReset, tok.Purpose) assert.NotNil(t, tok.UsedAt) } @@ -43,13 +42,13 @@ func TestConsume_ReturnsCorrectFields(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) - assert.Equal(t, model.PurposeEmailChange, tok.Purpose) + assert.Equal(t, verificationmodel.PurposeEmailChange, tok.Purpose) assert.Equal(t, "new@example.com", tok.Target) } @@ -58,13 +57,13 @@ func TestConsume_SecondConsumeFailsAlreadyUsed(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second consume should fail with already-used after usedAt is set") @@ -76,10 +75,10 @@ func TestConsume_ExpiredTokenFails(t *testing.T) { accountRef := bson.NewObjectID() // Create with a TTL that is already in the past. - raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", -time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenExpired), "expired token should return explicit expiry error") @@ -99,13 +98,13 @@ func TestConsume_AccountActivationWithoutAccountRef(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - raw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", raw) + tok, err := db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) - assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) + assert.Equal(t, verificationmodel.PurposeAccountActivation, tok.Purpose) } func TestCreate_InvalidatesPreviousToken(t *testing.T) { @@ -113,21 +112,21 @@ func TestCreate_InvalidatesPreviousToken(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - oldRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + oldRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - newRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + newRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) assert.NotEqual(t, oldRaw, newRaw, "new token should differ from old one") // Old token is no longer consumable — invalidated (usedAt set) by the second Create. - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", oldRaw) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", oldRaw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "old token should return already-used after invalidation") // New token works fine. - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", newRaw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", newRaw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -138,24 +137,24 @@ func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testi accountRef := bson.NewObjectID() // First issue for the same verification scope. - firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) // Second issue during resend should rotate token instead of failing with duplicate key. - secondRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + secondRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) assert.NotEqual(t, firstRaw, secondRaw) // Old token becomes unusable after reissue. - _, err = db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", firstRaw) + _, err = db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", firstRaw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed)) // New token is valid. - tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", secondRaw) + tok, err := db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", secondRaw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) - assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) + assert.Equal(t, verificationmodel.PurposeAccountActivation, tok.Purpose) // Non-idempotent requests should still persist unique internal keys, // preventing uniqueness collisions on (accountRef, purpose, target, idempotencyKey). @@ -165,7 +164,7 @@ func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testi keys := map[string]struct{}{} for _, stored := range repo.data { - if stored.AccountRef != accountRef || stored.Purpose != model.PurposeAccountActivation { + if stored.AccountRef != accountRef || stored.Purpose != verificationmodel.PurposeAccountActivation { continue } require.NotNil(t, stored.IdempotencyKey) @@ -180,19 +179,19 @@ func TestCreate_InvalidatesMultiplePreviousTokens(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - first, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + first, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - second, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + second, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - third, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + third, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", first) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", first) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "first should be invalidated/used") - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", second) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", second) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second should be invalidated/used") - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", third) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", third) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -202,16 +201,16 @@ func TestCreate_DifferentPurposeNotInvalidated(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - resetRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + resetRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) // Creating an activation token should NOT invalidate the password-reset token. - _, err = db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + _, err = db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", resetRaw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", resetRaw) require.NoError(t, err) - assert.Equal(t, model.PurposePasswordReset, tok.Purpose) + assert.Equal(t, verificationmodel.PurposePasswordReset, tok.Purpose) } func TestCreate_DifferentTargetNotInvalidated(t *testing.T) { @@ -219,14 +218,14 @@ func TestCreate_DifferentTargetNotInvalidated(t *testing.T) { ctx := context.Background() accountRef := bson.NewObjectID() - firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour)) + firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "a@example.com", time.Hour)) require.NoError(t, err) // Creating a token for a different target email should NOT invalidate the first. - _, err = db.Create(ctx, req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour)) + _, err = db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "b@example.com", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", firstRaw) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", firstRaw) require.NoError(t, err) assert.Equal(t, "a@example.com", tok.Target) } @@ -237,13 +236,13 @@ func TestCreate_DifferentAccountNotInvalidated(t *testing.T) { account1 := bson.NewObjectID() account2 := bson.NewObjectID() - raw1, err := db.Create(ctx, req(account1, model.PurposePasswordReset, "", time.Hour)) + raw1, err := db.Create(ctx, req(account1, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Create(ctx, req(account2, model.PurposePasswordReset, "", time.Hour)) + _, err = db.Create(ctx, req(account2, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, account1, model.PurposePasswordReset, "", raw1) + tok, err := db.Consume(ctx, account1, verificationmodel.PurposePasswordReset, "", raw1) require.NoError(t, err) assert.Equal(t, account1, tok.AccountRef) } @@ -254,18 +253,18 @@ func TestCreate_AlreadyUsedTokenNotInvalidatedAgain(t *testing.T) { accountRef := bson.NewObjectID() // Create and consume first token. - raw1, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw1, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw1) + _, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw1) require.NoError(t, err) // Create second — the already-consumed token should have usedAt set, // so the invalidation query (usedAt == nil) should skip it. // This tests that the PatchMany filter correctly excludes already-used tokens. - raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw2, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw2) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } @@ -276,14 +275,14 @@ func TestCreate_ExpiredTokenNotInvalidated(t *testing.T) { accountRef := bson.NewObjectID() // Create a token that is already expired. - _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) + _, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", -time.Hour)) require.NoError(t, err) // Create a fresh one — invalidation should skip the expired token (expiresAt > now filter). - raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) + raw2, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) - tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) + tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw2) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } diff --git a/backend/edge/shared/storage/verification/verification_test_helpers_test.go b/backend/product/verification/store/verification_test_helpers_test.go similarity index 79% rename from backend/edge/shared/storage/verification/verification_test_helpers_test.go rename to backend/product/verification/store/verification_test_helpers_test.go index 1229061..0a95274 100644 --- a/backend/edge/shared/storage/verification/verification_test_helpers_test.go +++ b/backend/product/verification/store/verification_test_helpers_test.go @@ -2,13 +2,13 @@ package verification import ( "context" + verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "sync" "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/template" + "git.sendico.io/tech/sxchnge/shared/database/transaction" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -22,7 +22,7 @@ func newRollbackTestVerificationDB(t *testing.T) *verificationDB { repo := newMemoryTokenRepository() logger := zap.NewNop() return &verificationDB{ - DBImp: template.DBImp[*model.VerificationToken]{ + DBImp: template.DBImp[*verificationmodel.VerificationToken]{ Logger: logger, Repository: repo, }, @@ -35,7 +35,7 @@ func newTestVerificationDBWithFactory(t *testing.T, tf transaction.Factory) *ver repo := newMemoryTokenRepository() logger := zap.NewNop() return &verificationDB{ - DBImp: template.DBImp[*model.VerificationToken]{ + DBImp: template.DBImp[*verificationmodel.VerificationToken]{ Logger: logger, Repository: repo, }, @@ -92,12 +92,12 @@ func (tx *rollbackTx) Execute(ctx context.Context, cb transaction.Callback) (any return result, nil } -func req(accountRef bson.ObjectID, purpose model.VerificationPurpose, target string, ttl time.Duration) *Request { +func req(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string, ttl time.Duration) *Request { return NewLinkRequest(accountRef, purpose, target).WithTTL(ttl) } func contextReq(accountRef bson.ObjectID, contextRef string) *Request { - return NewOTPRequest(accountRef, model.PurposePasswordReset, "user@example.com"). + return NewOTPRequest(accountRef, verificationmodel.PurposePasswordReset, "user@example.com"). WithTTL(time.Hour). WithContextRef(contextRef) } diff --git a/backend/shared/README.md b/backend/shared/README.md new file mode 100644 index 0000000..1b26124 --- /dev/null +++ b/backend/shared/README.md @@ -0,0 +1,12 @@ +# Shared backend library + +This is a Go library module, not an executable. It provides database connections, +transactions and repository primitives, errors, structured logging, generic HTTP +responses, configuration and rate-limit helpers. It contains no customer, session, +verification or Sendico domain models and imports no executable SXCHNGE module. + +`product` consumes it through a local module replacement. A future backoffice +executable can consume the same infrastructure independently. + +Run `go test -race ./...` and `go vet ./...` here, or use the root Make targets to +validate all implemented backend modules. diff --git a/backend/pkg/mutil/config/param.go b/backend/shared/config/param.go similarity index 97% rename from backend/pkg/mutil/config/param.go rename to backend/shared/config/param.go index abb2b10..250ea53 100644 --- a/backend/pkg/mutil/config/param.go +++ b/backend/shared/config/param.go @@ -4,7 +4,7 @@ import ( "os" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" "go.uber.org/zap" ) diff --git a/backend/shared/database/config.go b/backend/shared/database/config.go new file mode 100644 index 0000000..9d04665 --- /dev/null +++ b/backend/shared/database/config.go @@ -0,0 +1,13 @@ +package db + +import ( + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" +) + +type DBDriver string + +const ( + Mongo DBDriver = "mongodb" +) + +type Config = sharedmodel.DriverConfig[DBDriver] diff --git a/backend/pkg/db/connection.go b/backend/shared/database/connection.go similarity index 89% rename from backend/pkg/db/connection.go rename to backend/shared/database/connection.go index 10cc975..52092fd 100644 --- a/backend/pkg/db/connection.go +++ b/backend/shared/database/connection.go @@ -3,9 +3,9 @@ package db import ( "context" - mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/readpref" ) diff --git a/backend/edge/shared/storage/mongo/dependencies.go b/backend/shared/database/dependencies/dependencies.go similarity index 84% rename from backend/edge/shared/storage/mongo/dependencies.go rename to backend/shared/database/dependencies/dependencies.go index 0b22592..e8c4dc2 100644 --- a/backend/edge/shared/storage/mongo/dependencies.go +++ b/backend/shared/database/dependencies/dependencies.go @@ -1,9 +1,9 @@ package mongo import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/factory.go b/backend/shared/database/factory.go similarity index 56% rename from backend/pkg/db/factory.go rename to backend/shared/database/factory.go index 5a49298..592d81b 100644 --- a/backend/pkg/db/factory.go +++ b/backend/shared/database/factory.go @@ -1,18 +1,14 @@ package db import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" ) -// Factory exposes high-level repositories used by application services. +// Factory exposes the local connection and transaction lifecycle. type Factory interface { - NewAccountDB() (account.DB, error) - NewAccountDBWithCollection(collection string) (account.DB, error) - TransactionFactory() transaction.Factory CloseConnection() diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go b/backend/shared/database/internal/builder/accumulator.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go rename to backend/shared/database/internal/builder/accumulator.go index c59e89c..c465306 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/accumulator.go +++ b/backend/shared/database/internal/builder/accumulator.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go b/backend/shared/database/internal/builder/alias.go similarity index 96% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go rename to backend/shared/database/internal/builder/alias.go index bf9c508..7773b3e 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go +++ b/backend/shared/database/internal/builder/alias.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go b/backend/shared/database/internal/builder/array.go similarity index 89% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go rename to backend/shared/database/internal/builder/array.go index 5c0a4ad..2a3fd0b 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/array.go +++ b/backend/shared/database/internal/builder/array.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go b/backend/shared/database/internal/builder/expression.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go rename to backend/shared/database/internal/builder/expression.go index 719ac97..b1b5397 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go +++ b/backend/shared/database/internal/builder/expression.go @@ -3,7 +3,7 @@ package builderimp import ( "reflect" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go b/backend/shared/database/internal/builder/field.go similarity index 95% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go rename to backend/shared/database/internal/builder/field.go index e53a22c..f19bcd0 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/field.go +++ b/backend/shared/database/internal/builder/field.go @@ -3,7 +3,7 @@ package builderimp import ( "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" ) type FieldImp struct { diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go b/backend/shared/database/internal/builder/func.go similarity index 98% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go rename to backend/shared/database/internal/builder/func.go index 18ed6e0..718bd00 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/func.go +++ b/backend/shared/database/internal/builder/func.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go b/backend/shared/database/internal/builder/gaccumulator.go similarity index 92% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go rename to backend/shared/database/internal/builder/gaccumulator.go index 4ee062b..8a52d68 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/gaccumulator.go +++ b/backend/shared/database/internal/builder/gaccumulator.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go b/backend/shared/database/internal/builder/patch.go similarity index 96% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go rename to backend/shared/database/internal/builder/patch.go index 9311116..a997bbb 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go +++ b/backend/shared/database/internal/builder/patch.go @@ -3,8 +3,8 @@ package builderimp import ( "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go b/backend/shared/database/internal/builder/patch_test.go similarity index 95% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go rename to backend/shared/database/internal/builder/patch_test.go index 191badf..b06153e 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/patch_test.go +++ b/backend/shared/database/internal/builder/patch_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go b/backend/shared/database/internal/builder/pipeline.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go rename to backend/shared/database/internal/builder/pipeline.go index 245821f..7304b29 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go +++ b/backend/shared/database/internal/builder/pipeline.go @@ -1,8 +1,8 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go b/backend/shared/database/internal/builder/pipeline_test.go similarity index 99% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go rename to backend/shared/database/internal/builder/pipeline_test.go index 7592b59..3f49233 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline_test.go +++ b/backend/shared/database/internal/builder/pipeline_test.go @@ -3,8 +3,8 @@ package builderimp import ( "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/stretchr/testify/assert" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go b/backend/shared/database/internal/builder/projection.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go rename to backend/shared/database/internal/builder/projection.go index f982533..2de698d 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/projection.go +++ b/backend/shared/database/internal/builder/projection.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go b/backend/shared/database/internal/builder/query.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go rename to backend/shared/database/internal/builder/query.go index fd2ed01..439b300 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query.go +++ b/backend/shared/database/internal/builder/query.go @@ -3,8 +3,8 @@ package builderimp import ( "reflect" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo/options" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go b/backend/shared/database/internal/builder/query_test.go similarity index 92% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go rename to backend/shared/database/internal/builder/query_test.go index 934fffb..9db6668 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/query_test.go +++ b/backend/shared/database/internal/builder/query_test.go @@ -3,7 +3,7 @@ package builderimp import ( "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go b/backend/shared/database/internal/builder/value.go similarity index 74% rename from backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go rename to backend/shared/database/internal/builder/value.go index 26cf000..01fcd50 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/builderimp/value.go +++ b/backend/shared/database/internal/builder/value.go @@ -1,7 +1,7 @@ package builderimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" ) type valueImp struct { diff --git a/backend/pkg/db/internal/mongo/db.go b/backend/shared/database/internal/mongo/db.go similarity index 88% rename from backend/pkg/db/internal/mongo/db.go rename to backend/shared/database/internal/mongo/db.go index a536c5a..1cbd093 100644 --- a/backend/pkg/db/internal/mongo/db.go +++ b/backend/shared/database/internal/mongo/db.go @@ -3,17 +3,15 @@ package mongo import ( "context" "fmt" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "net" "os" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/db/account" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/accountdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/transactionimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/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/transaction" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-viper/mapstructure/v2" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/readpref" @@ -85,7 +83,7 @@ func Config2DBSettings(logger mlogger.Logger, config *Config) *DBSettings { return p } -func decodeConfig(logger mlogger.Logger, settings model.SettingsT) (*Config, *DBSettings, error) { +func decodeConfig(logger mlogger.Logger, settings sharedmodel.SettingsT) (*Config, *DBSettings, error) { var config Config if err := mapstructure.Decode(settings, &config); err != nil { logger.Warn("Failed to decode settings", zap.Error(err)) @@ -121,7 +119,7 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er return client, nil } -func ConnectClient(logger mlogger.Logger, settings model.SettingsT) (*mongo.Client, *Config, *DBSettings, error) { +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 @@ -150,14 +148,6 @@ func (db *DB) MongoDatabase() *mongo.Database { return db.db() } -func (db *DB) NewAccountDB() (account.DB, error) { - return accountdb.Create(db.logger, db.db()) -} - -func (db *DB) NewAccountDBWithCollection(collection string) (account.DB, error) { - return accountdb.CreateWithCollection(db.logger, db.db(), collection) -} - func (db *DB) TransactionFactory() transaction.Factory { return transactionimp.CreateFactory(db.client) } @@ -234,7 +224,7 @@ func (db *DB) CloseConnection() { } // NewConnection creates a new database connection -func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) { +func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) { client, _, dbSettings, err := ConnectClient(logger, settings) if err != nil { return nil, err diff --git a/backend/pkg/db/internal/mongo/mongo.go b/backend/shared/database/internal/mongo/mongo.go similarity index 100% rename from backend/pkg/db/internal/mongo/mongo.go rename to backend/shared/database/internal/mongo/mongo.go diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index.go b/backend/shared/database/internal/repository/index.go similarity index 96% rename from backend/pkg/db/internal/mongo/repositoryimp/index.go rename to backend/shared/database/internal/repository/index.go index 5d71b17..0c4a39c 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/index.go +++ b/backend/shared/database/internal/repository/index.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go b/backend/shared/database/internal/repository/index_integration_test.go similarity index 95% rename from backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go rename to backend/shared/database/internal/repository/index_integration_test.go index 2aa6189..b7d5533 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/index_integration_test.go +++ b/backend/shared/database/internal/repository/index_integration_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/repository" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/index_test.go b/backend/shared/database/internal/repository/index_test.go similarity index 95% rename from backend/pkg/db/internal/mongo/repositoryimp/index_test.go rename to backend/shared/database/internal/repository/index_test.go index 94f27d5..2dda698 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/index_test.go +++ b/backend/shared/database/internal/repository/index_test.go @@ -3,7 +3,7 @@ package repositoryimp import ( "testing" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository.go b/backend/shared/database/internal/repository/repository.go similarity index 95% rename from backend/pkg/db/internal/mongo/repositoryimp/repository.go rename to backend/shared/database/internal/repository/repository.go index 18d5eec..f11e249 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository.go +++ b/backend/shared/database/internal/repository/repository.go @@ -5,11 +5,11 @@ import ( "errors" "fmt" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go b/backend/shared/database/internal/repository/repository_comprehensive_test.go similarity index 98% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go rename to backend/shared/database/internal/repository/repository_comprehensive_test.go index 4adf08d..cd38f92 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go +++ b/backend/shared/database/internal/repository/repository_comprehensive_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/internal/builder" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go b/backend/shared/database/internal/repository/repository_filter.go similarity index 90% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go rename to backend/shared/database/internal/repository/repository_filter.go index 186ee24..381bb1e 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_filter.go +++ b/backend/shared/database/internal/repository/repository_filter.go @@ -4,9 +4,9 @@ import ( "context" "errors" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go b/backend/shared/database/internal/repository/repository_insertmany_test.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go rename to backend/shared/database/internal/repository/repository_insertmany_test.go index 68f5286..a5f84fa 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_insertmany_test.go +++ b/backend/shared/database/internal/repository/repository_insertmany_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" + "git.sendico.io/tech/sxchnge/shared/database/record" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go b/backend/shared/database/internal/repository/repository_patch_test.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go rename to backend/shared/database/internal/repository/repository_patch_test.go index 1b0e501..be65ee2 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go +++ b/backend/shared/database/internal/repository/repository_patch_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_test.go b/backend/shared/database/internal/repository/repository_test.go similarity index 94% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_test.go rename to backend/shared/database/internal/repository/repository_test.go index ad1b2c6..85655ca 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_test.go +++ b/backend/shared/database/internal/repository/repository_test.go @@ -9,11 +9,11 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/database/internal/builder" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go b/backend/shared/database/internal/repository/repository_upsert_test.go similarity index 97% rename from backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go rename to backend/shared/database/internal/repository/repository_upsert_test.go index bf8136d..bea26d9 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_upsert_test.go +++ b/backend/shared/database/internal/repository/repository_upsert_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/backend/pkg/db/internal/mongo/transactionimp/factory.go b/backend/shared/database/internal/transaction/factory.go similarity index 86% rename from backend/pkg/db/internal/mongo/transactionimp/factory.go rename to backend/shared/database/internal/transaction/factory.go index 8106414..344b312 100644 --- a/backend/pkg/db/internal/mongo/transactionimp/factory.go +++ b/backend/shared/database/internal/transaction/factory.go @@ -1,7 +1,7 @@ package transactionimp import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/shared/database/transaction" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/internal/mongo/transactionimp/transaction.go b/backend/shared/database/internal/transaction/transaction.go similarity index 92% rename from backend/pkg/db/internal/mongo/transactionimp/transaction.go rename to backend/shared/database/internal/transaction/transaction.go index 73d09f6..458d6c3 100644 --- a/backend/pkg/db/internal/mongo/transactionimp/transaction.go +++ b/backend/shared/database/internal/transaction/transaction.go @@ -3,7 +3,7 @@ package transactionimp import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/shared/database/transaction" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/mutil/db/archive.go b/backend/shared/database/query/archive.go similarity index 81% rename from backend/pkg/mutil/db/archive.go rename to backend/shared/database/query/archive.go index daf901f..966a1ec 100644 --- a/backend/pkg/mutil/db/archive.go +++ b/backend/shared/database/query/archive.go @@ -3,10 +3,10 @@ package mutil import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/fields" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) diff --git a/backend/pkg/mutil/db/array.go b/backend/shared/database/query/array.go similarity index 60% rename from backend/pkg/mutil/db/array.go rename to backend/shared/database/query/array.go index 7d63abc..208e444 100644 --- a/backend/pkg/mutil/db/array.go +++ b/backend/shared/database/query/array.go @@ -2,16 +2,16 @@ package mutil import ( "context" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" ) -func GetObjects[T any](ctx context.Context, logger mlogger.Logger, filter builder.Query, cursor *model.ViewCursor, repo repository.Repository) ([]T, error) { +func GetObjects[T any](ctx context.Context, logger mlogger.Logger, filter builder.Query, cursor *sharedmodel.ViewCursor, repo repository.Repository) ([]T, error) { entities := make([]T, 0) decoder := func(cur *mongo.Cursor) error { var next T diff --git a/backend/pkg/mutil/db/db.go b/backend/shared/database/query/db.go similarity index 74% rename from backend/pkg/mutil/db/db.go rename to backend/shared/database/query/db.go index 646313b..a916622 100644 --- a/backend/pkg/mutil/db/db.go +++ b/backend/shared/database/query/db.go @@ -3,9 +3,9 @@ package mutil import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) diff --git a/backend/pkg/db/storable/id.go b/backend/shared/database/record/id.go similarity index 100% rename from backend/pkg/db/storable/id.go rename to backend/shared/database/record/id.go diff --git a/backend/pkg/db/storable/ref.go b/backend/shared/database/record/ref.go similarity index 100% rename from backend/pkg/db/storable/ref.go rename to backend/shared/database/record/ref.go diff --git a/backend/pkg/db/storable/storable.go b/backend/shared/database/record/storable.go similarity index 100% rename from backend/pkg/db/storable/storable.go rename to backend/shared/database/record/storable.go diff --git a/backend/pkg/db/repository/builder/accumulator.go b/backend/shared/database/repository/builder/accumulator.go similarity index 100% rename from backend/pkg/db/repository/builder/accumulator.go rename to backend/shared/database/repository/builder/accumulator.go diff --git a/backend/pkg/db/repository/builder/alias.go b/backend/shared/database/repository/builder/alias.go similarity index 100% rename from backend/pkg/db/repository/builder/alias.go rename to backend/shared/database/repository/builder/alias.go diff --git a/backend/pkg/db/repository/builder/array.go b/backend/shared/database/repository/builder/array.go similarity index 100% rename from backend/pkg/db/repository/builder/array.go rename to backend/shared/database/repository/builder/array.go diff --git a/backend/pkg/db/repository/builder/expression.go b/backend/shared/database/repository/builder/expression.go similarity index 100% rename from backend/pkg/db/repository/builder/expression.go rename to backend/shared/database/repository/builder/expression.go diff --git a/backend/pkg/db/repository/builder/field.go b/backend/shared/database/repository/builder/field.go similarity index 100% rename from backend/pkg/db/repository/builder/field.go rename to backend/shared/database/repository/builder/field.go diff --git a/backend/pkg/db/repository/builder/keyword.go b/backend/shared/database/repository/builder/keyword.go similarity index 100% rename from backend/pkg/db/repository/builder/keyword.go rename to backend/shared/database/repository/builder/keyword.go diff --git a/backend/pkg/db/repository/builder/operators.go b/backend/shared/database/repository/builder/operators.go similarity index 100% rename from backend/pkg/db/repository/builder/operators.go rename to backend/shared/database/repository/builder/operators.go diff --git a/backend/pkg/db/repository/builder/patch.go b/backend/shared/database/repository/builder/patch.go similarity index 100% rename from backend/pkg/db/repository/builder/patch.go rename to backend/shared/database/repository/builder/patch.go diff --git a/backend/pkg/db/repository/builder/pipeline.go b/backend/shared/database/repository/builder/pipeline.go similarity index 93% rename from backend/pkg/db/repository/builder/pipeline.go rename to backend/shared/database/repository/builder/pipeline.go index 957b863..61f36f8 100644 --- a/backend/pkg/db/repository/builder/pipeline.go +++ b/backend/shared/database/repository/builder/pipeline.go @@ -1,7 +1,7 @@ package builder import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/repository/builder/projection.go b/backend/shared/database/repository/builder/projection.go similarity index 100% rename from backend/pkg/db/repository/builder/projection.go rename to backend/shared/database/repository/builder/projection.go diff --git a/backend/pkg/db/repository/builder/query.go b/backend/shared/database/repository/builder/query.go similarity index 100% rename from backend/pkg/db/repository/builder/query.go rename to backend/shared/database/repository/builder/query.go diff --git a/backend/pkg/db/repository/builder/unwind.go b/backend/shared/database/repository/builder/unwind.go similarity index 100% rename from backend/pkg/db/repository/builder/unwind.go rename to backend/shared/database/repository/builder/unwind.go diff --git a/backend/pkg/db/repository/builder/value.go b/backend/shared/database/repository/builder/value.go similarity index 100% rename from backend/pkg/db/repository/builder/value.go rename to backend/shared/database/repository/builder/value.go diff --git a/backend/pkg/db/repository/builders.go b/backend/shared/database/repository/builders.go similarity index 94% rename from backend/pkg/db/repository/builders.go rename to backend/shared/database/repository/builders.go index ccf15ec..3eb8ebd 100644 --- a/backend/pkg/db/repository/builders.go +++ b/backend/shared/database/repository/builders.go @@ -1,10 +1,10 @@ package repository import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp/builderimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/internal/builder" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -41,11 +41,11 @@ func IDField() builder.Field { } func NameField() builder.Field { - return Field(model.NameField) + return Field(sharedmodel.NameField) } func DescrtiptionField() builder.Field { - return Field(model.DescriptionField) + return Field(sharedmodel.DescriptionField) } func IsArchivedField() builder.Field { @@ -69,7 +69,7 @@ func IsArchivedFilter(isArchived bool) builder.Query { } func AccountField() builder.Field { - return Field(model.AccountRefField) + return Field("accountRef") } func AccountFilter(ref bson.ObjectID) builder.Query { diff --git a/backend/pkg/db/repository/cursor.go b/backend/shared/database/repository/cursor.go similarity index 57% rename from backend/pkg/db/repository/cursor.go rename to backend/shared/database/repository/cursor.go index 8a66b50..49fecc8 100644 --- a/backend/pkg/db/repository/cursor.go +++ b/backend/shared/database/repository/cursor.go @@ -1,12 +1,12 @@ package repository import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" ) // ApplyCursor adds pagination and archival filters to the provided query. -func ApplyCursor(query builder.Query, cursor *model.ViewCursor) builder.Query { +func ApplyCursor(query builder.Query, cursor *sharedmodel.ViewCursor) builder.Query { if cursor == nil { return query } diff --git a/backend/pkg/db/repository/decoder/decoder.go b/backend/shared/database/repository/decoder/decoder.go similarity index 100% rename from backend/pkg/db/repository/decoder/decoder.go rename to backend/shared/database/repository/decoder/decoder.go diff --git a/backend/pkg/db/repository/index/index.go b/backend/shared/database/repository/index/index.go similarity index 89% rename from backend/pkg/db/repository/index/index.go rename to backend/shared/database/repository/index/index.go index 0606bea..d2ccdfd 100644 --- a/backend/pkg/db/repository/index/index.go +++ b/backend/shared/database/repository/index/index.go @@ -1,6 +1,6 @@ package repository -import "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" +import "git.sendico.io/tech/sxchnge/shared/database/repository/builder" type Sort int8 diff --git a/backend/pkg/db/repository/index/types.go b/backend/shared/database/repository/index/types.go similarity index 100% rename from backend/pkg/db/repository/index/types.go rename to backend/shared/database/repository/index/types.go diff --git a/backend/pkg/db/repository/repository.go b/backend/shared/database/repository/repository.go similarity index 88% rename from backend/pkg/db/repository/repository.go rename to backend/shared/database/repository/repository.go index 00bab1d..f53bc95 100644 --- a/backend/pkg/db/repository/repository.go +++ b/backend/shared/database/repository/repository.go @@ -3,12 +3,12 @@ package repository import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/repositoryimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" + "git.sendico.io/tech/sxchnge/shared/database/internal/repository" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/transaction" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/template/interface.go b/backend/shared/database/template/interface.go similarity index 84% rename from backend/pkg/db/template/interface.go rename to backend/shared/database/template/interface.go index 61b2556..e8918bd 100644 --- a/backend/pkg/db/template/interface.go +++ b/backend/shared/database/template/interface.go @@ -3,8 +3,8 @@ package template import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" "go.mongodb.org/mongo-driver/v2/bson" ) diff --git a/backend/pkg/db/template/template.go b/backend/shared/database/template/template.go similarity index 87% rename from backend/pkg/db/template/template.go rename to backend/shared/database/template/template.go index e1ad219..93b02cc 100644 --- a/backend/pkg/db/template/template.go +++ b/backend/shared/database/template/template.go @@ -3,13 +3,13 @@ package template import ( "context" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/logging/fields" + "git.sendico.io/tech/sxchnge/shared/service" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) diff --git a/backend/pkg/db/transaction/context.go b/backend/shared/database/transaction/context.go similarity index 100% rename from backend/pkg/db/transaction/context.go rename to backend/shared/database/transaction/context.go diff --git a/backend/pkg/db/transaction/context_test.go b/backend/shared/database/transaction/context_test.go similarity index 100% rename from backend/pkg/db/transaction/context_test.go rename to backend/shared/database/transaction/context_test.go diff --git a/backend/pkg/db/transaction/factory.go b/backend/shared/database/transaction/factory.go similarity index 100% rename from backend/pkg/db/transaction/factory.go rename to backend/shared/database/transaction/factory.go diff --git a/backend/pkg/db/transaction/transaction.go b/backend/shared/database/transaction/transaction.go similarity index 100% rename from backend/pkg/db/transaction/transaction.go rename to backend/shared/database/transaction/transaction.go diff --git a/backend/pkg/mutil/duration/duration.go b/backend/shared/duration/duration.go similarity index 100% rename from backend/pkg/mutil/duration/duration.go rename to backend/shared/duration/duration.go diff --git a/backend/pkg/merrors/classified.go b/backend/shared/errors/classified.go similarity index 100% rename from backend/pkg/merrors/classified.go rename to backend/shared/errors/classified.go diff --git a/backend/pkg/merrors/errors.go b/backend/shared/errors/errors.go similarity index 100% rename from backend/pkg/merrors/errors.go rename to backend/shared/errors/errors.go diff --git a/backend/pkg/merrors/errors_test.go b/backend/shared/errors/errors_test.go similarity index 100% rename from backend/pkg/merrors/errors_test.go rename to backend/shared/errors/errors_test.go diff --git a/backend/pkg/merrors/invalid_argument.go b/backend/shared/errors/invalid_argument.go similarity index 100% rename from backend/pkg/merrors/invalid_argument.go rename to backend/shared/errors/invalid_argument.go diff --git a/backend/pkg/merrors/reason.go b/backend/shared/errors/reason.go similarity index 100% rename from backend/pkg/merrors/reason.go rename to backend/shared/errors/reason.go diff --git a/backend/pkg/merrors/throttle.go b/backend/shared/errors/throttle.go similarity index 100% rename from backend/pkg/merrors/throttle.go rename to backend/shared/errors/throttle.go diff --git a/backend/pkg/merrors/throttle_test.go b/backend/shared/errors/throttle_test.go similarity index 100% rename from backend/pkg/merrors/throttle_test.go rename to backend/shared/errors/throttle_test.go diff --git a/backend/shared/go.mod b/backend/shared/go.mod new file mode 100644 index 0000000..04a90c1 --- /dev/null +++ b/backend/shared/go.mod @@ -0,0 +1,71 @@ +module git.sendico.io/tech/sxchnge/shared + +go 1.27.0 + +require ( + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/mattn/go-colorable v0.1.15 + github.com/stretchr/testify v1.12.1 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 + go.mongodb.org/mongo-driver/v2 v2.9.0 + go.uber.org/zap v1.28.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/backend/shared/go.sum b/backend/shared/go.sum new file mode 100644 index 0000000..c0b4aec --- /dev/null +++ b/backend/shared/go.sum @@ -0,0 +1,177 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0= +go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/pkg/api/http/methods.go b/backend/shared/http/methods.go similarity index 100% rename from backend/pkg/api/http/methods.go rename to backend/shared/http/methods.go diff --git a/backend/pkg/api/http/response/response.go b/backend/shared/http/response/response.go similarity index 97% rename from backend/pkg/api/http/response/response.go rename to backend/shared/http/response/response.go index 01a331b..e373154 100644 --- a/backend/pkg/api/http/response/response.go +++ b/backend/shared/http/response/response.go @@ -6,10 +6,10 @@ import ( "fmt" "net/http" - api "git.sendico.io/tech/sxchnge/bff/pkg/api/http" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/errors" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) diff --git a/backend/pkg/api/http/response/response_test.go b/backend/shared/http/response/response_test.go similarity index 92% rename from backend/pkg/api/http/response/response_test.go rename to backend/shared/http/response/response_test.go index b71e996..14c0f96 100644 --- a/backend/pkg/api/http/response/response_test.go +++ b/backend/shared/http/response/response_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/service" "github.com/stretchr/testify/require" "go.uber.org/zap" ) diff --git a/backend/pkg/api/http/response/result.go b/backend/shared/http/response/result.go similarity index 86% rename from backend/pkg/api/http/response/result.go rename to backend/shared/http/response/result.go index 0e13b93..6c1f521 100644 --- a/backend/pkg/api/http/response/result.go +++ b/backend/shared/http/response/result.go @@ -3,7 +3,7 @@ package response import ( "net/http" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" ) type Result struct { diff --git a/backend/pkg/api/http/status.go b/backend/shared/http/status.go similarity index 100% rename from backend/pkg/api/http/status.go rename to backend/shared/http/status.go diff --git a/backend/shared/logging/factory/mlogger.go b/backend/shared/logging/factory/mlogger.go new file mode 100644 index 0000000..e051ef4 --- /dev/null +++ b/backend/shared/logging/factory/mlogger.go @@ -0,0 +1,10 @@ +package mlogger + +import ( + lt "git.sendico.io/tech/sxchnge/shared/logging" + li "git.sendico.io/tech/sxchnge/shared/logging/internal" +) + +func NewLogger(debug bool) lt.Logger { + return li.NewLoggerImp(debug) +} diff --git a/backend/pkg/mutil/mzap/account.go b/backend/shared/logging/fields/account.go similarity index 72% rename from backend/pkg/mutil/mzap/account.go rename to backend/shared/logging/fields/account.go index 036179a..b7de948 100644 --- a/backend/pkg/mutil/mzap/account.go +++ b/backend/shared/logging/fields/account.go @@ -1,8 +1,7 @@ package mzap import ( - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask" + "git.sendico.io/tech/sxchnge/shared/logging/mask" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -22,7 +21,3 @@ func MaskEmail(field, email string) zap.Field { func MaskEmails(field string, emails []string) zap.Field { return zap.Strings(field, mask.Emails(emails)) } - -func Login(account *model.Account) zap.Field { - return Email(account.Login) -} diff --git a/backend/pkg/mutil/mzap/error.go b/backend/shared/logging/fields/error.go similarity index 100% rename from backend/pkg/mutil/mzap/error.go rename to backend/shared/logging/fields/error.go diff --git a/backend/pkg/mutil/mzap/object.go b/backend/shared/logging/fields/object.go similarity index 84% rename from backend/pkg/mutil/mzap/object.go rename to backend/shared/logging/fields/object.go index 01b71fd..e69a83f 100644 --- a/backend/pkg/mutil/mzap/object.go +++ b/backend/shared/logging/fields/object.go @@ -1,7 +1,7 @@ package mzap import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" + "git.sendico.io/tech/sxchnge/shared/database/record" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) diff --git a/backend/pkg/mlogger/internal/mlogger/mlogger.go b/backend/shared/logging/internal/mlogger.go similarity index 90% rename from backend/pkg/mlogger/internal/mlogger/mlogger.go rename to backend/shared/logging/internal/mlogger.go index 2c0f1cb..db89e78 100644 --- a/backend/pkg/mlogger/internal/mlogger/mlogger.go +++ b/backend/shared/logging/internal/mlogger.go @@ -1,7 +1,7 @@ package mlogger import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/logging" "github.com/mattn/go-colorable" "go.uber.org/zap" "go.uber.org/zap/zapcore" diff --git a/backend/pkg/mlogger/logger.go b/backend/shared/logging/logger.go similarity index 100% rename from backend/pkg/mlogger/logger.go rename to backend/shared/logging/logger.go diff --git a/backend/pkg/mlogger/logger_test.go b/backend/shared/logging/logger_test.go similarity index 100% rename from backend/pkg/mlogger/logger_test.go rename to backend/shared/logging/logger_test.go diff --git a/backend/pkg/mutil/mask/mask.go b/backend/shared/logging/mask/mask.go similarity index 100% rename from backend/pkg/mutil/mask/mask.go rename to backend/shared/logging/mask/mask.go diff --git a/backend/pkg/mutil/mask/mask_test.go b/backend/shared/logging/mask/mask_test.go similarity index 100% rename from backend/pkg/mutil/mask/mask_test.go rename to backend/shared/logging/mask/mask_test.go diff --git a/backend/pkg/model/archivable.go b/backend/shared/model/archivable.go similarity index 100% rename from backend/pkg/model/archivable.go rename to backend/shared/model/archivable.go diff --git a/backend/pkg/model/describable.go b/backend/shared/model/describable.go similarity index 100% rename from backend/pkg/model/describable.go rename to backend/shared/model/describable.go diff --git a/backend/pkg/model/fconfig.go b/backend/shared/model/fconfig.go similarity index 100% rename from backend/pkg/model/fconfig.go rename to backend/shared/model/fconfig.go diff --git a/backend/pkg/model/viewcursor.go b/backend/shared/model/viewcursor.go similarity index 100% rename from backend/pkg/model/viewcursor.go rename to backend/shared/model/viewcursor.go diff --git a/backend/edge/shared/interface/ratelimit/config.go b/backend/shared/ratelimit/config.go similarity index 95% rename from backend/edge/shared/interface/ratelimit/config.go rename to backend/shared/ratelimit/config.go index f76045f..f4d8c28 100644 --- a/backend/edge/shared/interface/ratelimit/config.go +++ b/backend/shared/ratelimit/config.go @@ -4,7 +4,7 @@ import ( "strings" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" + "git.sendico.io/tech/sxchnge/shared/errors" ) const ( diff --git a/backend/edge/shared/storage/ratelimits/db.go b/backend/shared/ratelimit/store/db.go similarity index 94% rename from backend/edge/shared/storage/ratelimits/db.go rename to backend/shared/ratelimit/store/db.go index cbc9995..7069e54 100644 --- a/backend/edge/shared/storage/ratelimits/db.go +++ b/backend/shared/ratelimit/store/db.go @@ -8,11 +8,11 @@ import ( "strings" "time" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" + "git.sendico.io/tech/sxchnge/shared/database/record" + "git.sendico.io/tech/sxchnge/shared/database/repository" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" ) diff --git a/backend/pkg/mservice/mservice.go b/backend/shared/service/mservice.go similarity index 100% rename from backend/pkg/mservice/mservice.go rename to backend/shared/service/mservice.go diff --git a/backend/pkg/mservice/services.go b/backend/shared/service/services.go similarity index 100% rename from backend/pkg/mservice/services.go rename to backend/shared/service/services.go diff --git a/backend/shared/version/factory/factory.go b/backend/shared/version/factory/factory.go new file mode 100644 index 0000000..58f71d0 --- /dev/null +++ b/backend/shared/version/factory/factory.go @@ -0,0 +1,10 @@ +package version + +import ( + "git.sendico.io/tech/sxchnge/shared/version" + versionimp "git.sendico.io/tech/sxchnge/shared/version/internal" +) + +func Create(info *version.Info) version.Printer { + return versionimp.Create(info) +} diff --git a/backend/pkg/version/info.go b/backend/shared/version/info.go similarity index 100% rename from backend/pkg/version/info.go rename to backend/shared/version/info.go diff --git a/backend/pkg/version/internal/version.go b/backend/shared/version/internal/version.go similarity index 98% rename from backend/pkg/version/internal/version.go rename to backend/shared/version/internal/version.go index dd159d6..add4cd5 100644 --- a/backend/pkg/version/internal/version.go +++ b/backend/shared/version/internal/version.go @@ -8,7 +8,7 @@ import ( "strings" "text/template" - "git.sendico.io/tech/sxchnge/bff/pkg/version" + "git.sendico.io/tech/sxchnge/shared/version" ) // versionInfoTmpl contains the template used by Info. diff --git a/backend/pkg/version/version.go b/backend/shared/version/version.go similarity index 100% rename from backend/pkg/version/version.go rename to backend/shared/version/version.go diff --git a/docs/architecture/bff.md b/docs/architecture/bff.md index a4dcd73..c3e1032 100644 --- a/docs/architecture/bff.md +++ b/docs/architecture/bff.md @@ -15,26 +15,30 @@ and allowed to diverge within this repository. Source: `S_I/sendico`, commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. The source checkout's unrelated dependency edits were not copied. -| Local component | Reused implementation | -| --- | --- | -| `backend/edge/shared` | Chi HTTP runtime, middleware, JWT login/pending sessions, refresh/rotation/logout, CSRF/cookies, rate limiting, Mongo verification and refresh-token stores | -| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, atomic customer creation, activation tokens, password reset and email-change token issuance, deletion | -| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, errors, structured logging, process bootstrap and health | -| `backend/internal/profile` | Small SXCHNGE composition layer selecting the copied local account service | -| `backend/contracts/sendico` | Separate snapshot of Sendico's public OpenAPI contract and its referenced schemas | +`backend/` groups Go modules. The implemented executable is `backend/product`, +with its own `main.go` and `go.mod`. `backend/shared` is a separate library module. +Backoffice will be a separate executable module (`backend/backoffice`) with its +own entry point, operator identity, configuration and deployment. Product's entry +point is not the entry point for every future backend. -Source provenance and the bounded adaptations are in -[PROVENANCE.md](../../backend/PROVENANCE.md). There is no sibling source import, -private Sendico module requirement, gRPC client, discovery subscription or broker -connection. Platform engines and Product's platform-provisioning bootstrap were -not copied. The original handwritten shell and tiny replacement helpers were removed. +| Owner | Retained implementation | +| --- | --- | +| `product/app` | HTTP runtime, startup, composition and health | +| `product/customers` | Local customer models, service and account repository | +| `product/auth` | Login/protected routes, local DTOs/mappers and auth configuration | +| `product/sessions` | Session/client models, refresh storage, cookies and token generation | +| `product/verification` | Verification models, configuration and storage | +| `shared` | Generic database/transaction/query code, errors, logging and HTTP helpers | + +Shared infrastructure imports no executable module. Customer repositories are +constructed by the product module rather than the generic database factory. +There is no global customer/session model package or Sendico `edge/product` +source hierarchy. Source provenance follows the moved files across Go modules. Local customers have no organization membership, roles, policies or tenant ID. -The account service validates and hashes passwords, creates an account and -activation token atomically, and deletes an account with session revocation. -Customer responses map to explicit local DTOs. Account, token and session collections and application client records belong only -to SXCHNGE. The runtime starts without any policy or tenant seed. Casbin, -invitations, financial models and provisioning machinery are excluded. +Creation validates/hashes passwords and commits the account and activation token +atomically. Deletion revokes that customer's sessions. The runtime starts without +any seed. Casbin, invitations and platform provisioning are absent. ## Identity and ownership @@ -57,9 +61,11 @@ Confirm the deployed credential policy before configuring a provider client. ## Contract and service structure -The copied OpenAPI files are a pinned upstream wire contract, not SXCHNGE's app -API or domain model. Preserve them as an isolated snapshot. Changes require an -explicit contract update; do not follow Sendico source updates automatically. +The bootstrap has no Sendico runtime calls and therefore no copied public wire +snapshot. When implementing an adapter, keep only its consumed request/response +contracts within that adapter. Pin their upstream revision separately from local +customer DTOs/models; do not copy the entire platform contract or synchronize +source automatically. When wiring a feature, keep the existing architecture: @@ -73,13 +79,13 @@ or replace local account services with Sendico login/customer calls. The next adapter will use a configured public HTTPS base URL, a fixed organization reference, bounded request timeouts and an injected HTTP transport. It must reject -cross-origin credential redirects. Reuse the existing public contract and response -conventions; do not invent a parallel provider facade or call internal services. +cross-origin credential redirects. Use the public contract and response +conventions for each implemented operation; do not invent a parallel provider facade or call internal services. ## Required public Sendico capabilities -The snapshot is API version 4.2.37. Source routes and schemas establish available -shapes; they do not prove that a deployment enables every feature. +The source reviewed at the pinned revision describes API version 4.2.37. Its +public routes and schemas establish available shapes; they do not prove that a deployment enables every feature. | Need | Existing public surface / work required | | --- | --- | @@ -120,7 +126,8 @@ operation details explain what has happened and what remains pending. ## Current delivery boundary -This task supplies the copied starting point and isolated public contracts. +This task supplies the selected copied starting point in independently buildable +Go modules and a public API integration plan. The local runtime installs the existing authentication transport and account service. Local signup/profile HTTP endpoints, verification delivery/completion, application client setup, Flutter HTTP adapters and live Sendico calls diff --git a/docs/implementation.md b/docs/implementation.md index 031efe5..040d884 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -379,7 +379,8 @@ Retained Sendico source is pinned to `8bb20dcd622dd8eb92d289b6b34411e7eccab083`: HTTP runtime, local customer/password service and Mongo repository, customer sessions and verification storage, generic repository/transaction code, errors, logging and health. [Provenance](../backend/PROVENANCE.json) covers 232 retained -Go files and 112 isolated public OpenAPI files; every adaptation is flagged. +Go files at that cleanup stage; every adaptation is flagged. The later module +reorganization below removes the unused public OpenAPI snapshot. The Sendico checkout remains unchanged. Customer creation accepts a raw password, validates/hashes it and commits the @@ -416,3 +417,32 @@ not been deployed. Existing experimental databases may retain unused tenancy collections; this change does not delete external database records. Customer HTTP responses now expose only ID, name, last name, login, locale and optional avatar. The frontend still uses dummy services and is unchanged. + +### Go module organization correction + +`backend/` now groups two implemented Go modules: the runnable `product` BFF +(`main.go`, `go.mod`, configuration and feature packages) and the `shared` library +(its own `go.mod`, generic storage, errors, logging and HTTP helpers). Product uses +`replace git.sendico.io/tech/sxchnge/shared => ../shared`. A future backoffice BFF +belongs in its own `backend/backoffice` executable module; it is not implemented +or silently combined with product in this task. + +Customer, session and verification models/storage now live with their features. +Shared database code no longer knows about customer repositories. Auth and +verification configurations belong to their feature packages. The inherited +`cmd`, `edge`, `internal`, `pkg`, `scripts` and `contracts` top-level folders are +removed. All 112 unused contract files are removed; future HTTP adapters will +own only consumed public wire definitions. Provenance retains the pinned upstream +source paths while recording the new local module paths. + +Root Make targets validate both modules independently and run product with +`cd backend/product && go run .`. Source behavior remains the previously validated +local customer/auth/session bootstrap; frontend and Sendico source are untouched. + +Validation passed after the module moves: `make test` (both Go modules with the +race detector and all frontend suites), `make lint-backend`, product executable +build and `-version` smoke check, and `go test -tags integration -run '^$' ./...` +in each Go module (compile-only for the additional tagged suites). All 233 +provenance source blobs match the pinned revision. `git diff --check` passed. +No security scans were requested or run. The correction is delivered on `bff-34` +in [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), which remains unmerged. -- 2.54.0 From 36277b6c45b25e692f9c82d2d311a43019169fe8 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 14:34:19 +0200 Subject: [PATCH 06/11] docs: require shared database and NATS infrastructure --- backend/AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 6c3fdd8..6cbe652 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -22,6 +22,28 @@ skill to perform this completion workflow. For example, prefer a concise path such as `payment.Details.ID` over `payment.Payment.PaymentID`. +## Reuse shared database and messaging infrastructure + +- All database integrations must use the existing shared database wrappers in + `backend/shared/database`. Do not bypass them with direct database clients or + create feature-local replacements. +- All NATS connections must use shared messaging drivers owned by the + `backend/shared` library module. Keep reusable NATS drivers in + `backend/shared/messaging`; executable modules must consume that package. + Do not open direct NATS connections or implement separate connection + management in a feature or service. +- If the shared messaging package is missing, consolidate the existing driver + into `backend/shared/messaging` and reuse it. Do not create another + feature-local driver or import a sibling executable module's infrastructure. +- Search for the existing wrappers, drivers, and their usage before implementing + an integration. Reuse their connection lifecycle, configuration, retries, and + other infrastructure behavior instead of copying or rewriting it. +- If an integration needs a capability the shared infrastructure does not yet + expose, extend the canonical wrapper or driver. Keep reusable infrastructure + there and domain-specific behavior in the owning module. +- During review, check that database and NATS integrations use these shared + paths and remove any duplicated infrastructure introduced by the task. + ## Use the project error model - Do not introduce `fmt.Errorf` or `errors.New`. Use the constructors and -- 2.54.0 From 5c2d36281bf9a8743b284875ef7bcbff34d1b346 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 14:41:55 +0200 Subject: [PATCH 07/11] Implement notifications service with shared messaging and email drivers (#36) --- .gitignore | 5 + Makefile | 25 +- backend/README.md | 8 +- backend/notifications/PROVENANCE.md | 26 ++ backend/notifications/README.md | 88 +++++ backend/notifications/app/config.go | 53 +++ backend/notifications/app/config_test.go | 35 ++ backend/notifications/app/run.go | 105 ++++++ backend/notifications/bus/dispatch.go | 108 +++++++ backend/notifications/bus/dispatch_test.go | 55 ++++ backend/notifications/config.example.yml | 24 ++ backend/notifications/email/driver.go | 79 +++++ backend/notifications/email/driver_test.go | 105 ++++++ backend/notifications/email/http.go | 54 ++++ backend/notifications/email/resend.go | 22 ++ backend/notifications/email/sendgrid.go | 28 ++ backend/notifications/go.mod | 84 +++++ backend/notifications/go.sum | 203 ++++++++++++ backend/notifications/interface/README.md | 41 +++ backend/notifications/interface/go.mod | 15 + backend/notifications/interface/go.sum | 38 +++ .../interface/proto/v1/interface.proto | 73 +++++ backend/notifications/main.go | 33 ++ backend/notifications/model/model.go | 58 ++++ .../notifications/service/integration_test.go | 209 ++++++++++++ backend/notifications/service/service.go | 109 +++++++ backend/notifications/service/worker.go | 75 +++++ backend/notifications/service/worker_test.go | 64 ++++ backend/notifications/store/mongo.go | 118 +++++++ backend/notifications/templates.example.json | 15 + backend/notifications/templates/catalog.go | 146 +++++++++ .../notifications/templates/catalog_test.go | 42 +++ backend/notifications/transport/mapper.go | 46 +++ backend/notifications/transport/server.go | 92 ++++++ .../notifications/transport/server_test.go | 66 ++++ backend/product/architecture/boundary_test.go | 4 +- backend/shared/README.md | 2 + backend/shared/go.mod | 3 + backend/shared/go.sum | 6 + backend/shared/messaging/capability/errors.go | 19 ++ backend/shared/messaging/config.go | 13 + .../shared/messaging/internal/natsb/broker.go | 304 ++++++++++++++++++ .../messaging/internal/natsb/broker_test.go | 15 + .../messaging/internal/natsb/config/config.go | 13 + .../messaging/internal/natscap/adapter.go | 114 +++++++ .../messaging/internal/natscap/admin.go | 282 ++++++++++++++++ .../internal/natscap/subscription.go | 178 ++++++++++ .../internal/natscap/subscription_test.go | 58 ++++ .../messaging/reliable/durable_settings.go | 75 +++++ .../reliable/durable_settings_behavior.go | 25 ++ .../shared/messaging/reliable/transport.go | 198 ++++++++++++ .../messaging/reliable/transport_test.go | 16 + backend/shared/messaging/runtime/runtime.go | 49 +++ docs/implementation.md | 31 ++ 54 files changed, 3746 insertions(+), 6 deletions(-) create mode 100644 backend/notifications/PROVENANCE.md create mode 100644 backend/notifications/README.md create mode 100644 backend/notifications/app/config.go create mode 100644 backend/notifications/app/config_test.go create mode 100644 backend/notifications/app/run.go create mode 100644 backend/notifications/bus/dispatch.go create mode 100644 backend/notifications/bus/dispatch_test.go create mode 100644 backend/notifications/config.example.yml create mode 100644 backend/notifications/email/driver.go create mode 100644 backend/notifications/email/driver_test.go create mode 100644 backend/notifications/email/http.go create mode 100644 backend/notifications/email/resend.go create mode 100644 backend/notifications/email/sendgrid.go create mode 100644 backend/notifications/go.mod create mode 100644 backend/notifications/go.sum create mode 100644 backend/notifications/interface/README.md create mode 100644 backend/notifications/interface/go.mod create mode 100644 backend/notifications/interface/go.sum create mode 100644 backend/notifications/interface/proto/v1/interface.proto create mode 100644 backend/notifications/main.go create mode 100644 backend/notifications/model/model.go create mode 100644 backend/notifications/service/integration_test.go create mode 100644 backend/notifications/service/service.go create mode 100644 backend/notifications/service/worker.go create mode 100644 backend/notifications/service/worker_test.go create mode 100644 backend/notifications/store/mongo.go create mode 100644 backend/notifications/templates.example.json create mode 100644 backend/notifications/templates/catalog.go create mode 100644 backend/notifications/templates/catalog_test.go create mode 100644 backend/notifications/transport/mapper.go create mode 100644 backend/notifications/transport/server.go create mode 100644 backend/notifications/transport/server_test.go create mode 100644 backend/shared/messaging/capability/errors.go create mode 100644 backend/shared/messaging/config.go create mode 100644 backend/shared/messaging/internal/natsb/broker.go create mode 100644 backend/shared/messaging/internal/natsb/broker_test.go create mode 100644 backend/shared/messaging/internal/natsb/config/config.go create mode 100644 backend/shared/messaging/internal/natscap/adapter.go create mode 100644 backend/shared/messaging/internal/natscap/admin.go create mode 100644 backend/shared/messaging/internal/natscap/subscription.go create mode 100644 backend/shared/messaging/internal/natscap/subscription_test.go create mode 100644 backend/shared/messaging/reliable/durable_settings.go create mode 100644 backend/shared/messaging/reliable/durable_settings_behavior.go create mode 100644 backend/shared/messaging/reliable/transport.go create mode 100644 backend/shared/messaging/reliable/transport_test.go create mode 100644 backend/shared/messaging/runtime/runtime.go diff --git a/.gitignore b/.gitignore index 6534200..e999375 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ pnpm-debug.log* # Local BFF configuration and build output /backend/config.yml /backend/bff + +# Protobuf client bindings are generated, never committed. +/backend/notifications/interface/notifications/**/*.pb.go +/backend/notifications/config.yml +/backend/notifications/notifications diff --git a/Makefile b/Makefile index 8c23427..c1327e3 100644 --- a/Makefile +++ b/Makefile @@ -15,15 +15,36 @@ test-frontend: .PHONY: test-backend lint-backend run-backend -test-backend: +test-backend: generate-notifications cd backend/shared && go test -race ./... + cd backend/notifications/interface && go test -race ./... + cd backend/notifications && go test -race ./... cd backend/product && go test -race ./... -lint-backend: +lint-backend: generate-notifications cd backend/product && go test ./architecture cd backend && test -z "$$(gofmt -l .)" cd backend/shared && go vet ./... + cd backend/notifications/interface && go vet ./... + cd backend/notifications && go vet ./... cd backend/product && go vet ./... run-backend: cd backend/product && go run . + +.PHONY: run-notifications generate-notifications +run-notifications: generate-notifications + cd backend/notifications && go run . + +generate-notifications: + @command -v protoc >/dev/null + @tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ + GOBIN="$$tmp" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 && \ + GOBIN="$$tmp" go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 && \ + PATH="$$tmp:$$PATH" protoc -I backend/notifications/interface/proto --go_out=backend/notifications/interface --go_opt=module=git.sendico.io/tech/sxchnge/notifications/interface --go-grpc_out=backend/notifications/interface --go-grpc_opt=module=git.sendico.io/tech/sxchnge/notifications/interface backend/notifications/interface/proto/v1/interface.proto + +.PHONY: package-notifications-interface +package-notifications-interface: generate-notifications + mkdir -p dist + 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 diff --git a/backend/README.md b/backend/README.md index 2bb03f7..5c45998 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,6 +12,8 @@ backend/ │ ├── auth/ Login/protected HTTP routes, DTOs and configuration │ ├── sessions/ Session models, refresh storage, cookies and tokens │ └── verification/ Verification models, configuration and storage +├── notifications/ Internal gRPC notification service and email workers +│ └── interface/ Independent protobuf/client contract module └── shared/ Reusable library module ├── go.mod ├── database/ Connection, transactions and repository primitives @@ -27,9 +29,11 @@ not implemented by this customer-BFF bootstrap. Shared infrastructure has no local `replace` for `../shared`; each module has its own dependency manifest. Run the product with `make run-backend`; see its [run guide](product/README.md). -`make test` runs both backend modules and the frontend. `make lint-backend` checks -both Go modules and their architecture boundaries. +`make test` runs all backend modules and the frontend. `make lint-backend` checks +all Go modules and their architecture boundaries. Copied source remains locally owned. See [provenance](PROVENANCE.md), the [architecture/API plan](../docs/architecture/bff.md) and the [delivery record](../docs/implementation.md). + +Notifications uses shared database and messaging infrastructure. See its [run guide](notifications/README.md) and [protobuf interface](notifications/interface/proto/v1/interface.proto). Generated client bindings are ignored; Make generates them before backend checks. diff --git a/backend/notifications/PROVENANCE.md b/backend/notifications/PROVENANCE.md new file mode 100644 index 0000000..b8f4902 --- /dev/null +++ b/backend/notifications/PROVENANCE.md @@ -0,0 +1,26 @@ +# Source provenance + +SendGrid driver source adapted from the local Sendico checkout at commit +`8bb20dcd622dd8eb92d289b6b34411e7eccab083`: +`api/notification/internal/server/notificationimp/mail/internal/sendgrid.go`. + +The adaptation retains SendGrid SDK message construction, sender configuration +and personalization. It implements our provider-neutral `email.Sender` contract, +accepts already-rendered subject/text/HTML, uses bounded context-aware HTTP, +classifies delivery outcomes and never logs provider response bodies. Sendico's +builders, dynamic-template dependency, notification events and unused producer +were removed. Resend is a new driver using the same contract. + +Shared messaging was selected from that same Sendico revision's +`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`, +`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`, +`internal/natsb/broker.go`, `internal/natsb/config/config.go`, and +`internal/natscap/durable.go`. The selected code lives in +`backend/shared/messaging`, with imports adapted to SXCHNGE. The runtime composes +those adapters and owns connection closure. Unused discovery, best-effort envelope +subscriptions, platform stream registries and parking provisioning were omitted. +The durable adapter was separated into transport, subscription and admin files; +publishing now honors its context and fetch polling respects its bounded wait. + +No Sendico module or live Sendico infrastructure is required. Use SXCHNGE-owned +MongoDB and NATS deployments. diff --git a/backend/notifications/README.md b/backend/notifications/README.md new file mode 100644 index 0000000..5aed36d --- /dev/null +++ b/backend/notifications/README.md @@ -0,0 +1,88 @@ +# Notifications backend + +Independent internal gRPC service with durable asynchronous email delivery. +The public contract belongs to [interface/proto/v1/interface.proto](interface/proto/v1/interface.proto). +Generated clients are build artifacts, not committed source. + +## Architecture + +The gRPC transport maps protobuf DTOs to domain requests. The service validates +and renders immutable template revisions, then saves a notification and its +pending-work state atomically in one document through the shared DB wrapper. +The outbox dispatcher publishes opaque IDs using shared messaging's durable +transport, adapted from Sendico. A durable NATS worker claims the notification +with an atomic conditional patch and saves the outcome before acknowledging. +`DriverConfig` selects the email driver: `sendgrid` or `resend`. + +Email adapters only map rendered email models to provider wire payloads. SendGrid +uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support +subject, text and HTML, bounded timeouts and safe error classification. + +## Run + +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. +Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for +each configured caller, and the selected email provider's API key environment +variable. Configure a verified sender address, TLS certificate/key and template +catalog. Paths are relative to the process working directory. + +From the root, run `make run-notifications`. The gRPC listener always uses TLS. +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 +`DriverConfig` settings; email settings reject unknown keys. + +The service provisions `SXCHNGE_NOTIFICATIONS` (file storage, 48-hour retention, +one replica), subject `sxchnge.notifications.email`, and durable consumer `email`. +This initial setup is for a single-replica deployment. Configure production +replication/provisioning policy in code before deploying to a clustered environment. +Multiple service workers can safely compete for jobs through atomic claims. + +## Templates and parameters + +See `templates.example.json`. Parameters use Go template map syntax such as +`{{.first_name}}`. Each revision declares the exact allowed parameter names and +locale-specific content. Missing/extra parameters, unknown revisions/locales and +invalid content are rejected before acceptance. Empty locale uses the default. +Values are data, not executable templates; HTML is escaped by `html/template`. +The catalog is loaded and compiled at startup. Never edit a published revision; +add a new version. Rendered payloads are persisted so retries do not depend on a +later catalog change. Recipient, body and subject are cleared on terminal state; +remaining status and request fingerprints expire through a seven-day TTL. + +## Delivery semantics + +- Send persists before returning; it does not wait for NATS or the email provider. +- Outbox scans every five seconds and republishes safely after a bus failure. +- Duplicate RPCs and duplicate broker deliveries do not create extra jobs/sends. +- Provider HTTP 2xx becomes ACCEPTED. No receipt/webhook or read tracking is claimed. +- Explicit rate limiting (429) retries with bounded exponential backoff and + Retry-After, at most five provider attempts, bounded by message expiry. +- Transport errors, 408, 5xx and expired worker leases become UNKNOWN. These may + have sent the email; operators must reconcile before requesting another send. +- Other provider failures are terminal. `provider_authentication_failed` signals + credentials/permissions, `provider_rejected` a nonretryable rejection, + `retry_exhausted` the attempt limit, and `expired` the message deadline. +- Resend receives the notification ID as its provider idempotency key. Its window + is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email + delivery is not promised. +- Changing the configured driver fails queued jobs pinned to the old driver with + `driver_changed`. Drain queued jobs before changing sender credentials/config. +- Callers authorize recipients and preferences. No direct customer-database access, + bulk campaigns, provider failover, SMS, push or Telegram delivery is included. + +Structured logs report notification IDs, driver, state and attempt count; message +content, template values, addresses and provider response bodies are excluded. +Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings. + +## Validate and distribute + +`make test` and `make lint-backend` generate interfaces and validate all modules. +Notifications tests exercise real disposable MongoDB and NATS containers, concurrent +idempotency and claims, lease recovery, retries, transport authentication, rendering +and both provider payloads through local HTTP servers. No live emails are sent. +`make package-notifications-interface` prepares source and Go-client archives. +See [provenance](PROVENANCE.md) and [contract usage](interface/README.md). + +Provider references: [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send), +[Resend Send Email](https://resend.com/docs/api-reference/emails/send-email). diff --git a/backend/notifications/app/config.go b/backend/notifications/app/config.go new file mode 100644 index 0000000..134db44 --- /dev/null +++ b/backend/notifications/app/config.go @@ -0,0 +1,53 @@ +package app + +import ( + "bytes" + "io" + "os" + "strings" + + "git.sendico.io/tech/sxchnge/notifications/email" + db "git.sendico.io/tech/sxchnge/shared/database" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/messaging" + "gopkg.in/yaml.v3" +) + +type Config struct { + Messaging messaging.Config `yaml:"messaging"` + Listen string `yaml:"listen"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + Database db.Config `yaml:"database"` + Templates string `yaml:"templates"` + Callers map[string]string `yaml:"callers"` // caller -> token environment variable + Email email.Config `yaml:"email"` +} + +func Load(path string) (Config, error) { + var c Config + data, err := os.ReadFile(path) + if err != nil { + return c, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot read configuration") + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err = decoder.Decode(&c); err != nil { + return c, merrors.InvalidArgument("invalid configuration") + } + if err = decoder.Decode(new(any)); err != io.EOF { + 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 { + return c, merrors.InvalidArgument("missing notification configuration") + } + 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 +} diff --git a/backend/notifications/app/config_test.go b/backend/notifications/app/config_test.go new file mode 100644 index 0000000..6a424be --- /dev/null +++ b/backend/notifications/app/config_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExampleConfigAndSecrets(t *testing.T) { + t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32)) + c, err := Load("../config.example.yml") + if err != nil { + t.Fatal(err) + } + if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" { + 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") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.yml") + if err = os.WriteFile(path, append(data, []byte("\n---\nunexpected: document\n")...), 0600); err != nil { + t.Fatal(err) + } + if _, err = Load(path); err == nil { + t.Fatal("extra YAML document accepted") + } +} diff --git a/backend/notifications/app/run.go b/backend/notifications/app/run.go new file mode 100644 index 0000000..a883c74 --- /dev/null +++ b/backend/notifications/app/run.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "crypto/tls" + "net" + "os" + "sync" + "time" + + "git.sendico.io/tech/sxchnge/notifications/bus" + "git.sendico.io/tech/sxchnge/notifications/email" + pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" + "git.sendico.io/tech/sxchnge/notifications/service" + "git.sendico.io/tech/sxchnge/notifications/store" + "git.sendico.io/tech/sxchnge/notifications/templates" + "git.sendico.io/tech/sxchnge/notifications/transport" + db "git.sendico.io/tech/sxchnge/shared/database" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func Run(ctx context.Context, c Config, logger mlogger.Logger) error { + catalog, err := templates.Load(c.Templates) + if err != nil { + return err + } + sender, err := email.New(c.Email) + if err != nil { + return err + } + cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) + if err != nil { + return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate") + } + client, err := db.ConnectMongo(logger, &c.Database) + if err != nil { + return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "connect notifications database") + } + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = client.Disconnect(cleanup) + }() + startup, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + repo, err := store.New(logger, client.Database()) + if err != nil { + return err + } + rt, err := msgruntime.New(logger, &c.Messaging) + if err != nil { + return err + } + defer rt.Close() + svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger) + broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger) + if err != nil { + return err + } + defer broker.Close() + tokens := map[string]string{} + 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)) + listener, err := net.Listen("tcp", c.Listen) + if err != nil { + return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "listen for notifications") + } + defer listener.Close() + runCtx, stop := context.WithCancel(ctx) + defer stop() + var wg sync.WaitGroup + for _, run := range []func(context.Context){broker.RunPublisher, broker.RunWorker} { + wg.Add(1) + go func() { defer wg.Done(); run(runCtx) }() + } + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + logger.Info("Notifications service started", zap.String("listen", c.Listen), zap.String("email_driver", string(c.Email.Driver))) + select { + case <-ctx.Done(): + case err = <-serveErr: + } + stop() + stopped := make(chan struct{}) + go func() { server.GracefulStop(); close(stopped) }() + select { + case <-stopped: + case <-time.After(5 * time.Second): + server.Stop() + } + wg.Wait() + if err != nil { + return merrors.OpaqueWrap(merrors.ErrInternal, err, "notification server stopped unexpectedly") + } + logger.Info("Notifications service stopped") + return nil +} diff --git a/backend/notifications/bus/dispatch.go b/backend/notifications/bus/dispatch.go new file mode 100644 index 0000000..93b57b3 --- /dev/null +++ b/backend/notifications/bus/dispatch.go @@ -0,0 +1,108 @@ +// Package bus connects the notification outbox to the shared messaging runtime. +package bus + +import ( + "context" + "strconv" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + "git.sendico.io/tech/sxchnge/shared/messaging/reliable" +) + +const subject = "sxchnge.notifications.email" + +type Outbox interface { + Due(context.Context, time.Time) ([]model.Notification, error) + Recover(context.Context, time.Time) error +} +type Handler interface { + Dispatch(context.Context, string) (bool, error) +} +type Bus struct { + transport reliable.Transport + subscription reliable.Subscription + outbox Outbox + handler Handler + logger mlogger.Logger +} + +func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin, outbox Outbox, handler Handler, logger mlogger.Logger) (*Bus, error) { + settings := reliable.DurableSettings{Enabled: true, Stream: reliable.DurableStreamSettings{Name: "SXCHNGE_NOTIFICATIONS", Subjects: []string{subject}, Storage: "file", MaxAgeHours: 48, Replicas: 1}, Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningApp, Environment: reliable.DurableEnvironmentNonProduction}} + consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll} + if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging") + } + subscription, err := transport.OpenConsumer(ctx, settings, consumer) + if err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker") + } + return &Bus{transport, subscription, outbox, handler, mlogger.Named(logger, "notifications", "bus")}, nil +} +func (b *Bus) Close() error { return b.subscription.Close() } +func (b *Bus) PublishDue(ctx context.Context) error { + if err := b.outbox.Recover(ctx, time.Now().UTC()); err != nil { + return err + } + jobs, err := b.outbox.Due(ctx, time.Now().UTC()) + if err != nil { + return err + } + for _, job := range jobs { + err = b.transport.Publish(ctx, reliable.Record{Subject: subject, Payload: []byte(job.ID.Hex()), MessageID: job.ID.Hex() + "-" + strconv.FormatUint(uint64(job.Attempts), 10)}) + if err != nil { + return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "publish notification job") + } + } + return nil +} +func (b *Bus) RunPublisher(ctx context.Context) { + for ctx.Err() == nil { + op, cancel := context.WithTimeout(ctx, 10*time.Second) + err := b.PublishDue(op) + cancel() + if err != nil && ctx.Err() == nil { + b.logger.Warn("Notification outbox publication failed") + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } +} +func (b *Bus) RunWorker(ctx context.Context) { + for ctx.Err() == nil { + deliveries, err := b.subscription.Fetch(ctx, 1, time.Second) + if err != nil { + if ctx.Err() == nil { + b.logger.Warn("Notification job fetch failed") + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + continue + } + for _, delivery := range deliveries { + payload := delivery.Record().Payload + if len(payload) == 24 { + if _, err = b.handler.Dispatch(ctx, string(payload)); err != nil { + b.logger.Warn("Notification job processing failed") + continue + } + } else { + b.logger.Warn("Malformed notification job discarded") + } + ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + err = delivery.Ack(ackCtx) + cancel() + if err != nil { + b.logger.Warn("Notification acknowledgement failed") + } + } + } +} diff --git a/backend/notifications/bus/dispatch_test.go b/backend/notifications/bus/dispatch_test.go new file mode 100644 index 0000000..418b2fc --- /dev/null +++ b/backend/notifications/bus/dispatch_test.go @@ -0,0 +1,55 @@ +package bus + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" + "git.sendico.io/tech/sxchnge/shared/database/record" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/messaging/reliable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type outbox struct{ jobs []model.Notification } + +func (o *outbox) Due(context.Context, time.Time) ([]model.Notification, error) { return o.jobs, nil } +func (o *outbox) Recover(context.Context, time.Time) error { return nil } + +type publisher struct { + reliable.Transport + fail bool + records []reliable.Record +} + +func (p *publisher) Publish(_ context.Context, r reliable.Record) error { + if p.fail { + return merrors.ServiceUnavailable("offline") + } + p.records = append(p.records, r) + return nil +} +func TestOutboxSurvivesPublishFailure(t *testing.T) { + id := bson.NewObjectID() + out := &outbox{jobs: []model.Notification{{Base: storable.Base{ID: id}, Email: model.Email{Address: "private@example.com", Text: "private"}}}} + transport := &publisher{fail: true} + b := &Bus{outbox: out, transport: transport} + if err := b.PublishDue(context.Background()); err == nil { + t.Fatal("publication failure hidden") + } + transport.fail = false + if err := b.PublishDue(context.Background()); err != nil { + t.Fatal(err) + } + if len(transport.records) != 1 || string(transport.records[0].Payload) != id.Hex() || transport.records[0].MessageID != id.Hex()+"-0" { + t.Fatal(transport.records) + } + out.jobs[0].Attempts = 1 + if err := b.PublishDue(context.Background()); err != nil { + t.Fatal(err) + } + if transport.records[1].MessageID == transport.records[0].MessageID { + t.Fatal("retry shares transport deduplication identity") + } +} diff --git a/backend/notifications/config.example.yml b/backend/notifications/config.example.yml new file mode 100644 index 0000000..f45a22c --- /dev/null +++ b/backend/notifications/config.example.yml @@ -0,0 +1,24 @@ +listen: "127.0.0.1:8083" +tls_cert: "server.crt" +tls_key: "server.key" +database: + driver: mongodb + settings: + uri_env: SXCHNGE_MONGO_URI + database: sxchnge_notifications +messaging: + driver: NATS + settings: + url_env: SXCHNGE_NATS_URL + broker_name: sxchnge-notifications + max_reconnects: -1 + reconnect_wait: 2 +templates: "templates.example.json" +callers: + product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" +email: + driver: resend # sendgrid or resend + settings: + api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid + sender_address: "notifications@example.com" + sender_name: "SXCHNGE" diff --git a/backend/notifications/email/driver.go b/backend/notifications/email/driver.go new file mode 100644 index 0000000..a9e1f65 --- /dev/null +++ b/backend/notifications/email/driver.go @@ -0,0 +1,79 @@ +package email + +import ( + "context" + "net/http" + "net/mail" + "os" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" + "github.com/go-viper/mapstructure/v2" +) + +type Driver string + +const ( + SendGrid Driver = "sendgrid" + Resend Driver = "resend" +) + +type Config = sharedmodel.DriverConfig[Driver] +type Settings struct { + APIKeyEnv string `mapstructure:"api_key_env"` + SenderAddress string `mapstructure:"sender_address"` + SenderName string `mapstructure:"sender_name"` +} + +// Outcome describes provider acceptance, never recipient delivery or reading. +type Outcome struct { + State model.State + Code string + RetryAfter time.Duration +} +type Sender interface { + Send(context.Context, string, model.Email) Outcome +} + +type client struct { + http *http.Client + key string + settings Settings + endpoint string +} + +func New(config Config) (Sender, error) { + var s Settings + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"}) + if err != nil { + return nil, merrors.InternalWrap(err, "create driver settings decoder") + } + if err = decoder.Decode(config.Settings); err != nil { + return nil, merrors.InvalidArgument("invalid email driver settings") + } + if !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") { + return nil, merrors.InvalidArgument("invalid sender") + } + key := os.Getenv(s.APIKeyEnv) + if s.APIKeyEnv == "" || strings.TrimSpace(key) == "" || strings.ContainsAny(key, "\r\n") { + return nil, merrors.FailedPrecondition("email API key environment variable is missing") + } + c := client{http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, key: key, settings: s} + switch config.Driver { + case SendGrid: + c.endpoint = "https://api.sendgrid.com/v3/mail/send" + return &sendgrid{c}, nil + case Resend: + c.endpoint = "https://api.resend.com/emails" + return &resend{c}, nil + default: + return nil, merrors.InvalidArgument("unsupported email driver") + } +} +func ValidAddress(s string) bool { + a, err := mail.ParseAddress(s) + return err == nil && a.Address == s && !strings.ContainsAny(s, "\r\n") && len(s) <= 254 +} diff --git a/backend/notifications/email/driver_test.go b/backend/notifications/email/driver_test.go new file mode 100644 index 0000000..b937989 --- /dev/null +++ b/backend/notifications/email/driver_test.go @@ -0,0 +1,105 @@ +package email + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" +) + +func TestDriversRenderWirePayload(t *testing.T) { + for _, driver := range []Driver{SendGrid, Resend} { + t.Run(string(driver), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-secret" { + t.Error("missing authorization") + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["subject"] != "Completed" { + t.Errorf("subject: %v", body) + } + if driver == SendGrid { + if body["template_id"] != nil { + t.Error("provider-managed templates must not be used") + } + p := body["personalizations"].([]any)[0].(map[string]any) + if p["to"].([]any)[0].(map[string]any)["email"] != "user@example.com" { + t.Error("wrong recipient") + } + content := body["content"].([]any) + if len(content) != 2 || content[1].(map[string]any)["value"] != "

A & B

" { + t.Error("wrong body") + } + } else { + if r.Header.Get("Idempotency-Key") != "delivery-id" { + t.Error("missing idempotency key") + } + if body["html"] != "

A & B

" || body["text"] != "A & B" { + t.Error("wrong body") + } + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + c := client{http: server.Client(), key: "test-secret", endpoint: server.URL, settings: Settings{SenderAddress: "sender@example.com", SenderName: "SXCHNGE"}} + var sender Sender = &sendgrid{c} + if driver == Resend { + sender = &resend{c} + } + outcome := sender.Send(context.Background(), "delivery-id", model.Email{Address: "user@example.com", Subject: "Completed", HTML: "

A & B

", Text: "A & B"}) + if outcome.State != model.Accepted { + t.Fatal(outcome) + } + }) + } +} +func TestProviderFailures(t *testing.T) { + for _, tc := range []struct { + code int + state model.State + }{{429, model.RetryWait}, {400, model.Failed}, {401, model.Failed}, {403, model.Failed}, {500, model.Unknown}, {408, model.Unknown}, {302, model.Failed}} { + t.Run(http.StatusText(tc.code), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "120") + w.WriteHeader(tc.code) + _, _ = w.Write([]byte("secret provider response")) + })) + defer server.Close() + c := client{http: server.Client(), endpoint: server.URL} + out := c.post(context.Background(), "", struct{}{}) + if out.State != tc.state || strings.Contains(out.Code, "secret") { + t.Fatal(out) + } + if tc.code == 429 && out.RetryAfter != 2*time.Minute { + t.Fatal(out) + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c := client{http: http.DefaultClient, endpoint: "http://127.0.0.1:1"} + if out := c.post(ctx, "", struct{}{}); out.State != model.Unknown { + t.Fatal(out) + } +} +func TestDriverConfig(t *testing.T) { + t.Setenv("EMAIL_TEST_KEY", "secret") + for _, driver := range []Driver{SendGrid, Resend} { + if _, err := New(Config{Driver: driver, Settings: map[string]any{"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com"}}); err != nil { + t.Fatal(err) + } + } + for _, settings := range []map[string]any{{"api_key_env": "MISSING_KEY", "sender_address": "sender@example.com"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "bad"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com", "typo": true}} { + if _, err := New(Config{Driver: Resend, Settings: settings}); err == nil { + t.Fatal("accepted invalid driver config") + } + } +} diff --git a/backend/notifications/email/http.go b/backend/notifications/email/http.go new file mode 100644 index 0000000..ddeca31 --- /dev/null +++ b/backend/notifications/email/http.go @@ -0,0 +1,54 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" +) + +func (c *client) post(ctx context.Context, key string, payload any) Outcome { + body, err := json.Marshal(payload) + if err != nil { + return Outcome{State: model.Failed, Code: "invalid_payload"} + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return Outcome{State: model.Failed, Code: "invalid_provider_config"} + } + req.Header.Set("Authorization", "Bearer "+c.key) + req.Header.Set("Content-Type", "application/json") + if key != "" { + req.Header.Set("Idempotency-Key", key) + } + resp, err := c.http.Do(req) + if err != nil { + return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"} + } + defer resp.Body.Close() + // Discard a bounded response without logging provider-controlled content. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return Outcome{State: model.Accepted} + case resp.StatusCode == 429: + delay := time.Minute + if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 { + delay = time.Duration(n) * time.Second + } else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 { + delay = time.Until(date) + } + return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay} + case resp.StatusCode >= 500 || resp.StatusCode == 408: + return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"} + case resp.StatusCode == 401 || resp.StatusCode == 403: + return Outcome{State: model.Failed, Code: "provider_authentication_failed"} + default: + return Outcome{State: model.Failed, Code: "provider_rejected"} + } +} diff --git a/backend/notifications/email/resend.go b/backend/notifications/email/resend.go new file mode 100644 index 0000000..808ef86 --- /dev/null +++ b/backend/notifications/email/resend.go @@ -0,0 +1,22 @@ +package email + +import ( + "context" + "net/mail" + + "git.sendico.io/tech/sxchnge/notifications/model" +) + +type resend struct{ client } +type resendRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html,omitempty"` + Text string `json:"text,omitempty"` +} + +func (s *resend) Send(ctx context.Context, key string, email model.Email) Outcome { + from := (&mail.Address{Name: s.settings.SenderName, Address: s.settings.SenderAddress}).String() + return s.post(ctx, key, resendRequest{From: from, To: []string{email.Address}, Subject: email.Subject, HTML: email.HTML, Text: email.Text}) +} diff --git a/backend/notifications/email/sendgrid.go b/backend/notifications/email/sendgrid.go new file mode 100644 index 0000000..facb196 --- /dev/null +++ b/backend/notifications/email/sendgrid.go @@ -0,0 +1,28 @@ +package email + +// Adapted from Sendico's SendGrid notifier (see PROVENANCE.md). The SDK mail +// construction is retained; templates, retries and logging belong to our service. +import ( + "context" + + "git.sendico.io/tech/sxchnge/notifications/model" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type sendgrid struct{ client } + +func (s *sendgrid) Send(ctx context.Context, _ string, email model.Email) Outcome { + m := mail.NewV3Mail() + m.SetFrom(mail.NewEmail(s.settings.SenderName, s.settings.SenderAddress)) + m.Subject = email.Subject + p := mail.NewPersonalization() + p.AddTos(mail.NewEmail("", email.Address)) + m.AddPersonalizations(p) + if email.Text != "" { + m.AddContent(mail.NewContent("text/plain", email.Text)) + } + if email.HTML != "" { + m.AddContent(mail.NewContent("text/html", email.HTML)) + } + return s.post(ctx, "", m) +} diff --git a/backend/notifications/go.mod b/backend/notifications/go.mod new file mode 100644 index 0000000..05d9f84 --- /dev/null +++ b/backend/notifications/go.mod @@ -0,0 +1,84 @@ +module git.sendico.io/tech/sxchnge/notifications + +go 1.27.0 + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/nats-io/nats.go v1.53.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect +) + +replace git.sendico.io/tech/sxchnge/shared => ../shared + +require ( + git.sendico.io/tech/sxchnge/notifications/interface v0.0.0 + git.sendico.io/tech/sxchnge/shared v0.0.0 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/sendgrid/sendgrid-go v3.16.1+incompatible + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 + go.mongodb.org/mongo-driver/v2 v2.9.0 + go.uber.org/zap v1.28.0 + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 + gopkg.in/yaml.v3 v3.0.1 +) + +replace git.sendico.io/tech/sxchnge/notifications/interface => ./interface diff --git a/backend/notifications/go.sum b/backend/notifications/go.sum new file mode 100644 index 0000000..fb3bc71 --- /dev/null +++ b/backend/notifications/go.sum @@ -0,0 +1,203 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo= +github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0= +go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/notifications/interface/README.md b/backend/notifications/interface/README.md new file mode 100644 index 0000000..52d2de9 --- /dev/null +++ b/backend/notifications/interface/README.md @@ -0,0 +1,41 @@ +# Notifications service contract + +This independent module owns the public internal-service contract. The canonical +source is `proto/v1/interface.proto`; it defines `Send`, `Get`, requests, statuses +and email/SMS/push/Telegram targets. The executable currently implements email. + +From the repository root, `make generate-notifications` produces Go/gRPC client +bindings in `notifications/v1`. Generated `.pb.go` files are ignored and must not +be committed. Both backend validation targets generate bindings before compiling. +The generators are pinned in the Makefile; install `protoc` separately. + +`make package-notifications-interface` creates a distributable source archive and +a standalone Go client-module archive under `dist/`. Client consumers can generate +bindings for their own language from the source archive. The Go archive contains +the generated bindings and its own go.mod/go.sum, with no executable or shared +infrastructure dependency. Archive generation does not upload to a registry. + +Internal Go consumers use the module +`git.sendico.io/tech/sxchnge/notifications/interface` and import +`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this +checkout, use a local replace to this directory and generate before building. + +Calls require TLS and `authorization: Bearer ` metadata. The service +maps tokens to configured caller identities. Callers authorize user/destination +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 +is validated before persistence. An identical idempotency key/request returns the +original resource, even after completion or expiry. A changed request conflicts. +Keys are scoped to the authenticated caller and retained at least seven days; +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. + +ACCEPTED means provider acceptance, not delivery or reading. UNKNOWN means the +provider may have sent the message and automatic replay is unsafe. DELIVERED is +reserved for a future receipt implementation. Get never exposes recipient addresses, +rendered content, parameters, provider credentials or raw provider responses. + +Keep published template revisions and field numbers stable. Reserve removed +protobuf field/enum names and numbers. Compatible additions belong in v1; +incompatible changes require a new version. diff --git a/backend/notifications/interface/go.mod b/backend/notifications/interface/go.mod new file mode 100644 index 0000000..5b55da1 --- /dev/null +++ b/backend/notifications/interface/go.mod @@ -0,0 +1,15 @@ +module git.sendico.io/tech/sxchnge/notifications/interface + +go 1.27.0 + +require ( + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 +) + +require ( + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect +) diff --git a/backend/notifications/interface/go.sum b/backend/notifications/interface/go.sum new file mode 100644 index 0000000..1371e12 --- /dev/null +++ b/backend/notifications/interface/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/backend/notifications/interface/proto/v1/interface.proto b/backend/notifications/interface/proto/v1/interface.proto new file mode 100644 index 0000000..e8821de --- /dev/null +++ b/backend/notifications/interface/proto/v1/interface.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; +package sxchnge.notifications.v1; +option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1"; +import "google/protobuf/timestamp.proto"; + +// Internal service: authenticate callers over TLS. Send durably queues work. +service NotificationsService { + rpc Send(SendRequest) returns (SendResponse); + rpc Get(GetRequest) returns (GetResponse); +} +message SendRequest { + string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days. + string user_id = 2; // Caller resolves and authorizes recipient ownership. + string template_id = 3; + string template_version = 4; + map parameters = 5; + string locale = 6; + repeated Target targets = 7; // V1 accepts exactly one email target. + google.protobuf.Timestamp expires_at = 8; // Default/max 24h from acceptance. +} +message Target { + oneof destination { + EmailTarget email = 1; + SmsTarget sms = 2; + PushTarget push = 3; + TelegramTarget telegram = 4; + } +} +message EmailTarget { string address = 1; } +message SmsTarget { string phone_number = 1; } +message PushTarget { string endpoint_id = 1; } +message TelegramTarget { string chat_id = 1; } +message SendResponse { Notification notification = 1; } +message GetRequest { string id = 1; } +message GetResponse { Notification notification = 1; } +message Notification { + string id = 1; + string user_id = 2; + string template_id = 3; + string template_version = 4; + repeated Delivery deliveries = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; + google.protobuf.Timestamp expires_at = 8; +} +message Delivery { + string id = 1; + Channel channel = 2; + DeliveryState state = 3; + uint32 attempt_count = 4; + Failure failure = 5; + google.protobuf.Timestamp next_attempt_at = 6; + google.protobuf.Timestamp updated_at = 7; +} +message Failure { string code = 1; string message = 2; } +enum Channel { + CHANNEL_UNSPECIFIED = 0; + CHANNEL_EMAIL = 1; + CHANNEL_SMS = 2; + CHANNEL_PUSH = 3; + CHANNEL_TELEGRAM = 4; +} +enum DeliveryState { + DELIVERY_STATE_UNSPECIFIED = 0; + DELIVERY_STATE_QUEUED = 1; + DELIVERY_STATE_SENDING = 2; + DELIVERY_STATE_RETRY_WAIT = 3; + DELIVERY_STATE_ACCEPTED = 4; + DELIVERY_STATE_DELIVERED = 5; // Reserved for future verified receipts. + DELIVERY_STATE_FAILED = 6; + DELIVERY_STATE_EXPIRED = 7; + DELIVERY_STATE_UNKNOWN = 8; // May have sent; no automatic replay. +} diff --git a/backend/notifications/main.go b/backend/notifications/main.go new file mode 100644 index 0000000..732365f --- /dev/null +++ b/backend/notifications/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "syscall" + + "git.sendico.io/tech/sxchnge/notifications/app" + "go.uber.org/zap" +) + +func main() { + path := flag.String("config.file", "config.yml", "notification configuration") + flag.Parse() + logger, err := zap.NewProduction() + if err != nil { + os.Exit(1) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + config, err := app.Load(*path) + if err == nil { + err = app.Run(ctx, config, logger) + } + if err != nil { + logger.Error("Notifications service terminated", zap.Error(err)) + _ = logger.Sync() + os.Exit(1) + } + _ = logger.Sync() +} diff --git a/backend/notifications/model/model.go b/backend/notifications/model/model.go new file mode 100644 index 0000000..daf0611 --- /dev/null +++ b/backend/notifications/model/model.go @@ -0,0 +1,58 @@ +package model + +import ( + "git.sendico.io/tech/sxchnge/shared/database/record" + "time" +) + +type State string + +const ( + Queued State = "queued" + Sending State = "sending" + RetryWait State = "retry_wait" + Accepted State = "accepted" + Failed State = "failed" + Expired State = "expired" + Unknown State = "unknown" +) + +type Request struct { + Key string + UserID string + TemplateID string + TemplateVersion string + Parameters map[string]string + Locale string + Address string + ExpiresAt time.Time +} + +// Email is a rendered immutable snapshot, independent of provider wire formats. +type Email struct { + Address string + Subject string + HTML string + Text string +} + +type Notification struct { + storable.Base `bson:",inline"` + Caller string `bson:"caller"` + Key string `bson:"key"` + Fingerprint string `bson:"fingerprint"` + UserID string `bson:"user_id"` + TemplateID string `bson:"template_id"` + TemplateVersion string `bson:"template_version"` + Email Email `bson:"email"` + Driver string `bson:"driver"` + State State `bson:"state"` + Attempts uint32 `bson:"attempts"` + FailureCode string `bson:"failure_code"` + ExpiresAt time.Time `bson:"expires_at"` + NextAttemptAt time.Time `bson:"next_attempt_at"` + LeaseUntil time.Time `bson:"lease_until"` + PurgeAt time.Time `bson:"purge_at"` +} + +func (*Notification) Collection() string { return "notifications" } diff --git a/backend/notifications/service/integration_test.go b/backend/notifications/service/integration_test.go new file mode 100644 index 0000000..402de93 --- /dev/null +++ b/backend/notifications/service/integration_test.go @@ -0,0 +1,209 @@ +package service_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/notifications/bus" + "git.sendico.io/tech/sxchnge/notifications/email" + "git.sendico.io/tech/sxchnge/notifications/model" + "git.sendico.io/tech/sxchnge/notifications/service" + "git.sendico.io/tech/sxchnge/notifications/store" + "git.sendico.io/tech/sxchnge/notifications/templates" + db "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/database/repository" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/messaging" + msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/wait" + "go.uber.org/zap" +) + +type sender struct { + calls atomic.Int32 + state model.State +} + +func (s *sender) Send(context.Context, string, model.Email) email.Outcome { + s.calls.Add(1) + return email.Outcome{State: s.state, Code: "test", RetryAfter: time.Millisecond} +} +func TestDurableDelivery(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + container, err := mongodb.Run(ctx, "mongo:8.0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = container.Terminate(context.Background()) }() + uri, err := container.ConnectionString(ctx) + if err != nil { + t.Fatal(err) + } + conn, err := db.ConnectMongo(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: map[string]any{"uri": uri, "database": "notifications_test"}}) + if err != nil { + t.Fatal(err) + } + defer conn.Disconnect(context.Background()) + repo, err := store.New(zap.NewNop(), conn.Database()) + if err != nil { + t.Fatal(err) + } + catalog, err := templates.New([]templates.Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]templates.Content{"en": {Subject: "Hello", HTML: "

{{.name}}

"}}}}) + if err != nil { + t.Fatal(err) + } + driver := &sender{state: model.Accepted} + svc := service.New(repo, catalog, driver, "resend", zap.NewNop()) + request := model.Request{Key: "duplicate", UserID: "u", TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "A & B"}, Address: "user@example.com"} + var wg sync.WaitGroup + ids := make(chan string, 10) + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + n, e := svc.Send(ctx, "product", request) + if e != nil { + t.Error(e) + return + } + ids <- n.ID.Hex() + }() + } + wg.Wait() + close(ids) + id := "" + for value := range ids { + if id != "" && value != id { + t.Error("idempotency race") + } + id = value + } + if id == "" { + t.Fatal("no notification") + } + request.Parameters = map[string]string{"name": "Changed"} + if _, err = svc.Send(ctx, "product", request); !errors.Is(err, merrors.ErrDataConflict) { + t.Fatal("missing conflict", err) + } + if _, err = svc.Get(ctx, "other", id); !errors.Is(err, merrors.ErrNoData) { + t.Fatal("caller isolation failed", err) + } + stored, err := svc.Get(ctx, "product", id) + if err != nil || stored.Email.HTML != "

A & B

" { + t.Fatal(stored, err) + } + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + if _, e := svc.Dispatch(ctx, id); e != nil { + t.Error(e) + } + }() + } + wg.Wait() + if driver.calls.Load() != 1 { + t.Fatalf("sent %d times", driver.calls.Load()) + } + stored, err = svc.Get(ctx, "product", id) + if err != nil || stored.State != model.Accepted || stored.Email.Address != "" { + t.Fatal("completion/scrubbing failed", stored, err) + } + // A stale sending lease is never automatically resent. + request.Key = "stale" + stale, err := svc.Send(ctx, "product", request) + if err != nil { + t.Fatal(err) + } + old := time.Now().Add(-2 * time.Minute) + if err = repo.DBImp.Patch(ctx, stale.ID, repository.Patch().Set(repository.Field("next_attempt_at"), old)); err != nil { + t.Fatal(err) + } + claimed, err := repo.Claim(ctx, stale.ID.Hex(), old) + if err != nil { + t.Fatal(err) + } + if err = repo.Recover(ctx, time.Now()); err != nil { + t.Fatal(err) + } + claimed.State = model.Accepted + if err = repo.Finish(ctx, claimed); !errors.Is(err, merrors.ErrDataConflict) { + t.Fatal("stale worker overwrote recovery", err) + } + stale, err = svc.Get(ctx, "product", stale.ID.Hex()) + if err != nil || stale.State != model.Unknown { + t.Fatal(stale, err) + } + // Retry state persists, and a duplicate dispatch cannot bypass the due time. + request.Key = "retry" + retry, err := svc.Send(ctx, "product", request) + if err != nil { + t.Fatal(err) + } + retryDriver := &sender{state: model.RetryWait} + retrySvc := service.New(repo, catalog, retryDriver, "resend", zap.NewNop()) + if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil { + t.Fatal(err) + } + if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil { + t.Fatal(err) + } + retry, err = svc.Get(ctx, "product", retry.ID.Hex()) + if err != nil || retry.State != model.RetryWait || retryDriver.calls.Load() != 1 { + t.Fatal(retry, err) + } + // 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}) + if err != nil { + t.Fatal(err) + } + defer ncContainer.Terminate(context.Background()) + endpoint, err := ncContainer.Endpoint(ctx, "") + if err != nil { + t.Fatal(err) + } + t.Setenv("TEST_NOTIFICATIONS_NATS", "nats://"+endpoint) + rt, err := msgruntime.New(zap.NewNop(), &messaging.Config{Driver: messaging.BBNats, Settings: map[string]any{"url_env": "TEST_NOTIFICATIONS_NATS", "broker_name": "test", "max_reconnects": -1, "reconnect_wait": 1}}) + if err != nil { + t.Fatal(err) + } + defer rt.Close() + broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + request.Key = "jetstream" + job, err := svc.Send(ctx, "product", request) + if err != nil { + t.Fatal(err) + } + if err = broker.PublishDue(ctx); err != nil { + t.Fatal(err) + } + workerCtx, stop := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { broker.RunWorker(workerCtx); close(done) }() + defer func() { stop(); <-done }() + deadline := time.Now().Add(10 * time.Second) + for { + job, err = svc.Get(ctx, "product", job.ID.Hex()) + if err != nil { + t.Fatal(err) + } + if job.State == model.Accepted { + break + } + if time.Now().After(deadline) { + t.Fatal("JetStream job did not complete", job.State) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/backend/notifications/service/service.go b/backend/notifications/service/service.go new file mode 100644 index 0000000..f88d710 --- /dev/null +++ b/backend/notifications/service/service.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "git.sendico.io/tech/sxchnge/notifications/email" + "git.sendico.io/tech/sxchnge/notifications/model" + "git.sendico.io/tech/sxchnge/notifications/templates" + "git.sendico.io/tech/sxchnge/shared/database/record" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type Repository interface { + Find(context.Context, string, string) (*model.Notification, error) + Get(context.Context, string, string) (*model.Notification, error) + Create(context.Context, *model.Notification) (*model.Notification, error) + Claim(context.Context, string, time.Time) (*model.Notification, error) + Finish(context.Context, *model.Notification) error + Recover(context.Context, time.Time) error +} +type Service struct { + repo Repository + catalog *templates.Catalog + sender email.Sender + driver string + logger mlogger.Logger +} + +func New(repo Repository, catalog *templates.Catalog, sender email.Sender, driver string, logger mlogger.Logger) *Service { + return &Service{repo, catalog, sender, driver, mlogger.Named(logger, "notifications")} +} +func (s *Service) Send(ctx context.Context, caller string, r model.Request) (*model.Notification, error) { + if caller == "" { + return nil, merrors.Unauthorized("missing caller") + } + if r.Key == "" || len(r.Key) > 128 || r.UserID == "" || len(r.UserID) > 128 || !email.ValidAddress(r.Address) || len(r.TemplateID) > 128 || len(r.TemplateVersion) > 64 || len(r.Locale) > 64 || strings.ContainsRune(r.TemplateID+r.TemplateVersion, 0) { + return nil, merrors.InvalidArgument("invalid notification request") + } + if len(r.Parameters) > 64 { + return nil, merrors.InvalidArgument("too many parameters") + } + for k, v := range r.Parameters { + if k == "" || len(k) > 64 || len(v) > 4096 { + return nil, merrors.InvalidArgument("invalid template parameter") + } + } + payload, err := json.Marshal(r) + if err != nil { + return nil, merrors.InvalidArgument("invalid request") + } + if len(payload) > 64<<10 { + return nil, merrors.InvalidArgument("request too large") + } + sum := sha256.Sum256(payload) + fingerprint := hex.EncodeToString(sum[:]) + existing, err := s.repo.Find(ctx, caller, r.Key) + if err == nil { + return same(existing, fingerprint) + } + if !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + now := time.Now().UTC() + expiry := r.ExpiresAt + if expiry.IsZero() { + expiry = now.Add(24 * time.Hour) + } + if !expiry.After(now) || expiry.After(now.Add(24*time.Hour)) { + return nil, merrors.InvalidArgument("expiry must be within the next 24 hours") + } + rendered, err := s.catalog.Render(r) + if err != nil { + return nil, err + } + n := &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: now, UpdatedAt: now}, Caller: caller, Key: r.Key, Fingerprint: fingerprint, UserID: r.UserID, TemplateID: r.TemplateID, TemplateVersion: r.TemplateVersion, Email: rendered, Driver: s.driver, State: model.Queued, ExpiresAt: expiry, NextAttemptAt: now, PurgeAt: now.Add(7 * 24 * time.Hour)} + stored, err := s.repo.Create(ctx, n) + if err != nil { + return nil, err + } + result, err := same(stored, fingerprint) + if err == nil { + s.logger.Info("Notification accepted", zap.String("notification_id", stored.ID.Hex()), zap.String("caller", caller)) + } + return result, err +} +func same(n *model.Notification, fingerprint string) (*model.Notification, error) { + if n.Fingerprint != fingerprint { + return nil, merrors.DataConflict("idempotency key already used with a different request") + } + return n, nil +} +func (s *Service) Get(ctx context.Context, caller, id string) (*model.Notification, error) { + if caller == "" { + return nil, merrors.Unauthorized("missing caller") + } + if _, err := bson.ObjectIDFromHex(id); err != nil { + return nil, merrors.InvalidArgument("invalid notification ID") + } + return s.repo.Get(ctx, caller, id) +} diff --git a/backend/notifications/service/worker.go b/backend/notifications/service/worker.go new file mode 100644 index 0000000..44fadd9 --- /dev/null +++ b/backend/notifications/service/worker.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "errors" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "go.uber.org/zap" +) + +// Dispatch consumes an opaque job ID from JetStream. Duplicate messages are safe. +func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) { + now := time.Now().UTC() + if err := s.repo.Recover(ctx, now); err != nil { + return false, err + } + n, err := s.repo.Claim(ctx, id, now) + if errors.Is(err, merrors.ErrNoData) { + return false, nil + } + if err != nil { + return false, err + } + switch { + case !n.ExpiresAt.After(now): + n.State = model.Expired + n.FailureCode = "expired" + case n.Driver != s.driver: + n.State = model.Failed + n.FailureCode = "driver_changed" + default: + deadline := now.Add(20 * time.Second) + if n.ExpiresAt.Before(deadline) { + deadline = n.ExpiresAt + } + sendCtx, cancel := context.WithDeadline(ctx, deadline) + outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email) + cancel() + n.State = outcome.State + n.FailureCode = outcome.Code + if n.State == model.RetryWait { + delay := time.Duration(1< delay { + delay = outcome.RetryAfter + } + n.NextAttemptAt = time.Now().UTC().Add(delay) + if n.Attempts >= 5 { + n.State = model.Failed + n.FailureCode = "retry_exhausted" + } else if !n.NextAttemptAt.Before(n.ExpiresAt) { + n.State = model.Expired + n.FailureCode = "expired" + } + } else if n.State != model.Accepted && n.State != model.Failed && n.State != model.Unknown { + n.State = model.Unknown + n.FailureCode = "invalid_driver_outcome" + } + } + n.UpdatedAt = time.Now().UTC() + // Persist the result even when shutdown canceled the provider request. + finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err = s.repo.Finish(finishCtx, n); err != nil { + return true, err + } + fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)} + if n.State == model.Accepted { + s.logger.Info("Email accepted by provider", fields...) + } else { + s.logger.Warn("Email delivery attempt finished", fields...) + } + return true, nil +} diff --git a/backend/notifications/service/worker_test.go b/backend/notifications/service/worker_test.go new file mode 100644 index 0000000..2c8e084 --- /dev/null +++ b/backend/notifications/service/worker_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/notifications/email" + "git.sendico.io/tech/sxchnge/notifications/model" + "go.uber.org/zap" +) + +type workerRepo struct { + Repository + job model.Notification + saved *model.Notification +} + +func (r *workerRepo) Recover(context.Context, time.Time) error { return nil } +func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notification, error) { + n := r.job + return &n, nil +} +func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil } + +type workerSender struct { + calls int + outcome email.Outcome +} + +func (s *workerSender) Send(context.Context, string, model.Email) email.Outcome { + s.calls++ + return s.outcome +} +func TestWorkerTerminalAndRetryOutcomes(t *testing.T) { + for _, tc := range []struct { + name string + expiry time.Time + driver string + attempts uint32 + outcome model.State + want model.State + calls int + }{ + {"expired", time.Now().Add(-time.Minute), "resend", 1, model.Accepted, model.Expired, 0}, + {"driver changed", time.Now().Add(time.Hour), "sendgrid", 1, model.Accepted, model.Failed, 0}, + {"retry exhausted", time.Now().Add(time.Hour), "resend", 5, model.RetryWait, model.Failed, 1}, + {"retry expiry", time.Now().Add(time.Second), "resend", 1, model.RetryWait, model.Expired, 1}, + {"ambiguous", time.Now().Add(time.Hour), "resend", 1, model.Unknown, model.Unknown, 1}, + {"invalid driver outcome", time.Now().Add(time.Hour), "resend", 1, model.Queued, model.Unknown, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &workerRepo{job: model.Notification{Driver: tc.driver, ExpiresAt: tc.expiry, Attempts: tc.attempts}} + sender := &workerSender{outcome: email.Outcome{State: tc.outcome}} + svc := New(repo, nil, sender, "resend", zap.NewNop()) + if _, err := svc.Dispatch(context.Background(), "id"); err != nil { + t.Fatal(err) + } + if repo.saved == nil || repo.saved.State != tc.want || sender.calls != tc.calls { + t.Fatal(repo.saved, sender.calls) + } + }) + } +} diff --git a/backend/notifications/store/mongo.go b/backend/notifications/store/mongo.go new file mode 100644 index 0000000..de86cfd --- /dev/null +++ b/backend/notifications/store/mongo.go @@ -0,0 +1,118 @@ +package store + +import ( + "context" + "errors" + "time" + + "git.sendico.io/tech/sxchnge/notifications/model" + "git.sendico.io/tech/sxchnge/shared/database/repository" + "git.sendico.io/tech/sxchnge/shared/database/repository/builder" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/template" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type Store struct { + template.DBImp[*model.Notification] +} + +func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) { + s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)} + ttl := int32(0) + for _, index := range []*ri.Definition{ + {Name: "unique_caller_key", Keys: []ri.Key{{Field: "caller", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, Unique: true}, + {Name: "pending_dispatch", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "next_attempt_at", Sort: ri.Asc}}}, + {Name: "expired_lease", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "lease_until", Sort: ri.Asc}}}, + {Name: "purge_notifications", Keys: []ri.Key{{Field: "purge_at", Sort: ri.Asc}}, TTL: &ttl}, + } { + if err := s.Repository.CreateIndex(index); err != nil { + return nil, wrap(err) + } + } + return s, nil +} +func wrap(err error) error { + if err == nil { + return nil + } + if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrDataConflict) { + return err + } + return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "notification storage unavailable") +} +func (s *Store) Find(ctx context.Context, caller, key string) (*model.Notification, error) { + var n model.Notification + err := s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.Filter("key", key)), &n) + return &n, wrap(err) +} +func (s *Store) Get(ctx context.Context, caller, id string) (*model.Notification, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, merrors.InvalidArgument("invalid notification ID") + } + var n model.Notification + err = s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.IDFilter(oid)), &n) + return &n, wrap(err) +} +func (s *Store) Create(ctx context.Context, n *model.Notification) (*model.Notification, error) { + err := s.DBImp.Create(ctx, n) + if errors.Is(err, merrors.ErrDataConflict) { + return s.Find(ctx, n.Caller, n.Key) + } + return n, wrap(err) +} +func pending(now time.Time) builder.Query { + return repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now)) +} + +// A lost worker may already have sent its message. Do not automatically replay it. +func (s *Store) Recover(ctx context.Context, now time.Time) error { + query := repository.Query().And(repository.Filter("state", model.Sending), repository.Query().Comparison(repository.Field("lease_until"), builder.Lte, now)) + patch := repository.Patch().Set(repository.Field("state"), model.Unknown).Set(repository.Field("failure_code"), "worker_outcome_unknown").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{}) + _, err := s.PatchMany(ctx, query, patch) + return wrap(err) +} +func (s *Store) Claim(ctx context.Context, id string, now time.Time) (*model.Notification, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, merrors.NoData("invalid job ID") + } + var n model.Notification + query := repository.Query().And(repository.IDFilter(oid), pending(now)) + patch := repository.Patch().Set(repository.Field("state"), model.Sending).Set(repository.Field("updatedAt"), now).Set(repository.Field("lease_until"), now.Add(time.Minute)).Inc(repository.Field("attempts"), 1) + matched, err := s.Repository.PatchOneByFilter(ctx, query, patch, &n) + if err != nil { + return nil, wrap(err) + } + if !matched { + return nil, merrors.NoData("job is not pending") + } + return &n, nil +} +func (s *Store) Finish(ctx context.Context, n *model.Notification) error { + query := repository.Query().And(repository.IDFilter(n.ID), repository.Filter("state", model.Sending), repository.Filter("attempts", n.Attempts), repository.Filter("lease_until", n.LeaseUntil)) + patch := repository.Patch().Set(repository.Field("state"), n.State).Set(repository.Field("failure_code"), n.FailureCode).Set(repository.Field("updatedAt"), n.UpdatedAt).Set(repository.Field("next_attempt_at"), n.NextAttemptAt) + if n.State != model.RetryWait { + patch.Set(repository.Field("email"), model.Email{}) + } + count, err := s.PatchMany(ctx, query, patch) + if err != nil { + return wrap(err) + } + if count != 1 { + return merrors.DataConflict("delivery lease lost") + } + return nil +} + +// Acceptance and outbox work are one document, so no dual-write transaction is needed. +func (s *Store) Due(ctx context.Context, now time.Time) ([]model.Notification, error) { + limit := int64(100) + var jobs []model.Notification + err := s.Repository.FindAllByFilter(ctx, pending(now).Sort(repository.Field("next_attempt_at"), true).Limit(&limit), &jobs) + return jobs, wrap(err) +} diff --git a/backend/notifications/templates.example.json b/backend/notifications/templates.example.json new file mode 100644 index 0000000..0e5922c --- /dev/null +++ b/backend/notifications/templates.example.json @@ -0,0 +1,15 @@ +[ + { + "id": "exchange.completed", + "version": "1", + "default_locale": "en", + "parameters": ["first_name", "amount", "currency"], + "locales": { + "en": { + "subject": "Your exchange is complete", + "html": "

Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete.

", + "text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete." + } + } + } +] diff --git a/backend/notifications/templates/catalog.go b/backend/notifications/templates/catalog.go new file mode 100644 index 0000000..c4ddcf8 --- /dev/null +++ b/backend/notifications/templates/catalog.go @@ -0,0 +1,146 @@ +package templates + +import ( + "bytes" + "encoding/json" + html "html/template" + "io" + "os" + "strings" + text "text/template" + + "git.sendico.io/tech/sxchnge/notifications/model" + merrors "git.sendico.io/tech/sxchnge/shared/errors" +) + +type Content struct { + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` +} +type Definition struct { + ID string `json:"id"` + Version string `json:"version"` + DefaultLocale string `json:"default_locale"` + Parameters []string `json:"parameters"` + Locales map[string]Content `json:"locales"` +} +type compiled struct { + definition Definition + locales map[string]renderers +} +type renderers struct { + subject, text *text.Template + html *html.Template +} +type Catalog struct{ entries map[string]compiled } + +func Load(path string) (*Catalog, error) { + f, err := os.Open(path) + if err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot open template catalog") + } + defer f.Close() + var definitions []Definition + dec := json.NewDecoder(io.LimitReader(f, 4<<20)) + dec.DisallowUnknownFields() + if err = dec.Decode(&definitions); err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid template catalog") + } + if err = dec.Decode(new(any)); err != io.EOF { + return nil, merrors.InvalidArgument("unexpected template catalog content") + } + return New(definitions) +} +func New(definitions []Definition) (*Catalog, error) { + c := &Catalog{entries: map[string]compiled{}} + if len(definitions) == 0 { + return nil, merrors.InvalidArgument("empty template catalog") + } + for _, d := range definitions { + key := d.ID + "\x00" + d.Version + if d.ID == "" || d.Version == "" || strings.ContainsRune(d.ID+d.Version, 0) { + return nil, merrors.InvalidArgument("invalid template identity") + } + if _, ok := c.entries[key]; ok { + return nil, merrors.InvalidArgument("duplicate template revision") + } + if _, ok := d.Locales[d.DefaultLocale]; !ok || d.DefaultLocale == "" { + return nil, merrors.InvalidArgument("missing default locale") + } + params := map[string]string{} + for _, p := range d.Parameters { + if p == "" { + return nil, merrors.InvalidArgument("empty parameter") + } + if _, ok := params[p]; ok { + return nil, merrors.InvalidArgument("duplicate parameter") + } + params[p] = "sample" + } + entry := compiled{definition: d, locales: map[string]renderers{}} + for locale, content := range d.Locales { + s, err := text.New("subject").Option("missingkey=error").Parse(content.Subject) + if err != nil { + return nil, merrors.InvalidArgument("invalid subject template") + } + t, err := text.New("text").Option("missingkey=error").Parse(content.Text) + if err != nil { + return nil, merrors.InvalidArgument("invalid text template") + } + h, err := html.New("html").Option("missingkey=error").Parse(content.HTML) + if err != nil { + return nil, merrors.InvalidArgument("invalid HTML template") + } + entry.locales[locale] = renderers{s, t, h} + } + c.entries[key] = entry + for locale := range d.Locales { + if _, err := c.Render(model.Request{TemplateID: d.ID, TemplateVersion: d.Version, Locale: locale, Parameters: params}); err != nil { + return nil, err + } + } + } + return c, nil +} + +type boundedBuffer struct{ bytes.Buffer } + +func (b *boundedBuffer) Write(p []byte) (int, error) { + if b.Len()+len(p) > 256<<10 { + return 0, merrors.InvalidArgument("rendered content too large") + } + return b.Buffer.Write(p) +} +func (c *Catalog) Render(r model.Request) (model.Email, error) { + entry, ok := c.entries[r.TemplateID+"\x00"+r.TemplateVersion] + if !ok { + return model.Email{}, merrors.NoData("template revision not found") + } + if len(r.Parameters) != len(entry.definition.Parameters) { + return model.Email{}, merrors.InvalidArgument("template parameters do not match") + } + for _, p := range entry.definition.Parameters { + if _, ok := r.Parameters[p]; !ok { + return model.Email{}, merrors.InvalidArgument("missing template parameter") + } + } + locale := r.Locale + if locale == "" { + locale = entry.definition.DefaultLocale + } + render, ok := entry.locales[locale] + if !ok { + return model.Email{}, merrors.InvalidArgument("unsupported template locale") + } + var subject, body, plain boundedBuffer + for _, run := range []func() error{func() error { return render.subject.Execute(&subject, r.Parameters) }, func() error { return render.html.Execute(&body, r.Parameters) }, func() error { return render.text.Execute(&plain, r.Parameters) }} { + if err := run(); err != nil { + return model.Email{}, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "template rendering failed") + } + } + if subject.Len() == 0 || subject.Len() > 998 || strings.ContainsAny(subject.String(), "\r\n") || (body.Len() == 0 && plain.Len() == 0) { + return model.Email{}, merrors.InvalidArgument("invalid rendered email") + } + return model.Email{Address: r.Address, Subject: subject.String(), HTML: body.String(), Text: plain.String()}, nil +} diff --git a/backend/notifications/templates/catalog_test.go b/backend/notifications/templates/catalog_test.go new file mode 100644 index 0000000..9d033b7 --- /dev/null +++ b/backend/notifications/templates/catalog_test.go @@ -0,0 +1,42 @@ +package templates + +import ( + "strings" + "testing" + + "git.sendico.io/tech/sxchnge/notifications/model" +) + +func TestRenderAndValidate(t *testing.T) { + c, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]Content{"en": {Subject: "Hello {{.name}}", HTML: "

{{.name}}

", Text: "{{.name}}"}}}}) + if err != nil { + t.Fatal(err) + } + r := model.Request{TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": ""}} + result, err := c.Render(r) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.HTML, "