Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user