Connect sign-up to Sendico Facts intake (#46)
This commit is contained in:
1 parent
30f0455122
commit
a584c8189a
40 files changed
+1616
-35
No files matched your search
@@ -33,9 +33,10 @@ API versions are installed explicitly by the product composition. See
|
||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||
wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
copy only the wire definitions consumed by an implemented adapter.
|
||||
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||
contracts; local customers retain no platform tenancy. See
|
||||
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||
|
||||
## Validate
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package api
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Sendico *sendico.Config `yaml:"sendico"`
|
||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||
Versions []Mount `yaml:"versions"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
|
||||
@@ -2,16 +2,20 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
result = httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||
// Enabling the adapter validates credentials, composes once, and does not
|
||||
// register any provider resource until a customer starts sign-up.
|
||||
const source = "507f1f77bcf86cd799439011"
|
||||
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
|
||||
require.NoError(t, err)
|
||||
file := filepath.Join(t.TempDir(), "credential")
|
||||
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
|
||||
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
|
||||
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dependencies.Auth.Facts)
|
||||
require.NoError(t, finish(ctx))
|
||||
example.API.Sendico.TokenFile = ""
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
example.API.Notifications = nil
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -55,6 +58,18 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Sender = client
|
||||
if config.Sendico != nil {
|
||||
provider, err := sendico.Open(*config.Sendico)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bindings, err := identity.OpenStore(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
|
||||
logger.Info("Sendico sign-up facts integration enabled")
|
||||
}
|
||||
records, err := factorstore.Open(logger, db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -64,6 +79,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if config.Sendico != nil && deps.Auth == nil {
|
||||
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||
}
|
||||
logger.Info("Product dependencies initialized")
|
||||
return deps, cleanup, nil
|
||||
}
|
||||
@@ -26,7 +26,13 @@ import (
|
||||
|
||||
const Cooldown = time.Minute
|
||||
|
||||
type SignupFacts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
Ready(context.Context, bson.ObjectID) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Facts SignupFacts
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
Tokens verification.DB
|
||||
@@ -85,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
||||
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||
if lookup != nil {
|
||||
return nil, lookup
|
||||
}
|
||||
// A retry after a queue failure may resume only with the original password.
|
||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
||||
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||
return nil, merrors.DataConflict("email already registered")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) activationKey(email, key string) string {
|
||||
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
||||
}
|
||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
||||
secretKey := s.activationKey(a.Login, key)
|
||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||
@@ -176,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil, merrors.Unauthorized("account cannot be activated")
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||
})
|
||||
return err
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
||||
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
|
||||
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
|
||||
require.Equal(t, "signup_attention_required", envelope.Data.Error)
|
||||
require.NotContains(t, response.Body.String(), "private upstream detail")
|
||||
}
|
||||
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrFailedPrecondition):
|
||||
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -34,6 +36,38 @@ type capturedMail struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
type signupProvider struct {
|
||||
registrations, submissions int
|
||||
fail bool
|
||||
last identity.Facts
|
||||
}
|
||||
|
||||
func (*signupProvider) Namespace() string { return "test-provider" }
|
||||
func (p *signupProvider) Register(context.Context, string) (string, error) {
|
||||
p.registrations++
|
||||
return "subject", nil
|
||||
}
|
||||
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||
p.submissions++
|
||||
p.last = facts
|
||||
if p.fail {
|
||||
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
|
||||
}
|
||||
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type activationGuard struct {
|
||||
flow.SignupFacts
|
||||
block bool
|
||||
}
|
||||
|
||||
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||
if g.block {
|
||||
return merrors.ServiceUnavailable("intake receipt unavailable")
|
||||
}
|
||||
return g.SignupFacts.Ready(ctx, id)
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
mail := &capturedMail{}
|
||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
require.NoError(t, service.Validate())
|
||||
bindings, err := identity.OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
provider := &signupProvider{fail: true}
|
||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||
// Keep the real verification/customer stores and capture only session writes.
|
||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
@@ -83,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
}
|
||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||
w := post("signup", signup)
|
||||
require.Equal(t, 503, w.Code, w.Body.String())
|
||||
require.Empty(t, mail.messages)
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
|
||||
require.Empty(t, mail.messages)
|
||||
// Only the original password can resume a pending local customer.
|
||||
signup["password"] = "different password"
|
||||
require.Equal(t, 400, post("signup", signup).Code)
|
||||
signup["password"] = " correct password "
|
||||
signup["name"] = "Changed retry name"
|
||||
provider.fail = false
|
||||
w = post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, "Customer", provider.last.FullName)
|
||||
require.Equal(t, "user@example.com", provider.last.Email)
|
||||
require.Len(t, mail.messages, 1)
|
||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||
@@ -95,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||
require.Equal(t, 403, post("login", login).Code)
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||
service.Facts = guard
|
||||
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
guard.block = false
|
||||
w = post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
|
||||
@@ -9,6 +9,13 @@ database:
|
||||
uri_env: SXCHNGE_MONGO_URI
|
||||
database: sxchnge
|
||||
api:
|
||||
# Optional public Sendico facts integration. Enable with provisioned values;
|
||||
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
|
||||
# sendico:
|
||||
# api_url: https://sendico.example/api/v1
|
||||
# organization_ref: <sxchnge-organization-object-id>
|
||||
# source_account_ref: <integration-account-object-id>
|
||||
# token_file: /run/sxchnge/sendico-token
|
||||
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||
# versions:
|
||||
# - version: v1
|
||||
|
||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (string, error) {
|
||||
return s.createAccount(ctx, acct, "")
|
||||
}
|
||||
|
||||
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||
if len(key) < 32 || len(key) > 128 {
|
||||
return "", merrors.InvalidArgument("server activation key required")
|
||||
}
|
||||
return s.createAccount(ctx, acct, key)
|
||||
}
|
||||
|
||||
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||
if acct == nil {
|
||||
return "", merrors.InvalidArgument("account is required")
|
||||
}
|
||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.VerifyAccount(tx, saved)
|
||||
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
|
||||
@@ -36,6 +36,9 @@ type AccountService interface {
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (verificationToken string, err error)
|
||||
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
|
||||
// service replay an unsent initial token. The key must be server-secret-derived.
|
||||
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
|
||||
|
||||
VerifyAccount(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
|
||||
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
|
||||
type Facts struct {
|
||||
FullName string `bson:"fullName"`
|
||||
Email string `bson:"email"`
|
||||
CustomerRef string `bson:"customerRef"`
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
SubmissionRef string `bson:"submissionRef"`
|
||||
SubjectRef string `bson:"subjectRef"`
|
||||
AcceptedAt time.Time `bson:"acceptedAt"`
|
||||
}
|
||||
|
||||
// Binding uses the local customer ID as its primary key. Namespace is an opaque
|
||||
// adapter configuration fingerprint; no platform tenancy enters the customer model.
|
||||
type Binding struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Namespace string `bson:"namespace"`
|
||||
RegistrationRequest string `bson:"registrationRequest"`
|
||||
Facts Facts `bson:"facts"`
|
||||
Registering bool `bson:"registering"`
|
||||
RegisteringAt time.Time `bson:"registeringAt"`
|
||||
SubjectRef string `bson:"subjectRef"`
|
||||
Receipt *Receipt `bson:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
func (*Binding) Collection() string { return "signup_facts" }
|
||||
|
||||
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
|
||||
@@ -0,0 +1,132 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
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 Provider interface {
|
||||
Namespace() string
|
||||
Register(context.Context, string) (string, error)
|
||||
Submit(context.Context, string, string, Facts) (Receipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Provider Provider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
|
||||
|
||||
// Ensure completes only after a provider receipt has been persisted. Registration
|
||||
// has no idempotency contract, so its durable claim is never automatically expired.
|
||||
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
|
||||
if a == nil || a.ID.IsZero() {
|
||||
return merrors.InvalidArgument("customer required")
|
||||
}
|
||||
started := time.Now()
|
||||
requestRef := ""
|
||||
outcome := "unavailable"
|
||||
defer func() {
|
||||
requests.WithLabelValues(outcome).Inc()
|
||||
duration.Observe(time.Since(started).Seconds())
|
||||
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
|
||||
if result != nil {
|
||||
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
|
||||
} else {
|
||||
s.Logger.Info("Sign-up facts handoff completed", fields...)
|
||||
}
|
||||
}()
|
||||
namespace := s.Provider.Namespace()
|
||||
b, err := s.Store.Get(ctx, a.ID)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
|
||||
b.SetID(a.ID)
|
||||
if err = s.Store.Create(ctx, b); err != nil {
|
||||
if !errors.Is(err, merrors.ErrDataConflict) {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
|
||||
}
|
||||
b, err = s.Store.Get(ctx, a.ID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
|
||||
}
|
||||
requestRef = b.RegistrationRequest
|
||||
if b.Namespace != namespace {
|
||||
outcome = "attention_required"
|
||||
return merrors.FailedPrecondition("sign-up provider binding changed")
|
||||
}
|
||||
if b.Receipt != nil {
|
||||
outcome = "replayed"
|
||||
return nil
|
||||
}
|
||||
if b.SubjectRef == "" {
|
||||
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
|
||||
}
|
||||
if !claimed {
|
||||
// Another request may have completed while this request was claiming.
|
||||
b, err = s.Store.Get(ctx, a.ID)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
|
||||
}
|
||||
if b.SubjectRef == "" {
|
||||
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
|
||||
return unavailable()
|
||||
}
|
||||
outcome = "attention_required"
|
||||
return merrors.FailedPrecondition("individual registration requires reconciliation")
|
||||
}
|
||||
} else {
|
||||
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
|
||||
if err != nil {
|
||||
outcome = "attention_required"
|
||||
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
|
||||
}
|
||||
// Persist even if the browser left while Sendico accepted registration.
|
||||
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
|
||||
cancel()
|
||||
if err != nil {
|
||||
outcome = "attention_required"
|
||||
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
|
||||
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
|
||||
}
|
||||
b.SubjectRef = subject
|
||||
}
|
||||
}
|
||||
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
|
||||
}
|
||||
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
|
||||
return unavailable()
|
||||
}
|
||||
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
|
||||
}
|
||||
outcome = "accepted"
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ready checks confirmed intake without external I/O inside activation transactions.
|
||||
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||
b, err := s.Store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
|
||||
}
|
||||
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[bson.ObjectID]Binding
|
||||
bindErr, acceptErr error
|
||||
}
|
||||
|
||||
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.rows[id]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("missing")
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.rows[b.ID]; ok {
|
||||
return merrors.DataConflict("duplicate")
|
||||
}
|
||||
s.rows[b.ID] = *b
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b := s.rows[id]
|
||||
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
|
||||
return false, nil
|
||||
}
|
||||
b.Registering = true
|
||||
b.RegisteringAt = time.Now()
|
||||
s.rows[id] = b
|
||||
return true, nil
|
||||
}
|
||||
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.bindErr != nil {
|
||||
return s.bindErr
|
||||
}
|
||||
b := s.rows[id]
|
||||
b.SubjectRef = subject
|
||||
b.Registering = false
|
||||
s.rows[id] = b
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.acceptErr != nil {
|
||||
return s.acceptErr
|
||||
}
|
||||
b := s.rows[id]
|
||||
b.Receipt = &r
|
||||
s.rows[id] = b
|
||||
return nil
|
||||
}
|
||||
|
||||
type providerStub struct {
|
||||
registrations, submissions int
|
||||
registerErr, submitErr error
|
||||
snapshots []Facts
|
||||
sources []string
|
||||
entered, release chan struct{}
|
||||
}
|
||||
|
||||
func (*providerStub) Namespace() string { return "provider-1" }
|
||||
func (p *providerStub) Register(context.Context, string) (string, error) {
|
||||
p.registrations++
|
||||
if p.entered != nil {
|
||||
close(p.entered)
|
||||
<-p.release
|
||||
}
|
||||
return "subject-1", p.registerErr
|
||||
}
|
||||
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
|
||||
p.submissions++
|
||||
p.snapshots = append(p.snapshots, f)
|
||||
p.sources = append(p.sources, source)
|
||||
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
|
||||
}
|
||||
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
|
||||
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
|
||||
provider := &providerStub{}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Name = "Ada Lovelace"
|
||||
a.Login = "ada@example.com"
|
||||
a.Password = "never transmit"
|
||||
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
|
||||
}
|
||||
|
||||
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
p.submitErr = merrors.ServiceUnavailable("upstream")
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
b, err := store.Get(context.Background(), a.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subject-1", b.SubjectRef)
|
||||
require.Nil(t, b.Receipt)
|
||||
a.Name = "changed retry"
|
||||
p.submitErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.NoError(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Equal(t, 2, p.submissions)
|
||||
require.Equal(t, p.snapshots[0], p.snapshots[1])
|
||||
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
|
||||
require.Equal(t, p.sources[0], p.sources[1])
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 2, p.submissions)
|
||||
other := a.Copy()
|
||||
other.ID = bson.NewObjectID()
|
||||
require.Error(t, s.Ready(context.Background(), other.ID))
|
||||
}
|
||||
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
store.acceptErr = merrors.ServiceUnavailable("storage")
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
store.acceptErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Equal(t, 2, p.submissions)
|
||||
require.Equal(t, p.sources[0], p.sources[1])
|
||||
}
|
||||
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
|
||||
for _, bindFailure := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
if bindFailure {
|
||||
store.bindErr = merrors.ServiceUnavailable("storage")
|
||||
} else {
|
||||
p.registerErr = merrors.ServiceUnavailable("lost response")
|
||||
}
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
b := store.rows[a.ID]
|
||||
b.RegisteringAt = time.Now().Add(-time.Minute)
|
||||
store.rows[a.ID] = b
|
||||
store.bindErr = nil
|
||||
p.registerErr = nil
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Zero(t, p.submissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
|
||||
s, _, p, a := fixture()
|
||||
p.entered = make(chan struct{})
|
||||
p.release = make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Ensure(context.Background(), a) }()
|
||||
<-p.entered
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
|
||||
close(p.release)
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 1, p.submissions)
|
||||
}
|
||||
func TestNamespaceChangeFailsClosed(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
b := store.rows[a.ID]
|
||||
b.Namespace = "other-account-or-organization"
|
||||
store.rows[a.ID] = b
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.submissions)
|
||||
}
|
||||
|
||||
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
|
||||
s, _, p, a := fixture()
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
s.Logger = zap.New(core)
|
||||
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
p.submitErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Len(t, logs.All(), 2)
|
||||
for _, entry := range logs.All() {
|
||||
require.NotContains(t, entry.Message, a.Login)
|
||||
require.NotContains(t, entry.Message, a.Name)
|
||||
fields := entry.ContextMap()
|
||||
require.Equal(t, a.ID.Hex(), fields["account_ref"])
|
||||
require.NotEmpty(t, fields["request_ref"])
|
||||
for _, value := range fields {
|
||||
require.NotEqual(t, a.Login, value)
|
||||
require.NotEqual(t, a.Name, value)
|
||||
require.NotEqual(t, a.Password, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Get(context.Context, bson.ObjectID) (*Binding, error)
|
||||
Create(context.Context, *Binding) error
|
||||
Claim(context.Context, bson.ObjectID, string) (bool, error)
|
||||
Bind(context.Context, bson.ObjectID, string, string) error
|
||||
Accept(context.Context, bson.ObjectID, string, Receipt) error
|
||||
}
|
||||
|
||||
type mongoStore struct{ records repository.Repository }
|
||||
|
||||
func OpenStore(factory db.Factory) (Store, error) {
|
||||
d, err := deps.ResolveDependencies(factory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, nil
|
||||
}
|
||||
|
||||
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
||||
b := &Binding{}
|
||||
err := s.records.Get(ctx, id, b)
|
||||
return b, err
|
||||
}
|
||||
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
|
||||
return s.records.Insert(ctx, b, nil)
|
||||
}
|
||||
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
|
||||
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
|
||||
}
|
||||
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
|
||||
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
|
||||
}
|
||||
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
|
||||
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
|
||||
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
|
||||
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "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"
|
||||
)
|
||||
|
||||
func TestDurableBindingClaimsAndIsolation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", 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)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
// The handoff store must override unsafe caller defaults before claiming
|
||||
// ownership of a non-idempotent external operation.
|
||||
q.Set("w", "0")
|
||||
q.Set("readPreference", "secondary")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
store, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
|
||||
b.SetID(bson.NewObjectID())
|
||||
require.NoError(t, store.Create(ctx, b))
|
||||
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
|
||||
var wg sync.WaitGroup
|
||||
var wins atomic.Int32
|
||||
for range 12 {
|
||||
wg.Go(func() {
|
||||
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
|
||||
require.NoError(t, err)
|
||||
if claimed {
|
||||
wins.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
require.EqualValues(t, 1, wins.Load())
|
||||
stored, err := store.Get(ctx, b.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, stored.Registering)
|
||||
require.False(t, stored.RegisteringAt.IsZero())
|
||||
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
||||
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
||||
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
||||
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
||||
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||
receipt.SubjectRef = "subject"
|
||||
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||
// A separately opened store sees the durable receipt and cannot reclaim registration.
|
||||
reopened, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
stored, err = reopened.Get(ctx, b.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, receipt, *stored.Receipt)
|
||||
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
|
||||
require.NoError(t, err)
|
||||
require.False(t, claimed)
|
||||
_, err = reopened.Get(ctx, bson.NewObjectID())
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
|
||||
var receipt receiptDTO
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return receipt, err
|
||||
}
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "encode Sendico request")
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/"+resource+"/"+c.config.OrganizationID, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create Sendico request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Request-ID", requestRef)
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Sendico request failed")
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
// Never decode or expose upstream error bodies, which can contain assertions.
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
||||
var envelope envelopeDTO
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode Sendico receipt")
|
||||
}
|
||||
var trailing any
|
||||
if decoder.Decode(&trailing) != io.EOF || envelope.Status != "success" {
|
||||
return receipt, merrors.ServiceUnavailable("invalid Sendico response")
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
||||
dto, err := c.post(ctx, "individuals", requestRef, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mapSubject(dto)
|
||||
}
|
||||
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
|
||||
if err != nil {
|
||||
return identity.Receipt{}, err
|
||||
}
|
||||
return mapReceipt(dto, subject)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const org = "507f1f77bcf86cd799439011"
|
||||
const source = "507f1f77bcf86cd799439012"
|
||||
const subject = "507f1f77bcf86cd799439013"
|
||||
const submission = "507f1f77bcf86cd799439014"
|
||||
|
||||
func credential(t *testing.T, id string, expiry int64) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
|
||||
require.NoError(t, err)
|
||||
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
|
||||
}
|
||||
func config(t *testing.T, endpoint string) Config {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "token")
|
||||
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
|
||||
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
|
||||
}
|
||||
|
||||
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
||||
paths := []string{}
|
||||
var token string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
|
||||
paths = append(paths, r.URL.Path)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
if strings.Contains(r.URL.Path, "/individuals/") {
|
||||
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
||||
require.Empty(t, body)
|
||||
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
||||
} else {
|
||||
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
||||
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := config(t, server.URL)
|
||||
token = credential(t, source, time.Now().Add(time.Hour).Unix())
|
||||
client, err := Open(cfg)
|
||||
require.NoError(t, err)
|
||||
ref, err := client.Register(context.Background(), "registration-request")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, subject, ref)
|
||||
// File rotation takes effect without restarting; account identity stays fixed.
|
||||
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
|
||||
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
|
||||
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, submission, receipt.SubmissionRef)
|
||||
require.Equal(t, subject, receipt.SubjectRef)
|
||||
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
|
||||
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
|
||||
_, err = client.Register(context.Background(), "registration-request")
|
||||
require.Error(t, err)
|
||||
require.Len(t, paths, 2)
|
||||
}
|
||||
|
||||
func TestRejectInvalidConfiguration(t *testing.T) {
|
||||
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
|
||||
_, err := Open(config(t, endpoint))
|
||||
require.Error(t, err, endpoint)
|
||||
}
|
||||
cfg := config(t, "https://api.example")
|
||||
cfg.OrganizationID = "../other"
|
||||
_, err := Open(cfg)
|
||||
require.Error(t, err)
|
||||
cfg = config(t, "https://api.example")
|
||||
cfg.SourceID = ""
|
||||
_, err = Open(cfg)
|
||||
require.Error(t, err)
|
||||
cfg = config(t, "https://api.example")
|
||||
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
|
||||
_, err = Open(cfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"denied", 403, `{"secret":"private-assertion"}`},
|
||||
{"conflict", 409, `{"secret":"private-assertion"}`},
|
||||
{"unavailable", 503, `{"secret":"private-assertion"}`},
|
||||
{"malformed", 200, `{"private-assertion":`},
|
||||
{"empty", 200, `{"status":"success","data":{}}`},
|
||||
{"wrong-status", 200, `{"status":"error","data":{}}`},
|
||||
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
|
||||
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
|
||||
{"trailing", 200, `{"status":"success","data":{}} {}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "private-assertion")
|
||||
_, err = client.Register(context.Background(), "registration-request")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
_, err = client.Register(context.Background(), "registration-request")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 1, calls)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = client.Register(ctx, "registration-request")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 1, calls)
|
||||
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
||||
defer timeoutServer.Close()
|
||||
client, err = Open(config(t, timeoutServer.URL))
|
||||
require.NoError(t, err)
|
||||
client.http.Timeout = 20 * time.Millisecond
|
||||
_, err = client.Register(context.Background(), "timeout-request")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package sendico consumes only Sendico's public HTTP contracts.
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIURL string `yaml:"api_url"`
|
||||
OrganizationID string `yaml:"organization_ref"`
|
||||
SourceID string `yaml:"source_account_ref"`
|
||||
TokenFile string `yaml:"token_file"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config Config
|
||||
base string
|
||||
namespace string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func Open(c Config) (*Client, error) {
|
||||
u, err := url.Parse(c.APIURL)
|
||||
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
|
||||
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
|
||||
}
|
||||
for _, ref := range []string{c.OrganizationID, c.SourceID} {
|
||||
id, err := bson.ObjectIDFromHex(ref)
|
||||
if err != nil || id.IsZero() || ref != id.Hex() {
|
||||
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.TokenFile) == "" {
|
||||
return nil, merrors.InvalidArgument("Sendico token file required")
|
||||
}
|
||||
base := strings.TrimRight(c.APIURL, "/")
|
||||
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
|
||||
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
if _, err := client.token(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) Namespace() string { return c.namespace }
|
||||
|
||||
// Rotation may replace this file, but must retain the authenticated account.
|
||||
// Claim inspection only protects the replay namespace from operator mistakes;
|
||||
// Sendico remains responsible for signature validation and authorization.
|
||||
func (c *Client) token() (string, error) {
|
||||
raw, err := os.ReadFile(c.config.TokenFile)
|
||||
if err != nil {
|
||||
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
parts := strings.Split(token, ".")
|
||||
if len(token) > 16384 || len(parts) != 3 {
|
||||
return "", merrors.FailedPrecondition("invalid Sendico credential")
|
||||
}
|
||||
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Pending bool `json:"pending"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(claims, &payload); err != nil {
|
||||
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||
}
|
||||
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
|
||||
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Only consumed wire fields are owned here; never import platform modules.
|
||||
type factsDTO struct {
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
CustomerRef string `json:"external_customer_ref"`
|
||||
}
|
||||
type submissionDTO struct {
|
||||
SubjectRef string `json:"subject_ref"`
|
||||
SourceRef string `json:"source_submission_ref"`
|
||||
Facts factsDTO `json:"facts"`
|
||||
}
|
||||
type receiptDTO struct {
|
||||
SubjectRef string `json:"subject_ref"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
SubmissionRef string `json:"submission_ref"`
|
||||
AcceptedAt time.Time `json:"accepted_at"`
|
||||
}
|
||||
type envelopeDTO struct {
|
||||
Status string `json:"status"`
|
||||
Data receiptDTO `json:"data"`
|
||||
}
|
||||
|
||||
func validRef(ref string) bool {
|
||||
id, err := bson.ObjectIDFromHex(ref)
|
||||
return err == nil && !id.IsZero() && id.Hex() == ref
|
||||
}
|
||||
func mapFacts(subject, source string, facts identity.Facts) submissionDTO {
|
||||
return submissionDTO{SubjectRef: subject, SourceRef: source, Facts: factsDTO{FullName: facts.FullName, Email: facts.Email, CustomerRef: facts.CustomerRef}}
|
||||
}
|
||||
func mapSubject(dto receiptDTO) (string, error) {
|
||||
if !validRef(dto.SubjectRef) || dto.RegisteredAt.IsZero() {
|
||||
return "", merrors.ServiceUnavailable("invalid Sendico registration receipt")
|
||||
}
|
||||
return dto.SubjectRef, nil
|
||||
}
|
||||
func mapReceipt(dto receiptDTO, subject string) (identity.Receipt, error) {
|
||||
if !validRef(dto.SubmissionRef) || dto.SubjectRef != subject || dto.AcceptedAt.IsZero() {
|
||||
return identity.Receipt{}, merrors.ServiceUnavailable("invalid Sendico intake receipt")
|
||||
}
|
||||
return identity.Receipt{SubmissionRef: dto.SubmissionRef, SubjectRef: dto.SubjectRef, AcceptedAt: dto.AcceptedAt}, nil
|
||||
}
|
||||
Reference in new issue
Block a user