Submit customer identity proofs and show reviewed limits

This commit is contained in:
Stephan D committed 2026-10-15 02:22:28 +02:00
1 parent 7bf8b8087a
commit 1354551ae8
50 files changed
+1777 -67

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ 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/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
@@ -17,6 +18,7 @@ type Dependencies struct {
Customers accountservice.AccountService
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
+2
View File
@@ -5,6 +5,7 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api"
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
@@ -22,5 +23,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
if deps.Factors != nil {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
return nil
}
+2 -1
View File
@@ -67,7 +67,8 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
if err != nil {
return nil, nil, err
}
deps.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
+48
View File
@@ -0,0 +1,48 @@
package identityhttp
import "git.sendico.io/tech/sxchnge/product/identity"
type profileDTO struct {
FullName string `json:"fullName"`
DateOfBirth string `json:"dateOfBirth"`
ResidenceCountry string `json:"residenceCountry"`
}
func (p profileDTO) model() identity.Profile {
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
}
type verificationDTO struct {
Status string `json:"status"`
UpgradePending bool `json:"upgradePending"`
Reason string `json:"reason,omitempty"`
SubmissionID string `json:"submissionId,omitempty"`
Profile profileDTO `json:"profile"`
}
func mapVerification(v identity.Verification) verificationDTO {
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
type limitsDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profileRef"`
Entries []limitDTO `json:"entries"`
}
func mapLimits(v identity.Limits) limitsDTO {
entries := make([]limitDTO, 0, len(v.Entries))
for _, r := range v.Entries {
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
}
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
}
+107
View File
@@ -0,0 +1,107 @@
package identityhttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *identity.Service
logger mlogger.Logger
}
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
h := &router{service, logger.Named("identity_http")}
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
}
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Limits(r.Context(), a)
return h.result(mapLimits(v), err)
}
func (h *router) result(data any, err error) http.HandlerFunc {
if err == nil {
return response.Ok(h.logger, data)
}
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
}
}
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
case errors.Is(err, merrors.ErrDataConflict):
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
case errors.Is(err, merrors.ErrAccessDenied):
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
default:
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
}
}
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Verification(r.Context(), a)
return h.result(mapVerification(v), err)
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
// Read multipart as a bounded stream. Never spill identity documents to disk.
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
reader, err := r.MultipartReader()
if err != nil {
return h.result(nil, merrors.InvalidArgument("multipart required"))
}
fields := map[string][]byte{}
for {
part, readErr := reader.NextPart()
if readErr == io.EOF {
break
}
if readErr != nil {
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
}
name := part.FormName()
limit := int64(8192)
switch name {
case "document", "selfie":
limit = identity.MaxPhotoBytes
case "profile", "idempotencyKey":
default:
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("unexpected field"))
}
if _, exists := fields[name]; exists {
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("duplicate field"))
}
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
_ = part.Close()
if readErr != nil || int64(len(data)) > limit {
return h.result(nil, merrors.InvalidArgument("field too large"))
}
fields[name] = data
}
var p profileDTO
if err = json.Unmarshal(fields["profile"], &p); err != nil {
return h.result(nil, merrors.InvalidArgument("profile required"))
}
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
return h.result(mapVerification(v), err)
}
@@ -0,0 +1,20 @@
package identityhttp
import (
"net/http/httptest"
"testing"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
h := router{logger: zap.NewNop()}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/accounts/identity", nil)
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
require.Equal(t, 429, w.Code)
require.Equal(t, "120", w.Header().Get("Retry-After"))
}
+31
View File
@@ -0,0 +1,31 @@
package identity
import (
"bytes"
"image"
_ "image/jpeg"
_ "image/png"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
const MaxPhotoBytes = 10 << 20
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
// memory before decoding pixels. File names and client MIME types are ignored.
func ValidatePhoto(data []byte) error {
if len(data) == 0 || len(data) > MaxPhotoBytes {
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
}
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
return nil
}
+7 -1
View File
@@ -7,6 +7,8 @@ import (
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/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -26,7 +28,11 @@ func OpenStore(factory db.Factory) (Store, error) {
if err != nil {
return nil, err
}
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, nil
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{records: records}, nil
}
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
+7
View File
@@ -62,6 +62,13 @@ func TestDurableBindingClaimsAndIsolation(t *testing.T) {
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"))
duplicate := &Binding{Namespace: b.Namespace}
duplicate.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, duplicate))
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
require.NoError(t, err)
require.True(t, claimedDuplicate)
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
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))
+149
View File
@@ -0,0 +1,149 @@
package identity
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
)
// Profile holds unverified customer assertions. Email and customer reference are
// always supplied by the authenticated backend, never accepted from the browser.
type Profile struct {
FullName string
DateOfBirth string
ResidenceCountry string
}
func (p Profile) Validate() error {
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
return merrors.InvalidArgument("complete identity profile required")
}
return nil
}
type Verification struct {
Status string
UpgradePending bool
Reason string
SubmissionID string
Profile Profile
}
// VerificationProvider consumes reviewed state through the platform public API.
// It cannot accept a browser-selected subject, policy profile or approval.
type VerificationProvider interface {
Verification(context.Context, string) (Verification, error)
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
}
// Decimal amounts are transported losslessly in their currency's major unit.
// No fiat scale or illustrative client caps are applied to provider policy.
type Limit struct {
Operation string
Period string
Currency string
Cap string
Remaining string
Used string
Reserved string
}
type Limits struct {
Tier string
ProfileRef string
Entries []Limit
}
type LimitsProvider interface {
Limits(context.Context, string) (Limits, error)
}
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Limits{}, err
}
p, ok := s.Provider.(LimitsProvider)
if !ok {
return Limits{}, unavailable()
}
return p.Limits(ctx, b.SubjectRef)
}
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
if s == nil || s.Provider == nil || s.Store == nil {
return nil, merrors.ServiceUnavailable("identity verification unavailable")
}
if a == nil || a.ID.IsZero() {
return nil, merrors.InvalidArgument("customer required")
}
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
if err = s.Ensure(ctx, a); err != nil {
return nil, err
}
b, err = s.Store.Get(ctx, a.ID)
}
if err != nil {
return nil, err
}
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
}
return b, nil
}
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
v, err := provider.Verification(ctx, b.SubjectRef)
if err == nil && v.Profile.FullName == "" {
v.Profile.FullName = a.Name
}
return v, err
}
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
if len(key) < 16 || len(key) > 128 {
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
}
if err := p.Validate(); err != nil {
return Verification{}, err
}
for _, photo := range [][]byte{document, selfie} {
if err := ValidatePhoto(photo); err != nil {
return Verification{}, err
}
}
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
facts := b.Facts
facts.Email = a.Login
facts.CustomerRef = a.ID.Hex()
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
if err != nil {
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
return Verification{}, err
}
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil
}
@@ -0,0 +1,84 @@
package identity
import (
"bytes"
"context"
"image"
"image/png"
"testing"
"github.com/stretchr/testify/require"
)
type proofProvider struct {
*providerStub
subject, key string
profile Profile
facts Facts
calls int
}
func (p *proofProvider) Verification(_ context.Context, subject string) (Verification, error) {
p.subject = subject
return Verification{Status: "unverified"}, nil
}
func (p *proofProvider) SubmitVerification(_ context.Context, subject, key string, profile Profile, facts Facts, _, _ []byte) (Verification, error) {
p.subject = subject
p.key = key
p.profile = profile
p.facts = facts
p.calls++
return Verification{Status: "pending", SubmissionID: "receipt"}, nil
}
func proofImage(t *testing.T, width, height int) []byte {
t.Helper()
var body bytes.Buffer
require.NoError(t, png.Encode(&body, image.NewNRGBA(image.Rect(0, 0, width, height))))
return body.Bytes()
}
func TestVerificationUsesBoundSubjectAndStableCustomerKey(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
ctx := t.Context()
result, err := s.Verification(ctx, a)
require.NoError(t, err)
require.Equal(t, a.Name, result.Profile.FullName)
require.Equal(t, "subject-1", p.subject)
photo := proofImage(t, 600, 600)
profile := Profile{FullName: "Legal Customer Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
key := p.key
require.Equal(t, a.ID.Hex(), p.facts.CustomerRef)
require.Equal(t, a.Login, p.facts.Email)
require.Equal(t, profile, p.profile)
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.Equal(t, key, p.key)
require.Equal(t, 1, base.registrations)
other := a.Copy()
other.ID[0]++
_, err = s.SubmitVerification(ctx, other, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.NotEqual(t, key, p.key)
}
func TestVerificationValidationBeforeExternalHandoff(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
photo := proofImage(t, 600, 600)
for _, profile := range []Profile{{}, {FullName: "Name", DateOfBirth: "1990-02-30", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "2990-04-23", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "1990-04-23", ResidenceCountry: "USA"}} {
_, err := s.SubmitVerification(t.Context(), a, "0123456789012345", profile, photo, photo)
require.Error(t, err)
}
require.Zero(t, p.calls)
require.Zero(t, base.registrations)
require.Error(t, ValidatePhoto([]byte("not an image")))
require.Error(t, ValidatePhoto(proofImage(t, 599, 600)))
require.Error(t, ValidatePhoto(photo[:len(photo)-20]))
require.Error(t, ValidatePhoto(make([]byte, MaxPhotoBytes+1)))
var unavailableService *Service
_, err := unavailableService.Verification(t.Context(), a)
require.Error(t, err)
}
@@ -0,0 +1,184 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type profileDTO struct {
FullName string `json:"full_name"`
DateOfBirth string `json:"date_of_birth"`
ResidenceCountry string `json:"residence_country"`
Email string `json:"email"`
CustomerRef string `json:"external_customer_ref"`
}
type verificationDTO struct {
SubjectRef string `json:"subject_ref"`
SubmissionRef string `json:"submission_ref"`
Status string `json:"status"`
Reason string `json:"reason"`
Profile profileDTO `json:"profile"`
UpgradePending bool `json:"upgrade_pending"`
}
type policyDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profile_ref"`
Entries []limitDTO `json:"entries"`
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
if !validRef(subject) {
return merrors.InvalidArgument("invalid bound subject")
}
token, err := c.token()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
}
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// Uploads need a longer bound than small sign-up receipts. The base client is
// copied so concurrent sign-up operations keep their own four-second timeout.
client := *c.http
client.Timeout = 60 * time.Second
res, err := client.Do(req)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
}
defer func() { _ = res.Body.Close() }()
switch res.StatusCode {
case http.StatusOK:
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return merrors.InvalidArgument("identity profile or photos were not accepted")
case http.StatusConflict:
return merrors.DataConflict("identity submission changed; refresh")
case http.StatusForbidden:
return merrors.PermissionDenied("identity resource unavailable")
case http.StatusTooManyRequests:
retry := 60
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
retry = seconds
}
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
default:
return merrors.ServiceUnavailable("identity provider unavailable")
}
var envelope struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
if err := decoder.Decode(&envelope); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
}
var extra any
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
return merrors.ServiceUnavailable("invalid identity response")
}
if err := json.Unmarshal(envelope.Data, dst); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
}
return nil
}
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
if dto.SubjectRef != subject {
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
}
switch dto.Status {
case "unverified", "pending", "approved", "changesRequested", "denied":
default:
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
}
if dto.Status == "approved" && dto.UpgradePending {
dto.Status = "pending"
}
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
}
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
if dto.Status != "unverified" {
if err := profile.Validate(); err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
}
}
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
}
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
var dto verificationDTO
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
if err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
}
if err = writer.WriteField("profile", string(profile)); err != nil {
return identity.Verification{}, err
}
if err = writer.WriteField("source_submission_ref", key); err != nil {
return identity.Verification{}, err
}
for i, data := range [][]byte{document, selfie} {
name := "document"
if i == 1 {
name = "selfie"
}
part, writeErr := writer.CreateFormFile(name, name)
if writeErr != nil {
return identity.Verification{}, writeErr
}
if _, writeErr = part.Write(data); writeErr != nil {
return identity.Verification{}, writeErr
}
}
if err = writer.Close(); err != nil {
return identity.Verification{}, err
}
var dto verificationDTO
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
var dto policyDTO
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
return identity.Limits{}, err
}
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
}
entries := make([]identity.Limit, 0, len(dto.Entries))
for _, v := range dto.Entries {
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
}
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
}
@@ -0,0 +1,79 @@
package sendico
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
)
func TestProofWireContractAndLimitsProjection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
if r.URL.Path == expected+"/limits" {
require.Equal(t, http.MethodGet, r.Method)
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
return
}
require.Equal(t, expected, r.URL.Path)
if r.Method == http.MethodPost {
reader, err := r.MultipartReader()
require.NoError(t, err)
fields := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
require.NoError(t, err)
data, err := io.ReadAll(part)
require.NoError(t, err)
fields[part.FormName()] = string(data)
}
require.Equal(t, "document bytes", fields["document"])
require.Equal(t, "selfie bytes", fields["selfie"])
require.Equal(t, "stable-source", fields["source_submission_ref"])
var profile map[string]string
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
require.Equal(t, "Legal Name", profile["full_name"])
require.Equal(t, "local-customer", profile["external_customer_ref"])
require.Equal(t, "verified@example.test", profile["email"])
}
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
require.NoError(t, err)
require.Equal(t, "pending", result.Status)
require.True(t, result.UpgradePending)
policy, err := client.Limits(t.Context(), subject)
require.NoError(t, err)
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
require.Equal(t, "verified", policy.Tier)
require.Equal(t, 4*time.Second, client.http.Timeout)
}
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
w.WriteHeader(429)
_, _ = io.WriteString(w, "private identity assertions")
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Verification(t.Context(), subject)
require.Error(t, err)
require.NotContains(t, err.Error(), "private identity assertions")
throttle, ok := merrors.ThrottleDetailsFrom(err)
require.True(t, ok)
require.Equal(t, 120*time.Second, throttle.RetryAfter)
}