From 336d0c68ed67e2158cd85ce915a80635a8001073 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 08:41:41 -0700 Subject: [PATCH 01/74] test(serve): a branch bound key never acts outside its branch, red first The sweep behind this test (PR body) found every route a machine key reaches that writes or reads another branch's rows without the branch middleware: the location module's unwalled writes (PUT and DELETE /locations/{id}, POST /branches, PUT and DELETE /branches/{id}, the user grant, revoke and home branch routes), the exposure surface's by id routes on quotes, and the unwalled branch reads. ADR 0007 section 5.5 pins a bound key to its branch; only the branch middleware and the payload guard honoured the pin, and these routes mount neither. The tests drive serve's real mounts (branchWall.locations, registerExposureRoutes) behind the real machine key core with a really minted bound key, an unbound key and a user. All four fail at 1a719fd on exactly the escapes: the bound key edits, archives, grants, revokes and reads branch B's rows, writes an exposure event on branch B's quote, and runs the dealer wide exposure scan. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 555 ++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 core/internal/app/serve/wire_key_branch_pin_test.go diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go new file mode 100644 index 00000000..174a28b6 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -0,0 +1,555 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The branch bound machine key's pin (ADR 0007 section 5.5) through serve's +// real mount methods and the real machine key core: one key minted bound to +// branch A, one unbound key, and a user, against every route the sweep found +// writing or reading another branch's rows without the branch middleware. +// Only authentication is a stand-in: the machine key core is the real one +// over a real minted key, and the user arrives as the claims a JWT would +// carry. A route that loses its hold on the pin fails here. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/location" + "github.com/gablelbm/gable/internal/pricing" + "github.com/gablelbm/gable/internal/quote" + "github.com/gablelbm/gable/internal/techadmin" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// keyPinFixture is two branches, one yard per branch for the location rows, +// two spare branches for the archive arms, a quote per branch for the +// exposure routes, and the serve mounts the pin must hold on: the location +// routes through branchWall.locations and the exposure routes through +// registerExposureRoutes, both behind the real machine key core. +type keyPinFixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + branchA uuid.UUID + branchB uuid.UUID + branchC uuid.UUID + branchC2 uuid.UUID + yardA uuid.UUID + yardB uuid.UUID + quoteA uuid.UUID + quoteB uuid.UUID + custID uuid.UUID + bound string // the key pinned to branch A + unbound string // the same scopes, no pin +} + +// pinKeyScopes opens every route under test: the location writes, the branch +// directory, the user grants and the exposure surface. +var pinKeyScopes = []string{ + "locations:write", "branches:read", "branches:write", + "users:read", "users:grants", "quotes:read", "quotes:write", "admin:write", +} + +func newKeyPinFixture(t *testing.T) *keyPinFixture { + t.Helper() + testutil.LockOutboxTables(t) // the exposure writes record outbox events + db := testutil.RequireDB(t) + t.Setenv("AUTH_MODE", "dev") + ctx := context.Background() + f := &keyPinFixture{t: t, db: db, + branchA: uuid.New(), branchB: uuid.New(), branchC: uuid.New(), branchC2: uuid.New(), + yardA: uuid.New(), yardB: uuid.New(), quoteA: uuid.New(), quoteB: uuid.New(), custID: uuid.New(), + } + for _, r := range []struct { + id uuid.UUID + typ string + parent any + }{{f.branchA, "BRANCH", nil}, {f.branchB, "BRANCH", nil}, {f.branchC, "BRANCH", nil}, {f.branchC2, "BRANCH", nil}, + {f.yardA, "YARD", f.branchA}, {f.yardB, "YARD", f.branchB}} { + if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name, parent_id) VALUES ($1, $2, $3, $4, $5)`, + r.id, r.typ, "pin-"+r.id.String()[:8], "pin row "+r.id.String()[:8], r.parent); err != nil { + t.Fatalf("seed location: %v", err) + } + } + if _, err := db.Pool.Exec(ctx, `INSERT INTO customers (id, name, account_number, primary_branch_id) + VALUES ($1, 'pin cust', $2, $3)`, f.custID, "PIN-"+f.custID.String()[:8], f.branchA); err != nil { + t.Fatalf("seed customer: %v", err) + } + for _, q := range []struct{ id, branch uuid.UUID }{{f.quoteA, f.branchA}, {f.quoteB, f.branchB}} { + if _, err := db.Pool.Exec(ctx, `INSERT INTO quotes (id, number, customer_id, state, branch_id, total_amount) + VALUES ($1, $2, $3, 'DRAFT', $4, 10)`, q.id, "PINQ-"+q.id.String()[:8], f.custID, q.branch); err != nil { + t.Fatalf("seed quote: %v", err) + } + } + // A user granted both branches: the read narrowing keeps the pin's branch + // only for the bound key and both for everyone else. + if _, err := db.Pool.Exec(ctx, `INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) + VALUES ('pin-both', $1, TRUE, 'test'), ('pin-both', $2, FALSE, 'test')`, f.branchA, f.branchB); err != nil { + t.Fatalf("seed grants: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' + AND entity_id IN (SELECT id::text FROM quote_exposure_events WHERE quote_id IN ($1, $2))`, f.quoteA, f.quoteB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote' AND entity_id IN ($1, $2)`, f.quoteA, f.quoteB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM quote_exposure_events WHERE quote_id IN ($1, $2)`, f.quoteA, f.quoteB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM quotes WHERE id IN ($1, $2)`, f.quoteA, f.quoteB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, f.custID) + _, _ = db.Pool.Exec(ctx, `DELETE FROM user_locations WHERE user_sub LIKE 'pin-%' OR user_sub = 'pin-both'`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE code LIKE 'pin-new-%'`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE parent_id IN ($1, $2, $3, $4)`, f.branchA, f.branchB, f.branchC, f.branchC2) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2, $3, $4, $5, $6)`, + f.branchA, f.branchB, f.branchC, f.branchC2, f.yardA, f.yardB) + }) + + setSetting(t, db, "multi_branch_enabled", "true") + setSetting(t, db, "default_branch_required", "false") + wall := newBranchWall(db) + mux := http.NewServeMux() + wall.locations(mux, location.NewHandler(location.NewService(location.NewRepository(db)), + location.NewUserRepository(db), middleware.RequireRole("admin", "owner"))) + + // The exposure surface exactly as wireExposure registers it. + escRepo := pricing.NewEscalatorRepository(db) + exposureRepo := pricing.NewExposureRepository(db) + quoteRepo := quote.NewRepository(db) + exposureAudit := &exposureAuditAdapter{auditLog: audit.NewLogger(db)} + registerExposureRoutes(mux, exposureRoutes{ + Scanner: pricing.NewExposureScanner(exposureRepo, escRepo, quoteRepo, exposureAudit, db, slog.Default()). + WithOutbox(outbox.NewWriter(db, "")), + Checker: pricing.NewExposureChecker(db), + Exposure: exposureRepo, + Service: pricing.NewExposureService(exposureRepo, escRepo, quoteRepo, exposureAudit, pricing.NewExposureChecker(db), slog.Default()). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db), + Escalators: escRepo, + DB: db, + Logger: slog.Default(), + }) + + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) + mint := func(name string, branch *uuid.UUID) string { + t.Helper() + raw, _, err := keys.GenerateKey(ctx, name, pinKeyScopes, branch) + if err != nil { + t.Fatalf("mint %s: %v", name, err) + } + return raw + } + f.bound = mint("pin bound to A", &f.branchA) + f.unbound = mint("pin unbound", nil) + mkAuth := middleware.NewMachineKeyAuth(pinKeyValidator{svc: keys}, audit.NewLogger(db), []string{"/api/integration/"}, nil) + f.srv = httptest.NewServer(mkAuth.Handler(pinClaims(mux))) + t.Cleanup(f.srv.Close) + return f +} + +// pinClaims injects the claims a JWT would carry only for a request that +// names a role, so a keyed request reaches the role guards with no claims +// (the machine key core's context is what passes it there), exactly as the +// real auth layer arranges. +func pinClaims(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role := r.Header.Get("X-Test-Role"); role != "" { + claims := &middleware.UserClaims{ + RegisteredClaims: jwt.RegisteredClaims{Subject: r.Header.Get("X-Test-Sub")}, + Role: role, Roles: []string{role}, + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), middleware.UserContextKey, claims))) + return + } + next.ServeHTTP(w, r) + }) +} + +// pinKeyValidator adapts the techadmin service to the machine key core, the +// same adapter serve uses. +type pinKeyValidator struct { + svc *techadmin.Service +} + +func (v pinKeyValidator) ValidateKey(ctx context.Context, rawKey string) (middleware.KeyPrincipal, error) { + k, err := v.svc.ValidateKey(ctx, rawKey) + if err != nil { + if err == techadmin.ErrInvalidKey { + return middleware.KeyPrincipal{}, middleware.ErrInvalidMachineKey + } + return middleware.KeyPrincipal{}, err + } + return middleware.KeyPrincipal{ID: k.ID.String(), Scopes: k.Scopes, BranchID: k.BranchID}, nil +} + +// key sends one authenticated machine key request. +func (f *keyPinFixture) key(t *testing.T, raw, method, path, body string, hdr map[string]string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+raw) + for k, v := range hdr { + req.Header.Set(k, v) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + buf, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return res.StatusCode, buf +} + +// user sends one request as the claims a JWT would carry. +func (f *keyPinFixture) user(t *testing.T, method, path, body string, hdr map[string]string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-Role", "admin") + req.Header.Set("X-Test-Sub", "pin-boss") + for k, v := range hdr { + req.Header.Set(k, v) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + buf, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return res.StatusCode, buf +} + +func (f *keyPinFixture) auditRows(t *testing.T) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'key.branch_refused'`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// refuseForeign runs one bound key call that must be refused with its audit +// row: 403 forbidden, the pin refusal message, one fresh key.branch_refused. +func (f *keyPinFixture) refuseForeign(t *testing.T, method, path, body string, hdr map[string]string) { + t.Helper() + before := f.auditRows(t) + status, buf := f.key(t, f.bound, method, path, body, hdr) + if status != http.StatusForbidden { + t.Errorf("bound key %s %s: %d %s, want 403", method, path, status, buf) + return + } + if !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("bound key %s %s: refusal body does not name the pin rule: %s", method, path, buf) + } + if after := f.auditRows(t); after != before+1 { + t.Errorf("bound key %s %s: key.branch_refused rows %d then %d, want one new row", method, path, before, after) + } +} + +// allow runs one call that must pass (the bound key's own branch, or an +// unbound key, or a user) and returns its status with the body. +func (f *keyPinFixture) allow(t *testing.T, who, method, path, body string, hdr map[string]string, want int) []byte { + t.Helper() + var ( + status int + buf []byte + ) + switch who { + case "bound": + status, buf = f.key(t, f.bound, method, path, body, hdr) + case "unbound": + status, buf = f.key(t, f.unbound, method, path, body, hdr) + default: + status, buf = f.user(t, method, path, body, hdr) + } + if status != want { + t.Errorf("%s %s %s: %d %s, want %d", who, method, path, status, buf, want) + } + return buf +} + +// locState reads a location row's revision, name and active flag. +func (f *keyPinFixture) locState(t *testing.T, id uuid.UUID) (revision int64, name string, active bool) { + t.Helper() + err := f.db.Pool.QueryRow(context.Background(), + `SELECT revision, COALESCE(name, ''), active FROM locations WHERE id = $1`, id). + Scan(&revision, &name, &active) + if err != nil { + t.Fatal(err) + } + return revision, name, active +} + +func (f *keyPinFixture) hasGrant(t *testing.T, sub string, branch uuid.UUID) bool { + t.Helper() + var ok bool + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT EXISTS (SELECT 1 FROM user_locations WHERE user_sub = $1 AND branch_id = $2)`, sub, branch).Scan(&ok); err != nil { + t.Fatal(err) + } + return ok +} + +func (f *keyPinFixture) exposureEvents(t *testing.T, quote uuid.UUID) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM quote_exposure_events WHERE quote_id = $1`, quote).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// putLocationBody builds a PUT body that keeps the row's code and changes its +// name, with the If-Match the row's current revision demands. +func (f *keyPinFixture) putLocation(t *testing.T, who string, id uuid.UUID, name string, want int) { + t.Helper() + rev, code, _ := f.locState(t, id) + body := fmt.Sprintf(`{"code":%q,"path":"pin","name":%q}`, code, name) + f.allow(t, who, "PUT", "/api/v1/locations/"+id.String(), body, + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, want) +} + +// TestKeyBranchPin_LocationWrites walks the location module's unwalled writes: +// every branch B row the bound key names is a refused 403 with its audit row +// and no write, the key's own branch passes, and the unbound key and the user +// keep their reach. +func TestKeyBranchPin_LocationWrites(t *testing.T) { + f := newKeyPinFixture(t) + A, B := f.branchA.String(), f.branchB.String() + + // PUT /locations/{id}: the foreign yard is refused and unchanged, the own + // yard, the unbound key and the user pass. + f.refuseForeign(t, "PUT", "/api/v1/locations/"+f.yardB.String(), + fmt.Sprintf(`{"code":"pin-%s","path":"pin","name":"no"}`, f.yardB.String()[:8]), + map[string]string{"If-Match": `"1"`}) + if rev, _, active := f.locState(t, f.yardB); rev != 1 || !active { + t.Errorf("refused PUT changed the foreign yard: revision %d active %v", rev, active) + } + f.putLocation(t, "bound", f.yardA, "pin own edit", http.StatusOK) + f.putLocation(t, "unbound", f.yardB, "pin free edit", http.StatusOK) + f.putLocation(t, "user", f.yardB, "pin user edit", http.StatusOK) + + // DELETE /locations/{id}: the foreign yard is refused and stays active. + f.refuseForeign(t, "DELETE", "/api/v1/locations/"+f.yardB.String(), "", map[string]string{"If-Match": `"2"`}) + if _, _, active := f.locState(t, f.yardB); !active { + t.Errorf("refused DELETE archived the foreign yard") + } + rev, _, _ := f.locState(t, f.yardA) + f.allow(t, "bound", "DELETE", "/api/v1/locations/"+f.yardA.String(), "", + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, http.StatusNoContent) + rev, _, _ = f.locState(t, f.yardB) + f.allow(t, "unbound", "DELETE", "/api/v1/locations/"+f.yardB.String(), "", + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, http.StatusNoContent) + + // POST /branches: the directory create is refused for a bound key + // outright (a new branch is outside every pin), and stays open to the + // unbound key and the user. + newBranch := fmt.Sprintf(`{"type":"branch","code":"pin-new-%s","name":"pin new"}`, uuid.NewString()[:8]) + f.refuseForeign(t, "POST", "/api/v1/branches", newBranch, nil) + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM locations WHERE code LIKE 'pin-new-%'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("refused POST /branches created rows") + } + f.allow(t, "unbound", "POST", "/api/v1/branches", newBranch, nil, http.StatusCreated) + f.allow(t, "user", "POST", "/api/v1/branches", + fmt.Sprintf(`{"type":"branch","code":"pin-new-%s","name":"pin new"}`, uuid.NewString()[:8]), nil, http.StatusCreated) + + // PUT /branches/{id}: the foreign branch is refused and unchanged, the + // key's own branch passes, and the unbound key and the user keep theirs. + f.refuseForeign(t, "PUT", "/api/v1/branches/"+B, + fmt.Sprintf(`{"code":"pin-%s","path":"pin","name":"no"}`, B[:8]), map[string]string{"If-Match": `"1"`}) + if _, name, _ := f.locState(t, f.branchB); name != "pin row "+B[:8] { + t.Errorf("refused PUT /branches renamed the foreign branch to %q", name) + } + putBranch := func(who string, id uuid.UUID, name string, want int) { + rev, code, _ := f.locState(t, id) + f.allow(t, who, "PUT", "/api/v1/branches/"+id.String(), + fmt.Sprintf(`{"code":%q,"path":"pin","name":%q}`, code, name), + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, want) + } + putBranch("bound", f.branchA, "pin own branch", http.StatusOK) + putBranch("unbound", f.branchB, "pin free branch", http.StatusOK) + putBranch("user", f.branchB, "pin user branch", http.StatusOK) + + // DELETE /branches/{id}: the foreign branch is refused and stays active. + f.refuseForeign(t, "DELETE", "/api/v1/branches/"+B, "", map[string]string{"If-Match": `"2"`}) + if _, _, active := f.locState(t, f.branchB); !active { + t.Errorf("refused DELETE /branches archived the foreign branch") + } + rev, _, _ = f.locState(t, f.branchC) + f.allow(t, "unbound", "DELETE", "/api/v1/branches/"+f.branchC.String(), "", + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, http.StatusNoContent) + rev, _, _ = f.locState(t, f.branchC2) + f.allow(t, "user", "DELETE", "/api/v1/branches/"+f.branchC2.String(), "", + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, http.StatusNoContent) + rev, _, _ = f.locState(t, f.branchA) + f.allow(t, "bound", "DELETE", "/api/v1/branches/"+A, "", + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)}, http.StatusNoContent) +} + +// TestKeyBranchPin_UserGrants walks the grant, revoke and home branch routes: +// the bound key names branch B in the body or the path and is refused with +// its audit row and no grant write, its own branch passes, and the unbound +// key and the user keep their reach. +func TestKeyBranchPin_UserGrants(t *testing.T) { + f := newKeyPinFixture(t) + A, B := f.branchA.String(), f.branchB.String() + + // Grant: body branch_id. + f.refuseForeign(t, "POST", "/api/v1/users/pin-bound-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil) + if f.hasGrant(t, "pin-bound-grant", f.branchB) { + t.Errorf("refused grant wrote the foreign grant row") + } + f.allow(t, "bound", "POST", "/api/v1/users/pin-bound-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, A), nil, http.StatusNoContent) + if !f.hasGrant(t, "pin-bound-grant", f.branchA) { + t.Errorf("own branch grant did not land") + } + f.allow(t, "unbound", "POST", "/api/v1/users/pin-free-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + f.allow(t, "user", "POST", "/api/v1/users/pin-user-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + + // Home branch: body branch_id. + f.refuseForeign(t, "PUT", "/api/v1/users/pin-bound-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, B), nil) + f.allow(t, "bound", "PUT", "/api/v1/users/pin-bound-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, A), nil, http.StatusNoContent) + f.allow(t, "unbound", "PUT", "/api/v1/users/pin-free-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + f.allow(t, "user", "PUT", "/api/v1/users/pin-user-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + + // Revoke: path branch_id. + f.refuseForeign(t, "DELETE", "/api/v1/users/pin-both/branches/"+B, "", nil) + if !f.hasGrant(t, "pin-both", f.branchB) { + t.Errorf("refused revoke removed the foreign grant row") + } + f.allow(t, "bound", "DELETE", "/api/v1/users/pin-bound-grant/branches/"+A, "", nil, http.StatusNoContent) + if f.hasGrant(t, "pin-bound-grant", f.branchA) { + t.Errorf("own branch revoke did not land") + } + f.allow(t, "unbound", "DELETE", "/api/v1/users/pin-free-grant/branches/"+B, "", nil, http.StatusNoContent) + f.allow(t, "user", "DELETE", "/api/v1/users/pin-user-grant/branches/"+B, "", nil, http.StatusNoContent) +} + +// TestKeyBranchPin_LocationReads narrows the unwalled reads: the bound key +// reads only its own branch's grant rows, and the unbound key and the user +// keep the unfiltered answers. +func TestKeyBranchPin_LocationReads(t *testing.T) { + f := newKeyPinFixture(t) + A, B := f.branchA.String(), f.branchB.String() + + // The branch users read of the foreign branch is refused like a write. + f.refuseForeign(t, "GET", "/api/v1/branches/"+B+"/users", "", nil) + f.allow(t, "bound", "GET", "/api/v1/branches/"+A+"/users", "", nil, http.StatusOK) + f.allow(t, "unbound", "GET", "/api/v1/branches/"+B+"/users", "", nil, http.StatusOK) + f.allow(t, "user", "GET", "/api/v1/branches/"+B+"/users", "", nil, http.StatusOK) + + // A user's branch list carries only the pin's branch for the bound key, + // and every granted branch for everyone else. + read := func(who string) map[string]bool { + t.Helper() + buf := f.allow(t, who, "GET", "/api/v1/users/pin-both/branches", "", nil, http.StatusOK) + var out []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(buf, &out); err != nil { + t.Fatalf("%s branch list body: %v %s", who, err, buf) + } + got := map[string]bool{} + for _, it := range out { + got[it.ID] = true + } + return got + } + if got := read("bound"); got[A] != true || got[B] != false { + t.Errorf("bound key branch list = %v, want only its own branch", got) + } + if got := read("unbound"); !got[A] || !got[B] { + t.Errorf("unbound key branch list = %v, want both branches", got) + } + if got := read("user"); !got[A] || !got[B] { + t.Errorf("user branch list = %v, want both branches", got) + } +} + +// TestKeyBranchPin_ExposureRoutes holds the pin on the exposure surface: the +// quote of the other branch is refused on every by id route with its audit +// row and no write, the key's own branch's quote passes to the module, the +// dealer wide scan is refused for a bound key, and the unbound key and the +// user keep their reach. AUTH_MODE=dev is the mode where the actor fallback +// lets a keyless caller act, so it is the mode that must hold. +func TestKeyBranchPin_ExposureRoutes(t *testing.T) { + f := newKeyPinFixture(t) + qA, qB := f.quoteA.String(), f.quoteB.String() + ack := `{"method":"VERBAL","notes":"customer acknowledged by phone"}` + override := `{"notes":"emergency override, owner spoken to"}` + + // Every by id route on the foreign quote: refused, audited, no write. + for _, c := range []struct{ method, suffix, body string }{ + {"GET", "/exposure", ""}, + {"POST", "/exposure/acknowledge", ack}, + {"POST", "/exposure/request-ack", ""}, + {"POST", "/exposure/override", override}, + {"POST", "/exposure/escalate-now", ""}, + } { + f.refuseForeign(t, c.method, "/api/v1/quotes/"+qB+c.suffix, c.body, nil) + } + if n := f.exposureEvents(t, f.quoteB); n != 0 { + t.Errorf("refused exposure routes wrote %d events for the foreign quote", n) + } + + // The own branch's quote passes to the module: the read and the preview + // answer 200, request ack writes its event, and the two state gated + // actions reach the module's own refusal (409, exposure already clear), + // not the wall. + f.allow(t, "bound", "GET", "/api/v1/quotes/"+qA+"/exposure", "", nil, http.StatusOK) + f.allow(t, "bound", "POST", "/api/v1/quotes/"+qA+"/exposure/escalate-now", "", nil, http.StatusOK) + f.allow(t, "bound", "POST", "/api/v1/quotes/"+qA+"/exposure/request-ack", "", nil, http.StatusOK) + if n := f.exposureEvents(t, f.quoteA); n != 1 { + t.Errorf("own quote request ack wrote %d events, want 1", n) + } + f.allow(t, "bound", "POST", "/api/v1/quotes/"+qA+"/exposure/acknowledge", ack, nil, http.StatusConflict) + f.allow(t, "bound", "POST", "/api/v1/quotes/"+qA+"/exposure/override", override, nil, http.StatusConflict) + + // The dealer wide scan is refused for a bound key; the unbound key and + // the user keep it. + f.refuseForeign(t, "POST", "/api/v1/admin/exposure-scan", "", nil) + f.allow(t, "unbound", "POST", "/api/v1/admin/exposure-scan", "", nil, http.StatusOK) + f.allow(t, "user", "POST", "/api/v1/admin/exposure-scan", "", nil, http.StatusOK) + + // The unbound key and the user keep the foreign quote's surface. + f.allow(t, "unbound", "GET", "/api/v1/quotes/"+qB+"/exposure", "", nil, http.StatusOK) + f.allow(t, "user", "GET", "/api/v1/quotes/"+qB+"/exposure", "", nil, http.StatusOK) + f.allow(t, "unbound", "POST", "/api/v1/quotes/"+qB+"/exposure/request-ack", "", nil, http.StatusOK) + f.allow(t, "user", "POST", "/api/v1/quotes/"+qB+"/exposure/request-ack", "", nil, http.StatusOK) + + // The auth core's own header rule stands in front of all of it: a bound + // key naming the other branch in X-Branch-Id is refused before the router. + status, buf := f.key(t, f.bound, "GET", "/api/v1/quotes/"+qA+"/exposure", "", + map[string]string{"X-Branch-Id": f.branchB.String()}) + if status != http.StatusForbidden { + t.Errorf("bound key with foreign X-Branch-Id: %d %s, want 403", status, buf) + } +} From e141cdc8a8fff3763b1efc7d7833f5387e80f92b Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 08:52:44 -0700 Subject: [PATCH 02/74] fix(middleware): the key branch wall holds a bound key to its pin everywhere ADR 0007 section 5.5 pins a branch bound machine key to its branch, but only the branch middleware honoured the pin, and a machine key passes the role guards on its scope alone, so every route that mounted no branch middleware let a bound key act on any branch its body or path named: the location module's by id writes, the branch directory verbs, the user grant, revoke and home branch routes, the exposure surface's by id quote routes (whose actor fallback admits a key under AUTH_MODE=dev), and the dealer wide exposure scan. The wall (pkg/middleware/keybranch.go, beside the branch guard) reads the pin through branchctx.KeyBranchFromContext and compares the branch the request names by a path value, a body branch_id, or a row lookup (locations through their tree, quotes through their branch): a bound key naming another branch is refused 403 forbidden in the ADR envelope naming the field, with the key.branch_refused audit row, the same verdict the X-Branch-Id rule writes; its own branch passes, and a user and an unbound key pass unchanged, whatever the multi_branch_enabled switch says. The directory create and the scan refuse a bound key outright, being outside every pin. The branch users read of another branch is refused the same way, and a user's branch list is narrowed to the pin for a bound key. The wall mounts at the route registrations: the location handler takes it through WithKeyBranchWall (serve's branchWall builds it with the audit logger), and registerExposureRoutes composes it onto the exposure handler's guard. The two exposure lists and the branch directory reads stay un-narrowed: narrowing them changes recorded contracts (the exposure repository, PR 39's reference data decision) and the sweep lists them for the lead. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 24 +- core/internal/app/serve/wire_exposure.go | 64 ++++- .../app/serve/wire_key_branch_pin_test.go | 7 +- core/internal/location/handler.go | 59 ++++- core/pkg/middleware/keybranch.go | 224 ++++++++++++++++++ core/pkg/middleware/machinekey.go | 22 +- 6 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 core/pkg/middleware/keybranch.go diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 6c65abdf..b02a695a 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -22,25 +22,30 @@ import ( "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/internal/quote" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" ) // branchWall is the branch wall as serve wires it: the branch middleware that // settles a request's branch context, the role-plus-branch composition every -// scoped route uses, and the guard that holds a branch or location a request -// body names to that context (ADR 0007 section 2.3). The mount methods below +// scoped route uses, the guard that holds a branch or location a request +// body names to that context (ADR 0007 section 2.3), and the key branch wall +// that holds a branch bound machine key to its pin on the routes that mount +// no branch middleware (ADR 0007 section 5.5). The mount methods below // are the only places a route that takes a branch from its body gets its guard, // and wire_branch_wall_test.go drives them with the real middleware, so // dropping a guard here fails a test. type branchWall struct { - mw func(http.Handler) http.Handler - guard *middleware.BranchGuard - scoped func(roles ...string) func(http.Handler) http.Handler + mw func(http.Handler) http.Handler + guard *middleware.BranchGuard + keyWall *middleware.KeyBranchWall + scoped func(roles ...string) func(http.Handler) http.Handler } func newBranchWall(db *database.DB) *branchWall { - w := &branchWall{mw: middleware.NewBranchMiddleware(db).Handler, guard: middleware.NewBranchGuard(db)} + w := &branchWall{mw: middleware.NewBranchMiddleware(db).Handler, guard: middleware.NewBranchGuard(db), + keyWall: middleware.NewKeyBranchWall(db, audit.NewLogger(db))} w.scoped = func(roles ...string) func(http.Handler) http.Handler { return middleware.Compose(middleware.RequireRole(roles...), w.mw) } @@ -57,9 +62,12 @@ func (w *branchWall) chargeCodes(mux *http.ServeMux, svc *chargecode.Service) { // locations mounts the location routes behind the branch middleware: the // create writes into a branch's tree, the by-id reads and the list are held // to the caller's branches (the branch switcher reads /me/branches, not this -// list). +// list). The key branch wall covers the routes that mount no branch +// middleware: the by-id writes, the branch directory verbs and the grant +// routes (ADR 0007 section 5.5). func (w *branchWall) locations(mux *http.ServeMux, h *location.Handler) { - h.WithBranchWall(w.guard, w.mw).RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "warehouse", "sales")) + h.WithBranchWall(w.guard, w.mw).WithKeyBranchWall(w.keyWall). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "warehouse", "sales")) } // products mounts the product routes behind the branch middleware: the stock diff --git a/core/internal/app/serve/wire_exposure.go b/core/internal/app/serve/wire_exposure.go index 41b1c81c..bb15ba54 100644 --- a/core/internal/app/serve/wire_exposure.go +++ b/core/internal/app/serve/wire_exposure.go @@ -5,6 +5,7 @@ package serve import ( "context" + "errors" "log/slog" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/gablelbm/gable/pkg/middleware" "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) // ExposureWiring is the handle main.go keeps after wiring the lumber @@ -113,6 +115,7 @@ func wireExposure(deps exposureDeps) *ExposureWiring { Escalators: deps.EscalatorRepo, DB: deps.DB, Logger: logger, + AuditLog: deps.AuditLog, }) // DRAFT → SENT: freeze the index baseline, customer policy and threshold @@ -156,6 +159,9 @@ type exposureRoutes struct { Escalators pricing.EscalatorRepository DB *database.DB Logger *slog.Logger + // AuditLog writes the key.branch_refused rows the key branch wall on + // these routes records. Optional: nil leaves the refusals un-audited. + AuditLog *audit.Logger } // registerExposureRoutes attaches the twelve price-protection endpoints this @@ -168,18 +174,70 @@ type exposureRoutes struct { // standing up a database (see wire_exposure_test.go). func registerExposureRoutes(mux *http.ServeMux, r exposureRoutes) { // Salesperson + owner surface: at-risk list, per-quote detail and actions, - // portfolio report, admin scan trigger. + // portfolio report, admin scan trigger. The key branch wall holds a branch + // bound key to its pin on the by id routes (their quote's branch) and + // refuses it the dealer wide scan; the two lists narrow no further here + // (narrowing them changes the exposure repository's contract, and the + // sweep lists them for the lead). + var keyAuditor middleware.BranchRefusalAuditor // a nil logger must not ride a non nil interface + if r.AuditLog != nil { + keyAuditor = r.AuditLog + } + keyWall := middleware.NewKeyBranchWall(r.DB, keyAuditor) + exposureGuard := middleware.Compose( + middleware.RequireRole("admin", "owner", "sales"), + exposureKeyBranchWall(keyWall, r.quoteBranchOf)) pricing.NewExposureHandler(r.Scanner, r.Checker, r.Exposure, r.Service). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + RegisterRoutes(mux, exposureGuard) // Buyer/admin surface: index refresh (+ dry-run preview), metadata edit, // history time-series. Deliberately does NOT re-register - // GET /api/v1/market-indices — that belongs to pricing.EscalatorHandler and + // GET /api/v1/market-indices: that belongs to pricing.EscalatorHandler and // a duplicate pattern would panic the ServeMux. pricing.NewIndexAdminHandler(r.Escalators, r.Exposure, r.Scanner, r.DB, r.Logger). RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) } +// quoteBranchOf resolves the branch a quote belongs to, the row branch the +// exposure by id routes are held to. A quote that does not exist names no +// branch (the handler's own 404 answers). +func (r exposureRoutes) quoteBranchOf(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + if r.DB == nil { + return nil, errors.New("exposure key branch wall: no database for the quote lookup") + } + var branch *uuid.UUID + err := r.DB.GetExecutor(ctx).QueryRow(ctx, + `SELECT branch_id FROM quotes WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return branch, nil +} + +// exposureKeyBranchWall routes the exposure surface's handlers through the +// wall their request shape demands: the five by id quote routes hold their +// quote's branch to the pin, the admin scan refuses a bound key (it acts +// across every branch), and the two list routes pass unchanged. +func exposureKeyBranchWall(keyWall *middleware.KeyBranchWall, quoteBranchOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + byID := keyWall.RowBranch(quoteBranchOf)(next) + scan := keyWall.RefuseBound("the scan runs across every branch")(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/admin/exposure-scan": + scan.ServeHTTP(w, r) + case r.PathValue("id") != "": + byID.ServeHTTP(w, r) + default: + next.ServeHTTP(w, r) + } + }) + } +} + // exposureAuditAdapter bridges the synchronous pkg/audit.Logger to the narrow // pricing.AuditWriter interface (which re-declares the audit entry to avoid a // pricing→pkg/audit import). EntityID arrives as a string; non-UUID values map diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 174a28b6..3ae08a49 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -130,13 +130,14 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { registerExposureRoutes(mux, exposureRoutes{ Scanner: pricing.NewExposureScanner(exposureRepo, escRepo, quoteRepo, exposureAudit, db, slog.Default()). WithOutbox(outbox.NewWriter(db, "")), - Checker: pricing.NewExposureChecker(db), - Exposure: exposureRepo, - Service: pricing.NewExposureService(exposureRepo, escRepo, quoteRepo, exposureAudit, pricing.NewExposureChecker(db), slog.Default()). + Checker: pricing.NewExposureChecker(db), + Exposure: exposureRepo, + Service: pricing.NewExposureService(exposureRepo, escRepo, quoteRepo, exposureAudit, pricing.NewExposureChecker(db), slog.Default()). WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db), Escalators: escRepo, DB: db, Logger: slog.Default(), + AuditLog: audit.NewLogger(db), }) keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) diff --git a/core/internal/location/handler.go b/core/internal/location/handler.go index a32ec502..d0cc2d7c 100644 --- a/core/internal/location/handler.go +++ b/core/internal/location/handler.go @@ -24,6 +24,7 @@ type Handler struct { adminGuards []func(http.Handler) http.Handler // applied to admin-only routes guard LocationGuard // optional; see WithBranchWall branchMw func(http.Handler) http.Handler // optional; see WithBranchWall + keyWall *middleware.KeyBranchWall // optional; see WithKeyBranchWall } // NewHandler constructs the location handler. userRepo and adminGuards may be @@ -49,6 +50,18 @@ func (h *Handler) WithBranchWall(g LocationGuard, branchMw func(http.Handler) ht return h } +// WithKeyBranchWall puts the key branch wall on the routes that mount no +// branch middleware (ADR 0007 section 5.5): the by id location writes, the +// branch directory verbs and the user grant routes hold a branch bound key +// to its pin, refusing it another branch with the key.branch_refused row. +// The branch reads the wall narrows are /branches/{id}/users (refused for +// another branch) and /users/{sub}/branches (filtered to the pin). Without +// it nothing changes, so serve always sets it. +func (h *Handler) WithKeyBranchWall(w *middleware.KeyBranchWall) *Handler { + h.keyWall = w + return h +} + func NewHandler(service *Service, userRepo UserRepository, adminGuards ...func(http.Handler) http.Handler) *Handler { return &Handler{ service: service, @@ -83,6 +96,23 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand return h2.ServeHTTP } + // The key branch wall on the routes that mount no branch middleware: the + // by id location writes hold the row's branch to a bound key's pin, the + // directory create refuses one outright, the directory writes and the + // branch users read hold the path branch to it, and the grant routes hold + // the body or path branch to it (ADR 0007 section 5.5). + wall := func(mw func(http.Handler) http.Handler) func(http.HandlerFunc) http.HandlerFunc { + if mw == nil || h.keyWall == nil { + return func(hf http.HandlerFunc) http.HandlerFunc { return hf } + } + return func(hf http.HandlerFunc) http.HandlerFunc { return mw(hf).ServeHTTP } + } + locRowWall := wall(h.keyWall.LocationRowBranch()) + branchIdWall := wall(h.keyWall.NamedBranch("id")) + bodyBranchWall := wall(h.keyWall.BodyBranch()) + branchIdPathWall := wall(h.keyWall.NamedBranch("branch_id")) + directoryWall := wall(h.keyWall.RefuseBound("the branch directory is outside a branch bound key")) + // Legacy / shared location endpoints. create := http.HandlerFunc(h.CreateLocation) read := http.HandlerFunc(h.GetLocation) @@ -95,15 +125,15 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("POST /api/v1/locations", guard(create)) mux.HandleFunc("GET /api/v1/locations", guard(list)) mux.HandleFunc("GET /api/v1/locations/{id}", guard(read)) - mux.HandleFunc("PUT /api/v1/locations/{id}", adminGuard(h.UpdateLocation)) - mux.HandleFunc("DELETE /api/v1/locations/{id}", adminGuard(h.DeleteLocation)) + mux.HandleFunc("PUT /api/v1/locations/{id}", adminGuard(locRowWall(h.UpdateLocation))) + mux.HandleFunc("DELETE /api/v1/locations/{id}", adminGuard(locRowWall(h.DeleteLocation))) // Branch CRUD. mux.HandleFunc("GET /api/v1/branches", guard(h.ListBranches)) - mux.HandleFunc("POST /api/v1/branches", adminGuard(h.CreateBranch)) + mux.HandleFunc("POST /api/v1/branches", adminGuard(directoryWall(h.CreateBranch))) mux.HandleFunc("GET /api/v1/branches/{id}", guard(h.GetBranch)) - mux.HandleFunc("PUT /api/v1/branches/{id}", adminGuard(h.UpdateBranch)) - mux.HandleFunc("DELETE /api/v1/branches/{id}", adminGuard(h.DeleteBranch)) + mux.HandleFunc("PUT /api/v1/branches/{id}", adminGuard(branchIdWall(h.UpdateBranch))) + mux.HandleFunc("DELETE /api/v1/branches/{id}", adminGuard(branchIdWall(h.DeleteBranch))) tree := http.HandlerFunc(h.GetBranchTree) if h.branchMw != nil { tree = h.branchMw(tree).ServeHTTP @@ -115,10 +145,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("GET /api/v1/me/branches", guard(h.ListMyBranches)) mux.HandleFunc("GET /api/v1/users", adminGuard(h.ListKnownUsers)) mux.HandleFunc("GET /api/v1/users/{sub}/branches", adminGuard(h.ListUserBranches)) - mux.HandleFunc("POST /api/v1/users/{sub}/branches", adminGuard(h.GrantUserBranch)) - mux.HandleFunc("DELETE /api/v1/users/{sub}/branches/{branch_id}", adminGuard(h.RevokeUserBranch)) - mux.HandleFunc("PUT /api/v1/users/{sub}/home-branch", adminGuard(h.SetHomeBranch)) - mux.HandleFunc("GET /api/v1/branches/{id}/users", adminGuard(h.ListBranchUsers)) + mux.HandleFunc("POST /api/v1/users/{sub}/branches", adminGuard(bodyBranchWall(h.GrantUserBranch))) + mux.HandleFunc("DELETE /api/v1/users/{sub}/branches/{branch_id}", adminGuard(branchIdPathWall(h.RevokeUserBranch))) + mux.HandleFunc("PUT /api/v1/users/{sub}/home-branch", adminGuard(bodyBranchWall(h.SetHomeBranch))) + mux.HandleFunc("GET /api/v1/branches/{id}/users", adminGuard(branchIdWall(h.ListBranchUsers))) } } @@ -743,6 +773,17 @@ func (h *Handler) ListUserBranches(w http.ResponseWriter, r *http.Request) { if branches == nil { branches = []BranchSummary{} } + // A branch bound key sees the pin's branch only (ADR 0007 section 5.5): + // the list is narrowed to it, as every list the key reaches is. + if pin, ok := middleware.KeyBranchPin(r.Context()); ok { + kept := make([]BranchSummary, 0, len(branches)) + for _, b := range branches { + if b.ID == pin { + kept = append(kept, b) + } + } + branches = kept + } writeJSON(w, http.StatusOK, branches) } diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go new file mode 100644 index 00000000..db4330e9 --- /dev/null +++ b/core/pkg/middleware/keybranch.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// The key branch wall (ADR 0007 section 5.5). The machine key core pins a +// branch bound key's request to its branch and refuses a foreign X-Branch-Id +// itself; the branch middleware then holds every route that mounts it to the +// pin. A route that mounts no branch middleware escaped the pin: it acted on +// whatever branch its body or path named. The wall closes that: mounted on +// such a route, it reads the pin (branchctx.KeyBranchFromContext), compares +// the branch the request names, and refuses a bound key that names another +// branch with 403 forbidden and the key.branch_refused audit row, the same +// verdict and row the X-Branch-Id rule writes. A user and an unbound key +// name no pin, so the wall passes them through unchanged, and the pin holds +// whatever the multi_branch_enabled switch says, as on the middleware's own +// routes. The check lives here, once, not in each handler. + +// KeyBranchPin returns the branch a machine key is bound to, the pin the +// branch middleware and this wall hold a request to. ok is false for a user +// and an unbound key. +func KeyBranchPin(ctx context.Context) (uuid.UUID, bool) { + return branchctx.KeyBranchFromContext(ctx) +} + +// KeyBranchWall holds branch bound machine keys to their pin on routes that +// mount no branch middleware. A nil database or auditor only loses the +// location lookup or the audit row, never the refusal. +type KeyBranchWall struct { + db *database.DB + auditor BranchRefusalAuditor +} + +// NewKeyBranchWall builds the wall. db backs the location row lookup +// (LocationRowBranch); auditor writes the key.branch_refused row and may be +// nil (the refusal is still served). +func NewKeyBranchWall(db *database.DB, auditor BranchRefusalAuditor) *KeyBranchWall { + return &KeyBranchWall{db: db, auditor: auditor} +} + +// NamedBranch wraps a handler whose path names a branch directly: the given +// path value ("branch_id", or "id" on the branch routes) is the branch the +// request acts on. +func (w *KeyBranchWall) NamedBranch(pathValue string) func(http.Handler) http.Handler { + return w.wrap(pathValue, "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { + raw := r.PathValue(pathValue) + if raw == "" { + return uuid.Nil, false, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, false, nil // the handler answers the malformed id + } + return id, true, nil + }) +} + +// BodyBranch wraps a handler whose JSON body names a branch in branch_id. A +// body that names no branch (absent, empty, malformed) passes through: the +// handler's own validation answers it, and the wall never turns a 400 into a +// 403. The body is restored for the handler. +func (w *KeyBranchWall) BodyBranch() func(http.Handler) http.Handler { + return w.wrap("branch_id", "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { + raw, err := io.ReadAll(r.Body) + if err != nil { + return uuid.Nil, false, fmt.Errorf("key branch wall: read body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + var body struct { + BranchID string `json:"branch_id"` + } + if err := json.Unmarshal(raw, &body); err != nil { + return uuid.Nil, false, nil + } + if body.BranchID == "" { + return uuid.Nil, false, nil + } + id, err := uuid.Parse(body.BranchID) + if err != nil { + return uuid.Nil, false, nil // the handler answers the malformed id + } + return id, true, nil + }) +} + +// RowBranch wraps a handler whose path names a row ("id") whose branch +// branchOf resolves: a location, a quote. A row that does not exist, or that +// belongs to no branch, names no branch and passes to the handler's own +// answer. A lookup failure answers 500 rather than failing open. +func (w *KeyBranchWall) RowBranch(branchOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { + return w.wrap("id", "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { + raw := r.PathValue("id") + if raw == "" { + return uuid.Nil, false, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, false, nil // the handler answers the malformed id + } + branch, err := branchOf(ctx, id) + if err != nil { + return uuid.Nil, false, err + } + if branch == nil { + return uuid.Nil, false, nil + } + return *branch, true, nil + }) +} + +// LocationRowBranch is RowBranch over the locations tree: the row the path +// names stands for its branch (itself when it is a branch, else its +// branch_id), the same lookup the payload guard applies to a parent_id. +func (w *KeyBranchWall) LocationRowBranch() func(http.Handler) http.Handler { + return w.RowBranch(w.branchOfLocation) +} + +func (w *KeyBranchWall) branchOfLocation(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + if w.db == nil { + return nil, errors.New("key branch wall: no database for the location lookup") + } + var branch *uuid.UUID + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT CASE WHEN type = 'BRANCH' THEN COALESCE(branch_id, id) ELSE branch_id END + FROM locations WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("key branch wall: location branch lookup: %w", err) + } + return branch, nil +} + +// RefuseBound wraps a handler a branch bound key may never reach: a route +// that administers the branch directory or acts across every branch and so +// cannot be confined to one. reason says why in the refusal message. +func (w *KeyBranchWall) RefuseBound(reason string) func(http.Handler) http.Handler { + return w.wrap("", reason, func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { + // A branch is named that no pin can equal, so every bound key is + // refused whatever the request carries. + return uuid.Nil, true, nil + }) +} + +// wrap builds the wrapper: no pin passes (a user, an unbound key), the pin's +// own branch passes, any other branch is refused with its audit row. field +// names the request part that named the branch in the refusal's details; +// reason, when not empty, extends the refusal message. +func (w *KeyBranchWall) wrap(field, reason string, named func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + pin, ok := KeyBranchPin(r.Context()) + if !ok { + next.ServeHTTP(rw, r) + return + } + id, has, err := named(r.Context(), r) + if err != nil { + slog.Error("key branch wall: branch lookup failed", "error", err, "method", r.Method, "path", r.URL.Path) + respondAuthError(rw, r, http.StatusInternalServerError, "internal_error", "branch lookup failed") + return + } + if has && id != pin { + w.refuse(r.Context(), rw, r, pin, field, reason) + return + } + next.ServeHTTP(rw, r) + }) + } +} + +// refuse writes the 403 and its audit row. The refusal is the ADR envelope +// the machine key refusals answer in, naming the field that named the branch; +// a failure to write the row is logged inside the auditor and swallowed, as +// the refusal verdict has already been served. +func (w *KeyBranchWall) refuse(ctx context.Context, rw http.ResponseWriter, r *http.Request, pin uuid.UUID, field, reason string) { + if keyID, ok := KeyIDFromContext(ctx); ok && w.auditor != nil { + w.auditor.AuditKeyBranchRefusal(ctx, keyID, pin, r.Method, r.URL.Path) + } + message := "a branch bound key may act only on its own branch" + if reason != "" { + message = message + ": " + reason + } + var details []string + if field != "" { + details = []string{field} + } + writeAuthError(rw, r, http.StatusForbidden, "forbidden", message, details) +} + +// writeAuthError writes an auth layer refusal in the ADR envelope with +// optional detail strings, the shape respondAuthError writes. +func writeAuthError(w http.ResponseWriter, r *http.Request, status int, code, message string, details []string) { + reqID := w.Header().Get("X-Request-ID") + if reqID == "" { + reqID = r.Header.Get("X-Request-ID") + } + var body authErrorBody + body.Error.Code = code + body.Error.Message = message + body.Error.Details = details + body.Meta.RequestID = reqID + slog.Warn("auth refusal", "code", code, "status", status, "method", r.Method, "path", r.URL.Path, "request_id", reqID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/core/pkg/middleware/machinekey.go b/core/pkg/middleware/machinekey.go index bc745f90..db4fee65 100644 --- a/core/pkg/middleware/machinekey.go +++ b/core/pkg/middleware/machinekey.go @@ -5,7 +5,6 @@ package middleware import ( "context" - "encoding/json" "errors" "log/slog" "net/http" @@ -861,24 +860,5 @@ type authErrorBody struct { // verbatim (4xx only; this layer writes no 500 with detail), and the request // id so a client can quote one identifier for any response. func respondAuthError(w http.ResponseWriter, r *http.Request, status int, code, message string) { - reqID := w.Header().Get("X-Request-ID") - if reqID == "" { - reqID = r.Header.Get("X-Request-ID") - } - - var body authErrorBody - body.Error.Code = code - body.Error.Message = message - body.Meta.RequestID = reqID - - slog.Warn("auth refusal", - "code", code, - "status", status, - "method", r.Method, - "path", r.URL.Path, - "request_id", reqID, - ) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(body) + writeAuthError(w, r, status, code, message, nil) } From 0164f775c80aa914405feeb9836651e044adcbe9 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 08:52:44 -0700 Subject: [PATCH 03/74] docs: the branch bound key's pin holds on every route, recorded CONTRACT-CHANGES gains the SEC-BOUND-KEY-PIN row stating exactly what changed on every route the key branch wall now covers. ADR 0007 5.5 gains the sentence that says how a route without the branch middleware is held to the pin and why the directory create and the dealer wide routes refuse a bound key outright. The drafts and locations module docs' bound key paragraphs now describe the fixed behaviour: the gap text (an operator should not mint a branch bound key with users:grants, branches:write or locations:write) is gone, replaced by what each route answers, with the branch directory reads, the exposure lists and the events feed named as the stated limits the sweep listed for the lead. Signed-off-by: colton --- ...7-drafts-links-and-confirm-gated-scopes.md | 7 +++++ docs/modules/drafts.md | 31 +++++++++---------- docs/modules/locations.md | 17 +++++++--- docs/refactor/CONTRACT-CHANGES.md | 2 ++ 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 947ab1e3..4db5a03f 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -684,6 +684,13 @@ null branch is today's behaviour. A bound key: `payload.branch_id`, by the branch rule of section 2.3, on draft routes and on the module's own create alike (PR 37's guard). +A route that mounts no branch middleware is held to the pin the same way by +the key branch wall (`pkg/middleware/keybranch.go`): a branch a request +names in a path or body, or the branch of a row a path names, is compared to +the pin and another branch is the same 403 with the same `key.branch_refused` +row, while the branch directory's create and the dealer wide routes (the +exposure scan) refuse a bound key outright, being outside every pin. + One branch per key, not a set: the repositories' branch idiom filters on one branch or none, and a set would need a second idiom in every module. A dealer that needs a key for two branches mints two. diff --git a/docs/modules/drafts.md b/docs/modules/drafts.md index fed9f3bd..d1da4978 100644 --- a/docs/modules/drafts.md +++ b/docs/modules/drafts.md @@ -114,22 +114,21 @@ is read (`pkg/middleware/machinekey.go`, the `principal.BranchID != nil` branch in `handle`), and the routes that mount no branch middleware (the user-only prefixes `/api/v1/admin/keys` and `/api/v1/me`) are JWT user routes, so a machine key cannot -reach them, bound or not. The branch directory and user -grant routes (`/api/v1/branches` and its children except -`/tree`, `/api/v1/users` and its children) carry no branch -wall: the pin does not narrow them, writes included. A -bound key holding `users:grants` is limited by that scope -only, and can grant a user any branch. The branch CRUD -verbs (`GET`, `POST`, `PUT`, `DELETE` on `/api/v1/branches` -and its by id children except `/tree`) are limited by the key's scope only (the role guard passes a scope checked machine key), so a branch bound key's pin does not -confine those writes either; a bound key holding -`branches:write` can write any branch. The -`PUT /api/v1/locations/{id}` and `DELETE /api/v1/locations/{id}` -routes are limited by the key's scope only (the role guard passes a scope checked machine key), so a bound key -holding `locations:write` can write any location. An -operator should not mint a branch bound key with -`users:grants`, `branches:write` or `locations:write` until -this gap is fixed (the pin narrows only the routes that mount the branch middleware; on the rest a key reaches whatever its scopes admit). An unbound key behaves as before. +reach them, bound or not. The routes that mount no branch middleware are held to the +pin by the key branch wall (`core/pkg/middleware/keybranch.go`): +a bound key naming another branch in a body `branch_id` or a +path id, or a row of another branch (`PUT` and `DELETE +/api/v1/locations/{id}`), is a 403 `forbidden` in the wire +envelope naming the field, audited as `key.branch_refused` +like the header rule; its own branch passes. The directory +create (`POST /api/v1/branches`) refuses a bound key outright, +and `PUT` and `DELETE /api/v1/branches/{id}` refuse it another +branch. A bound key holding `users:grants` grants, revokes and +moves home only its own branch. The branch directory reads +(`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay +reference data (the PR 39 decision), and the exposure lists +and the events feed stay cross branch, stated limits listed +for the lead. An unbound key behaves as before. ## Routes diff --git a/docs/modules/locations.md b/docs/modules/locations.md index d4babd11..c56f725f 100644 --- a/docs/modules/locations.md +++ b/docs/modules/locations.md @@ -376,10 +376,19 @@ before the switch is read, `BranchMiddleware` in see that branch only, a request naming another branch in `X-Branch-Id` is a 403 `forbidden` audited as `key.branch_refused`, and a body `parent_id` or path id of another branch is a 403. The -routes that mount no branch middleware (`GET /api/v1/branches`, `GET -/api/v1/branches/{id}`, `GET /api/v1/branches/{id}/users`, `GET -/api/v1/users`) are not narrowed by the pin. An unbound key behaves -as before. +routes that mount no branch middleware are held to the pin by the +key branch wall (`core/pkg/middleware/keybranch.go`, mounted through +`WithKeyBranchWall`): a bound key writing another branch's location +(`PUT` and `DELETE /api/v1/locations/{id}`), another branch itself +(`PUT` and `DELETE /api/v1/branches/{id}`) or another branch in a +grant body or path is a 403 `forbidden` naming the field, audited as +`key.branch_refused`; its own branch passes; the directory create +(`POST /api/v1/branches`) refuses a bound key outright; +`GET /api/v1/branches/{id}/users` refuses it another branch and +`GET /api/v1/users/{sub}/branches` answers only its branch for it. +The branch directory reads (`GET /api/v1/branches`, `GET +/api/v1/branches/{id}`) stay reference data, unwalled by the PR 39 +decision, a stated limit. An unbound key behaves as before. ## ADRs that govern this module diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 14f246ac..3020319d 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -272,3 +272,5 @@ get an entry. | C5-2a | `POST /api/v1/drafts/quotes`, `PUT /api/v1/drafts/quotes/{id}` (and the orders kind's) | No draft routes existed (the payload branch rule was the quote create's alone, PR 37) | The payload branch rule of ADR 0007 section 2.3 applies to every draft route through the same guard: with a branch in the request's context (always, for a bound key) a payload `branch_id` must equal it, else 403 naming `payload.branch_id`; with no context branch an administrator or owner may name any branch, and so may every caller while the single branch kill switch is off, an unbound key, and `AUTH_MODE=dev`; a user with no context branch may name only a branch in their grants; the branch is fixed at create | | C5-2a | `PUT /api/v1/quotes/{id}/file` | No such route: the AI parse's original file could only enter through the quote create, which a draft promotion replaces | The quote file route of ADR 0007 section 10: the raw file bytes with their content type, the same 5 MiB bound (413 above it), `quotes:write`, the revision precondition (428, 409), accepted only while the quote is in status `draft` (else 409 `invalid_state_transition` with the `quote_not_draft` blocker); it replaces any stored file, moves the revision by one, returns the quote with its new `ETag`, applies the branch wall like the read, writes the `quote.file_attached` audit row and no outbox event. Being an entity write on a gated module it is refused to an agent marked session | | C5-2a | `GET /api/v1/links/quotes/{id}`, `GET /api/v1/links/orders/{id}`, `GET /api/v1/links/invoices/{id}`, `GET /api/v1/links/customers/{id}`, `GET /api/v1/links/products/{id}`, `GET /api/v1/links/drafts/quotes/{id}`, `GET /api/v1/links/drafts/orders/{id}` | No link resolver existed: every frontend and agent carried its own route table | The link resolver of ADR 0007 section 8: `{id}` is a UUID or the entity's document number, the record must exist and be visible to the caller (the module's own read, so an invisible record is the same 404 a missing one gets), and the answer carries the desk link (absolute when `GABLE_PUBLIC_URL` is set, origin relative otherwise), the front door's `/?open=` form, `gable://` for the shell, a null portal until it has record screens, and the agent slot from `GABLE_AGENT_URL_TEMPLATE` (null when unset). The scope is read off the URL: the module `read` for an entity link, `propose` or `commit` for a draft link. The declarative table is the only copy and generates `core/api/links.json`; `core/api/link-paths.json` carries the plain segment vectors the shell, the door and their tests share | +| SEC-BOUND-KEY-PIN | every route a machine key reaches that mounts no branch middleware: locations `PUT /api/v1/locations/{id}`, `DELETE /api/v1/locations/{id}`, branches `POST /api/v1/branches`, `PUT /api/v1/branches/{id}`, `DELETE /api/v1/branches/{id}`, grants `POST /api/v1/users/{sub}/branches`, `DELETE /api/v1/users/{sub}/branches/{branch_id}`, `PUT /api/v1/users/{sub}/home-branch`, reads `GET /api/v1/branches/{id}/users`, `GET /api/v1/users/{sub}/branches`, exposure `GET /api/v1/quotes/{id}/exposure`, `POST /api/v1/quotes/{id}/exposure/acknowledge`, `/request-ack`, `/override`, `/escalate-now`, and `POST /api/v1/admin/exposure-scan` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin, and a scope checked key passes the role guards on its scope alone (RequireRole), so on every route above a bound key acted on the branch its body or path named: it edited and archived another branch's locations and branches, created branches, granted, revoked and moved any user's branches, and under AUTH_MODE=dev (whose actor fallback passes a keyless caller as the dev owner) acknowledged, requested, overrode and previewed another branch's quote exposure and ran the dealer wide scan | The key branch wall (`core/pkg/middleware/keybranch.go`, mounted through `WithKeyBranchWall` and `registerExposureRoutes`) reads the pin (branchctx.KeyBranchFromContext) and compares the branch the request names by a path value, a body branch_id, or the branch of the row the path names (a location through its tree, a quote through its branch): a bound key naming another branch is refused 403 `forbidden` in the wire envelope naming the field, audited as `key.branch_refused` (the row the X-Branch-Id rule writes); its own branch passes; the directory create and the exposure scan refuse a bound key outright (outside every pin); `GET /api/v1/branches/{id}/users` refuses another branch and `GET /api/v1/users/{sub}/branches` answers only the pin's branch for a bound key. Users and unbound keys are unchanged on every route, whatever the multi_branch_enabled switch says. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data (PR 39) and the exposure lists and the events feed stay cross branch, stated limits for the lead | + From 7ef2d1d8476e3884efe527c69b39eb5091cdf7b5 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 09:06:19 -0700 Subject: [PATCH 04/74] test(drafts): the kill switch flip rides the outbox lock The promotion test flips multi_branch_enabled globally with no lock, and a package whose fixture reads the setting mid flip (CI: the invoice credit memo test's sales caller) caches the flipped state for its middleware's 30 second TTL and answers 400 X-Branch-Id header required where its role guard owns the 403. Every other settings flipper (the serve wall and key pin fixtures) already rides the outbox lock; this one now does too. Signed-off-by: colton --- core/internal/drafts/feed_c5_2a_p2_1_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/internal/drafts/feed_c5_2a_p2_1_test.go b/core/internal/drafts/feed_c5_2a_p2_1_test.go index 83ea1b53..d945892f 100644 --- a/core/internal/drafts/feed_c5_2a_p2_1_test.go +++ b/core/internal/drafts/feed_c5_2a_p2_1_test.go @@ -206,6 +206,10 @@ func TestFeedSubjectFilterExcludesNoiseCreatedAfterReady(t *testing.T) { // instead of the draft's branch to k.Promote lands the quote in the // header's branch and the assertion fails. func TestPromotionRunsAtDraftsBranchWithKillSwitchOn(t *testing.T) { + // The kill switch flip is global database state every package's fixtures + // read (a non admin caller elsewhere suddenly needs X-Branch-Id), so it + // rides the outbox lock the other settings flippers take. + testutil.LockOutboxTables(t) db := testutil.RequireDB(t) ctx := context.Background() From 502ac8ecc13c772cae93b82ad3efb4e7e2184c34 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:11:00 -0700 Subject: [PATCH 05/74] test(serve): a grant body with trailing data must not reach the handler, red first The second review proved the wall and the handler read the body differently: the wall's json.Unmarshal fails on a body with trailing data and so names no branch, while the grant and home branch handlers' json.Decoder reads the first value and ignores the rest, so a key bound to A wrote a grant for B and moved a home branch to B with no audit row. Five bodies from the review, red at 7ef2d1d: all five land the write with 204. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 3ae08a49..69f65af5 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -313,6 +313,17 @@ func (f *keyPinFixture) hasGrant(t *testing.T, sub string, branch uuid.UUID) boo return ok } +// homeBranch reads the branch a user's is_home grant row names. +func (f *keyPinFixture) homeBranch(t *testing.T, sub string) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT branch_id FROM user_locations WHERE user_sub = $1 AND is_home`, sub).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + func (f *keyPinFixture) exposureEvents(t *testing.T, quote uuid.UUID) int { t.Helper() var n int @@ -441,6 +452,36 @@ func TestKeyBranchPin_UserGrants(t *testing.T) { f.allow(t, "unbound", "PUT", "/api/v1/users/pin-free-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) f.allow(t, "user", "PUT", "/api/v1/users/pin-user-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + // A body the handler would read only in part never reaches it: the wall + // parses the whole body exactly as the handler's own decoder would have to, + // and a valid object followed by anything at all is refused for the bound + // key with its audit row and no write. Each body below names the foreign + // branch in its first value, which is all the handler's json.Decoder reads. + for _, body := range []string{ + fmt.Sprintf(`{"branch_id":%q} {}`, B), + fmt.Sprintf(`{"branch_id":%q}xyz`, B), + fmt.Sprintf(`{"branch_id":%q} 1`, B), + } { + f.refuseForeign(t, "POST", "/api/v1/users/pin-trail-grant/branches", body, nil) + if f.hasGrant(t, "pin-trail-grant", f.branchB) { + t.Errorf("grant body with trailing data %q wrote the foreign grant row", body) + } + } + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) + VALUES ('pin-trail-home', $1, TRUE, 'test'), ('pin-trail-home', $2, FALSE, 'test')`, f.branchA, f.branchB); err != nil { + t.Fatalf("seed trail home grants: %v", err) + } + for _, body := range []string{ + fmt.Sprintf(`{"branch_id":%q} {}`, B), + fmt.Sprintf(`{"branch_id":%q}]`, B), + } { + f.refuseForeign(t, "PUT", "/api/v1/users/pin-trail-home/home-branch", body, nil) + if home := f.homeBranch(t, "pin-trail-home"); home != f.branchA { + t.Errorf("home branch body with trailing data %q moved the home to %s", body, home) + } + } + // Revoke: path branch_id. f.refuseForeign(t, "DELETE", "/api/v1/users/pin-both/branches/"+B, "", nil) if !f.hasGrant(t, "pin-both", f.branchB) { From af9b3f7ebe871c0bf49b55b3c591225197645e34 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:12:47 -0700 Subject: [PATCH 06/74] fix(middleware): the body branch wall reads the whole body or refuses it BodyBranch parsed with json.Unmarshal and let an unparsable body through as naming no branch, while the grant and home branch handlers decode with json.Decoder and read only the first value: a valid object followed by trailing data passed the wall and the handler acted on the first value. The wall now decodes into the same branch_id uuid.UUID the handlers decode, and a body that is not exactly one complete JSON value is refused for a bound key with the same 403 and key.branch_refused row as a foreign branch, so the wall and the handler can never disagree about what a body names. A body that parses and names no branch (absent, null) still passes to the handler's own 400, and the explicit nil UUID now meets the handler's branch_id required check rather than a wall refusal. Users and unbound keys never reach the parse. Signed-off-by: colton --- core/pkg/middleware/keybranch.go | 56 ++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index db4330e9..d283aca6 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -71,34 +71,50 @@ func (w *KeyBranchWall) NamedBranch(pathValue string) func(http.Handler) http.Ha }) } -// BodyBranch wraps a handler whose JSON body names a branch in branch_id. A -// body that names no branch (absent, empty, malformed) passes through: the -// handler's own validation answers it, and the wall never turns a 400 into a -// 403. The body is restored for the handler. +// BodyBranch wraps a handler whose JSON body names a branch in branch_id. The +// wall decodes the body into the same branch_id field and the same uuid.UUID +// the handler decodes, and only a body that is exactly one complete JSON value +// reaches that comparison: a body the handler's own json.Decoder would read +// only in part (a second value or trailing bytes after the first) or not at +// all is refused for a bound key, so the wall and the handler can never +// disagree about what a body names. A body that parses and names no branch +// (absent, null) passes through to the handler's own validation. The body is +// restored for the handler. func (w *KeyBranchWall) BodyBranch() func(http.Handler) http.Handler { return w.wrap("branch_id", "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { - raw, err := io.ReadAll(r.Body) - if err != nil { - return uuid.Nil, false, fmt.Errorf("key branch wall: read body: %w", err) - } - r.Body = io.NopCloser(bytes.NewReader(raw)) var body struct { - BranchID string `json:"branch_id"` + BranchID uuid.UUID `json:"branch_id"` } - if err := json.Unmarshal(raw, &body); err != nil { - return uuid.Nil, false, nil + if err := decodeOneJSON(r, &body); err != nil { + return uuid.Nil, false, err } - if body.BranchID == "" { + if body.BranchID == uuid.Nil { return uuid.Nil, false, nil } - id, err := uuid.Parse(body.BranchID) - if err != nil { - return uuid.Nil, false, nil // the handler answers the malformed id - } - return id, true, nil + return body.BranchID, true, nil }) } +// errRefused tells wrap the request is refused for a bound key rather than +// failed: a body the wall cannot read as one complete JSON value. +var errRefused = errors.New("key branch wall: refused") + +// decodeOneJSON reads the whole body, restores it for the handler, and decodes +// it into v only when it is exactly one complete JSON value. Anything else +// (unparsable, truncated, trailing data after the first value) answers +// errRefused. +func decodeOneJSON(r *http.Request, v any) error { + raw, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("key branch wall: read body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + if err := json.Unmarshal(raw, v); err != nil { + return errRefused + } + return nil +} + // RowBranch wraps a handler whose path names a row ("id") whose branch // branchOf resolves: a location, a quote. A row that does not exist, or that // belongs to no branch, names no branch and passes to the handler's own @@ -173,6 +189,10 @@ func (w *KeyBranchWall) wrap(field, reason string, named func(ctx context.Contex } id, has, err := named(r.Context(), r) if err != nil { + if errors.Is(err, errRefused) { + w.refuse(r.Context(), rw, r, pin, field, reason) + return + } slog.Error("key branch wall: branch lookup failed", "error", err, "method", r.Method, "path", r.URL.Path) respondAuthError(rw, r, http.StatusInternalServerError, "internal_error", "branch lookup failed") return From afb06465224a0fb9db852ca3114f13dfcae1f982 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:15:11 -0700 Subject: [PATCH 07/74] test(serve): the dealer wide surface refuses a bound key, red first The sweep the reviews ran past the wall: the reporting module in all three of its registrations, the events feed, the two exposure lists, the GL, AP and bank reconciliation reads, the sales team reads, the known users list and the market index refresh are all open to a branch bound key today (30 routes, each one landing its module answer instead of the 403), while the unbound key and the user already answer the same as each other on every route. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 117 +++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 69f65af5..958fe9c0 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -22,11 +22,18 @@ import ( "strings" "testing" + "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/bankrecon" + "github.com/gablelbm/gable/internal/events" + "github.com/gablelbm/gable/internal/gl" "github.com/gablelbm/gable/internal/location" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/quote" + "github.com/gablelbm/gable/internal/reporting" + "github.com/gablelbm/gable/internal/salesteam" "github.com/gablelbm/gable/internal/techadmin" "github.com/gablelbm/gable/internal/testutil" + glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -58,10 +65,13 @@ type keyPinFixture struct { } // pinKeyScopes opens every route under test: the location writes, the branch -// directory, the user grants and the exposure surface. +// directory, the user grants, the exposure surface and the dealer wide reads. var pinKeyScopes = []string{ "locations:write", "branches:read", "branches:write", "users:read", "users:grants", "quotes:read", "quotes:write", "admin:write", + "reports:read", "reporting:read", "reporting:write", "events:read", + "gl:read", "gl:write", "ap:read", "bankrecon:read", "sales-team:read", + "market-indices:write", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -122,7 +132,8 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { wall.locations(mux, location.NewHandler(location.NewService(location.NewRepository(db)), location.NewUserRepository(db), middleware.RequireRole("admin", "owner"))) - // The exposure surface exactly as wireExposure registers it. + // The exposure surface exactly as wireExposure registers it (the index + // admin surface with the market index refresh comes with it). escRepo := pricing.NewEscalatorRepository(db) exposureRepo := pricing.NewExposureRepository(db) quoteRepo := quote.NewRepository(db) @@ -140,6 +151,23 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { AuditLog: audit.NewLogger(db), }) + // The dealer wide modules exactly as serve registers them: the reporting + // surface in its three registrations, the events feed, the GL, AP and bank + // reconciliation books and the sales team reads. + reportingHandler := reporting.NewHandler(reporting.NewService(reporting.NewRepository(db))) + reportingHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + wireReportSchedules(mux, reportingHandler, nil) + reportingHandler.RegisterBIIntegrationRoutes(mux, middleware.RequireRole("admin", "owner")) + events.NewHandler(db).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + glSvc := gl.NewService(gl.NewRepository(db), glint.NewMockGLAdapter(), slog.Default()) + gl.NewHandler(glSvc).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + ap.NewHandler(ap.NewService(db, ap.NewRepository(db), glSvc, slog.Default())). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + bankrecon.NewHandler(bankrecon.NewService(db, bankrecon.NewRepository(db), glSvc, slog.Default())). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + salesteam.NewHandler(salesteam.NewRepository(db)). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { t.Helper() @@ -291,6 +319,18 @@ func (f *keyPinFixture) allow(t *testing.T, who, method, path, body string, hdr return buf } +// parity asserts the unbound key and the user keep the same answer on a +// route: the reach they hold wherever the wall refuses the bound key. +func (f *keyPinFixture) parity(t *testing.T, method, path, body string) int { + t.Helper() + sk, _ := f.key(t, f.unbound, method, path, body, nil) + su, _ := f.user(t, method, path, body, nil) + if sk != su { + t.Errorf("unbound key %s %s: %d, user: %d, want the same answer", method, path, sk, su) + } + return sk +} + // locState reads a location row's revision, name and active flag. func (f *keyPinFixture) locState(t *testing.T, id uuid.UUID) (revision int64, name string, active bool) { t.Helper() @@ -595,3 +635,76 @@ func TestKeyBranchPin_ExposureRoutes(t *testing.T) { t.Errorf("bound key with foreign X-Branch-Id: %d %s, want 403", status, buf) } } + +// TestKeyBranchPin_DealerWideReads holds the pin on the dealer wide surface: +// every reporting route, the events feed, the two exposure lists, the GL, AP, +// bank reconciliation and sales team reads, the known users list and the +// market index refresh refuse a branch bound key outright with their audit +// row, while the unbound key and the user keep the same answer as each other +// on every route, and the GL writes (no branch fact, outside the ruling) keep +// their reach for every principal alike. +func TestKeyBranchPin_DealerWideReads(t *testing.T) { + f := newKeyPinFixture(t) + preview := `{"entity_type":"invoices","definition":{}}` + for _, c := range []struct{ method, path, body string }{ + {"GET", "/api/v1/reports/sales-summary", ""}, + {"GET", "/api/v1/reports/daily-till", ""}, + {"GET", "/api/v1/reports/ar-aging", ""}, + {"GET", "/api/v1/reports/customer-statement/" + f.custID.String(), ""}, + {"GET", "/api/v1/reporting/export/invoices", ""}, + {"POST", "/api/v1/reporting/builder/preview", preview}, + {"POST", "/api/v1/reporting/builder/export", preview}, + {"GET", "/api/v1/reporting/saved", ""}, + {"POST", "/api/v1/reporting/saved/" + uuid.NewString() + "/run", ""}, + {"GET", "/api/v1/reporting/schedules", ""}, + {"POST", "/api/v1/reporting/schedules", `{"name":"pin"}`}, + {"GET", "/api/v1/events", ""}, + {"GET", "/api/v1/quotes/exposure", ""}, + {"GET", "/api/v1/reports/exposure", ""}, + {"GET", "/api/v1/gl/accounts", ""}, + {"GET", "/api/v1/gl/journal-entries", ""}, + {"GET", "/api/v1/gl/journal-entries/" + uuid.NewString(), ""}, + {"GET", "/api/v1/gl/trial-balance", ""}, + {"GET", "/api/v1/gl/profit-and-loss", ""}, + {"GET", "/api/v1/gl/balance-sheet", ""}, + {"GET", "/api/v1/gl/fiscal-periods", ""}, + {"GET", "/api/v1/ap/invoices", ""}, + {"GET", "/api/v1/ap/payments", ""}, + {"GET", "/api/v1/ap/aging", ""}, + {"GET", "/api/v1/bankrecon/accounts", ""}, + {"GET", "/api/v1/bankrecon/sessions", ""}, + {"GET", "/api/v1/sales-team", ""}, + {"GET", "/api/v1/sales-team/" + uuid.NewString(), ""}, + {"GET", "/api/v1/users", ""}, + {"POST", "/api/v1/market-indices/" + uuid.NewString() + "/refresh", ""}, + } { + f.refuseForeign(t, c.method, c.path, c.body, nil) + f.parity(t, c.method, c.path, c.body) + } + + // The reads that answer data keep answering it to the unbound key: the + // summaries and lists the second review read branch B rows through. + for _, path := range []string{ + "/api/v1/reports/sales-summary", "/api/v1/reports/ar-aging", + "/api/v1/reports/customer-statement/" + f.custID.String(), + "/api/v1/events", "/api/v1/quotes/exposure", "/api/v1/gl/accounts", + "/api/v1/ap/invoices", "/api/v1/sales-team", "/api/v1/users", + } { + if sk := f.parity(t, "GET", path, ""); sk != http.StatusOK { + t.Errorf("unbound key GET %s: %d, want 200", path, sk) + } + } + + // The GL writes carry no branch fact and are outside the ruling: every + // principal meets the same module answer, none a wall refusal. + body := `{"name":"pin wall probe"}` + sk, _ := f.key(t, f.unbound, "POST", "/api/v1/gl/accounts", body, nil) + su, _ := f.user(t, "POST", "/api/v1/gl/accounts", body, nil) + sb, bb := f.key(t, f.bound, "POST", "/api/v1/gl/accounts", body, nil) + if sk != su || sb != sk { + t.Errorf("POST /api/v1/gl/accounts: unbound %d, user %d, bound %d (%s), want one answer", sk, su, sb, bb) + } + if sb == http.StatusForbidden && strings.Contains(string(bb), "branch bound key") { + t.Errorf("POST /api/v1/gl/accounts: the GL writes are outside the ruling and must not hit the wall: %s", bb) + } +} From 2fcf981b0cf4cff7dc52f5d4c429407429c85b9a Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:18:21 -0700 Subject: [PATCH 08/74] fix(serve): the dealer wide surface refuses a branch bound key The lead's ruling on the reads and writes the sweep missed (TO-PRINCIPAL 51): every reporting route in its three registrations, the events feed, the two exposure lists, the GL, AP and bank reconciliation reads, the sales team reads, the known users list and the market index refresh now refuse a branch bound key outright with the key.branch_refused row. The GL, AP and bank reconciliation writes carry no branch fact and keep the role guard alone for every principal, as does the index metadata and history beside the refresh. Users and unbound keys keep exactly their reach: the test walks all thirty routes with the unbound key and the user and they answer the same as each other, 200 on the reads that answer data. Signed-off-by: colton --- core/internal/app/serve/serve.go | 23 ++++---- core/internal/app/serve/wire_branch_wall.go | 52 +++++++++++++++++++ core/internal/app/serve/wire_exposure.go | 43 +++++++++++---- .../app/serve/wire_key_branch_pin_test.go | 18 +++---- core/internal/app/serve/wire_schedules.go | 4 +- core/internal/location/handler.go | 8 +-- 6 files changed, 114 insertions(+), 34 deletions(-) diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index a9ff18d5..e374c7e9 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -64,8 +64,8 @@ import ( "github.com/gablelbm/gable/internal/reporting" "github.com/gablelbm/gable/internal/salesteam" "github.com/gablelbm/gable/internal/tax" - "github.com/gablelbm/gable/internal/unit" "github.com/gablelbm/gable/internal/techadmin" + "github.com/gablelbm/gable/internal/unit" "github.com/gablelbm/gable/internal/vendor" "github.com/gablelbm/gable/internal/vision" "github.com/gablelbm/gable/pkg/apps" @@ -322,7 +322,8 @@ func Run() { // Sales Team Module salesTeamRepo := salesteam.NewRepository(db) salesTeamHandler := salesteam.NewHandler(salesTeamRepo) - salesTeamHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + salesTeamHandler.RegisterRoutes(mux, + wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) // CRM Module: on the wire contract, its writes in one transaction with // their audit row and activity.* event, every route behind the branch @@ -347,7 +348,7 @@ func Run() { glRepo := gl.NewRepository(db) glSvc := gl.NewService(glRepo, glAdapter, logger) glHandler := gl.NewHandler(glSvc) - glHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + glHandler.RegisterRoutes(mux, wall.dealerWideReads("the general ledger spans every branch", "admin", "owner")) // Account Module: the AR core, the single writer of the subledger, the // balance, the applications and the AR columns of the documents (ADR 0005 @@ -670,7 +671,7 @@ func Run() { apRepo := ap.NewRepository(db) apSvc := ap.NewService(db, apRepo, glSvc, logger) apHandler := ap.NewHandler(apSvc) - apHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + apHandler.RegisterRoutes(mux, wall.dealerWideReads("payables span every branch", "admin", "owner", "finance")) // 3-Way PO Matching Module matchingRepo := matching.NewRepository(db) @@ -681,13 +682,14 @@ func Run() { bankreconRepo := bankrecon.NewRepository(db) bankreconSvc := bankrecon.NewService(db, bankreconRepo, glSvc, logger) bankreconHandler := bankrecon.NewHandler(bankreconSvc) - bankreconHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + bankreconHandler.RegisterRoutes(mux, + wall.dealerWideReads("bank reconciliation spans every branch", "admin", "owner", "finance")) // Reporting Module reportingRepo := reporting.NewRepository(db) reportingSvc := reporting.NewService(reportingRepo) reportingHandler := reporting.NewHandler(reportingSvc) - reportingHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + wall.reporting(mux, reportingHandler) // Scheduled report delivery. Loads every ACTIVE row from report_schedules, // runs each on the cron engine, and emails the rendered CSV/XLSX to its @@ -701,12 +703,12 @@ func Run() { if err := reportScheduler.Start(context.Background()); err != nil { logger.Error("report scheduler failed to start", "error", err) } - wireReportSchedules(mux, reportingHandler, reportScheduler) + wall.reportingSchedules(mux, reportingHandler, reportScheduler) logger.Info("Scheduled report delivery enabled", "cron_dialect", "six fields, seconds first", "delivery", reportScheduler.DeliveryDescription()) - reportingHandler.RegisterBIIntegrationRoutes(mux, middleware.RequireRole("admin", "owner")) + wall.reportingBIIntegration(mux, reportingHandler) // The idempotency retention purge no longer runs here: R1-4 moved it to // the worker role (internal/app/worker), so `core worker` owns it. @@ -835,9 +837,10 @@ func Run() { // Events feed: the outbox read API (item R1-12), the one cursor-paginated // feed of every domain event. Role gated admin/owner like the other admin - // reads; agents and integrations poll it with their own cursors. + // reads; agents and integrations poll it with their own cursors. The feed + // carries every branch's events, so a branch bound key is refused it. eventsHandler := events.NewHandler(db) - eventsHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + wall.eventsFeed(mux, eventsHandler) // Portal Module (Sovereign Dealer Portal) // Resolve JWT secret: required in production, uses dev default in dev mode only. diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index b02a695a..1bda8c98 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -13,6 +13,7 @@ import ( "github.com/gablelbm/gable/internal/customer/customerguard" "github.com/gablelbm/gable/internal/delivery" "github.com/gablelbm/gable/internal/document" + "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/inventory" "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/location" @@ -22,6 +23,7 @@ import ( "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/internal/quote" + "github.com/gablelbm/gable/internal/reporting" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -52,6 +54,56 @@ func newBranchWall(db *database.DB) *branchWall { return w } +// reporting mounts the reporting surface in its three registrations: every +// report spans every branch's rows (the summaries, the till, the aging, the +// statements, the builder, the exports, the saved runs and the schedules), so +// a branch bound key is refused outright and the role guard alone serves +// everyone else (ADR 0007 section 5.5). Three methods because serve builds +// the scheduler between the registrations. +func (w *branchWall) reporting(mux *http.ServeMux, h *reporting.Handler) { + h.RegisterRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner", "finance"), + w.keyWall.RefuseBound("reports span every branch"))) +} + +// reportingSchedules is reporting over the builder and schedule routes. +func (w *branchWall) reportingSchedules(mux *http.ServeMux, h *reporting.Handler, executor reporting.ScheduleExecutor) { + wireReportSchedules(mux, h, executor, w.keyWall.RefuseBound("reports span every branch")) +} + +// reportingBIIntegration is reporting over the BI export routes. +func (w *branchWall) reportingBIIntegration(mux *http.ServeMux, h *reporting.Handler) { + h.RegisterBIIntegrationRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner"), + w.keyWall.RefuseBound("reports span every branch"))) +} + +// eventsFeed mounts the events feed: the outbox carries every branch's events +// with their payloads, so a branch bound key is refused outright and the role +// guard alone serves everyone else. +func (w *branchWall) eventsFeed(mux *http.ServeMux, h *events.Handler) { + h.RegisterRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner"), + w.keyWall.RefuseBound("the event feed spans every branch"))) +} + +// dealerWideReads is the guard for a module whose books carry no branch +// dimension (the GL, AP and bank reconciliation) and the sales team roster: +// the reads span every branch, so a branch bound key is refused them, while +// the writes, which carry no branch fact, keep the role guard alone for every +// principal alike. +func (w *branchWall) dealerWideReads(reason string, roles ...string) func(http.Handler) http.Handler { + role := middleware.RequireRole(roles...) + reads := middleware.Compose(role, w.keyWall.RefuseBound(reason)) + return func(next http.Handler) http.Handler { + readHandler, writeHandler := reads(next), role(next) + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + readHandler.ServeHTTP(rw, r) + return + } + writeHandler.ServeHTTP(rw, r) + }) + } +} + // chargeCodes mounts the charge code master: every role that prices a line // reads it, and only admin, owner and finance write it (a code carries its // revenue account). diff --git a/core/internal/app/serve/wire_exposure.go b/core/internal/app/serve/wire_exposure.go index bb15ba54..4f97e512 100644 --- a/core/internal/app/serve/wire_exposure.go +++ b/core/internal/app/serve/wire_exposure.go @@ -8,6 +8,7 @@ import ( "errors" "log/slog" "net/http" + "strings" "github.com/gablelbm/gable/internal/delivery" "github.com/gablelbm/gable/internal/order" @@ -176,9 +177,9 @@ func registerExposureRoutes(mux *http.ServeMux, r exposureRoutes) { // Salesperson + owner surface: at-risk list, per-quote detail and actions, // portfolio report, admin scan trigger. The key branch wall holds a branch // bound key to its pin on the by id routes (their quote's branch) and - // refuses it the dealer wide scan; the two lists narrow no further here - // (narrowing them changes the exposure repository's contract, and the - // sweep lists them for the lead). + // refuses it the dealer wide routes outright: the scan re-checks every + // branch's quotes and the two lists (the at-risk and portfolio reports) + // return every branch's exposure rows. var keyAuditor middleware.BranchRefusalAuditor // a nil logger must not ride a non nil interface if r.AuditLog != nil { keyAuditor = r.AuditLog @@ -193,9 +194,30 @@ func registerExposureRoutes(mux *http.ServeMux, r exposureRoutes) { // Buyer/admin surface: index refresh (+ dry-run preview), metadata edit, // history time-series. Deliberately does NOT re-register // GET /api/v1/market-indices: that belongs to pricing.EscalatorHandler and - // a duplicate pattern would panic the ServeMux. + // a duplicate pattern would panic the ServeMux. The refresh re-checks + // every branch's quotes, the same dealer wide effect the scan refusal + // closes, so a branch bound key is refused it; the index metadata and + // history are dealer wide reference data and keep the role guard alone. pricing.NewIndexAdminHandler(r.Escalators, r.Exposure, r.Scanner, r.DB, r.Logger). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + RegisterRoutes(mux, indexAdminGuard(keyWall)) +} + +// indexAdminGuard holds the market index admin surface to a branch bound key's +// pin where it acts across branches: the refresh suffix route is refused +// outright, everything else keeps the role guard alone. +func indexAdminGuard(keyWall *middleware.KeyBranchWall) func(http.Handler) http.Handler { + role := middleware.RequireRole("admin", "owner") + refresh := middleware.Compose(role, keyWall.RefuseBound("the index refresh re-checks every branch's quotes")) + return func(next http.Handler) http.Handler { + refreshHandler, otherHandler := refresh(next), role(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/refresh") { + refreshHandler.ServeHTTP(w, r) + return + } + otherHandler.ServeHTTP(w, r) + }) + } } // quoteBranchOf resolves the branch a quote belongs to, the row branch the @@ -219,20 +241,21 @@ func (r exposureRoutes) quoteBranchOf(ctx context.Context, id uuid.UUID) (*uuid. // exposureKeyBranchWall routes the exposure surface's handlers through the // wall their request shape demands: the five by id quote routes hold their -// quote's branch to the pin, the admin scan refuses a bound key (it acts -// across every branch), and the two list routes pass unchanged. +// quote's branch to the pin, and the admin scan and the two list routes (the +// at-risk and portfolio reports, which return every branch's rows) refuse a +// bound key outright. func exposureKeyBranchWall(keyWall *middleware.KeyBranchWall, quoteBranchOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { byID := keyWall.RowBranch(quoteBranchOf)(next) - scan := keyWall.RefuseBound("the scan runs across every branch")(next) + dealerWide := keyWall.RefuseBound("the exposure surface acts across every branch")(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/v1/admin/exposure-scan": - scan.ServeHTTP(w, r) + dealerWide.ServeHTTP(w, r) case r.PathValue("id") != "": byID.ServeHTTP(w, r) default: - next.ServeHTTP(w, r) + dealerWide.ServeHTTP(w, r) } }) } diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 958fe9c0..95ec5834 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -26,6 +26,7 @@ import ( "github.com/gablelbm/gable/internal/bankrecon" "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/gl" + glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/internal/location" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/quote" @@ -33,7 +34,6 @@ import ( "github.com/gablelbm/gable/internal/salesteam" "github.com/gablelbm/gable/internal/techadmin" "github.com/gablelbm/gable/internal/testutil" - glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -155,18 +155,18 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { // surface in its three registrations, the events feed, the GL, AP and bank // reconciliation books and the sales team reads. reportingHandler := reporting.NewHandler(reporting.NewService(reporting.NewRepository(db))) - reportingHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) - wireReportSchedules(mux, reportingHandler, nil) - reportingHandler.RegisterBIIntegrationRoutes(mux, middleware.RequireRole("admin", "owner")) - events.NewHandler(db).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + wall.reporting(mux, reportingHandler) + wall.reportingSchedules(mux, reportingHandler, nil) + wall.reportingBIIntegration(mux, reportingHandler) + wall.eventsFeed(mux, events.NewHandler(db)) glSvc := gl.NewService(gl.NewRepository(db), glint.NewMockGLAdapter(), slog.Default()) - gl.NewHandler(glSvc).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + gl.NewHandler(glSvc).RegisterRoutes(mux, wall.dealerWideReads("the general ledger spans every branch", "admin", "owner")) ap.NewHandler(ap.NewService(db, ap.NewRepository(db), glSvc, slog.Default())). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + RegisterRoutes(mux, wall.dealerWideReads("payables span every branch", "admin", "owner", "finance")) bankrecon.NewHandler(bankrecon.NewService(db, bankrecon.NewRepository(db), glSvc, slog.Default())). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + RegisterRoutes(mux, wall.dealerWideReads("bank reconciliation spans every branch", "admin", "owner", "finance")) salesteam.NewHandler(salesteam.NewRepository(db)). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + RegisterRoutes(mux, wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { diff --git a/core/internal/app/serve/wire_schedules.go b/core/internal/app/serve/wire_schedules.go index f690d35a..16bca4c9 100644 --- a/core/internal/app/serve/wire_schedules.go +++ b/core/internal/app/serve/wire_schedules.go @@ -48,11 +48,11 @@ import ( // API rejects five-field crontab strings up front via // reporting.ValidateCronExpression rather than storing schedules the engine // would refuse, and advertises the dialect on the read path. -func wireReportSchedules(mux *http.ServeMux, h *reporting.Handler, executor reporting.ScheduleExecutor) { +func wireReportSchedules(mux *http.ServeMux, h *reporting.Handler, executor reporting.ScheduleExecutor, extra ...func(http.Handler) http.Handler) { if executor != nil { h = h.WithScheduleExecutor(executor) } - h.RegisterBuilderRoutes(mux, reportScheduleGuard()) + h.RegisterBuilderRoutes(mux, middleware.Compose(reportScheduleGuard(), middleware.Compose(extra...))) } // reportScheduleGuard is the role guard the builder and schedule routes run diff --git a/core/internal/location/handler.go b/core/internal/location/handler.go index d0cc2d7c..b057b356 100644 --- a/core/internal/location/handler.go +++ b/core/internal/location/handler.go @@ -55,8 +55,9 @@ func (h *Handler) WithBranchWall(g LocationGuard, branchMw func(http.Handler) ht // branch directory verbs and the user grant routes hold a branch bound key // to its pin, refusing it another branch with the key.branch_refused row. // The branch reads the wall narrows are /branches/{id}/users (refused for -// another branch) and /users/{sub}/branches (filtered to the pin). Without -// it nothing changes, so serve always sets it. +// another branch) and /users/{sub}/branches (filtered to the pin), and the +// known users list (/users, every branch's subs) refuses a bound key +// outright. Without it nothing changes, so serve always sets it. func (h *Handler) WithKeyBranchWall(w *middleware.KeyBranchWall) *Handler { h.keyWall = w return h @@ -112,6 +113,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand bodyBranchWall := wall(h.keyWall.BodyBranch()) branchIdPathWall := wall(h.keyWall.NamedBranch("branch_id")) directoryWall := wall(h.keyWall.RefuseBound("the branch directory is outside a branch bound key")) + knownUsersWall := wall(h.keyWall.RefuseBound("the known users list spans every branch")) // Legacy / shared location endpoints. create := http.HandlerFunc(h.CreateLocation) @@ -143,7 +145,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand // User-branch grants. if h.userRepo != nil { mux.HandleFunc("GET /api/v1/me/branches", guard(h.ListMyBranches)) - mux.HandleFunc("GET /api/v1/users", adminGuard(h.ListKnownUsers)) + mux.HandleFunc("GET /api/v1/users", adminGuard(knownUsersWall(h.ListKnownUsers))) mux.HandleFunc("GET /api/v1/users/{sub}/branches", adminGuard(h.ListUserBranches)) mux.HandleFunc("POST /api/v1/users/{sub}/branches", adminGuard(bodyBranchWall(h.GrantUserBranch))) mux.HandleFunc("DELETE /api/v1/users/{sub}/branches/{branch_id}", adminGuard(branchIdPathWall(h.RevokeUserBranch))) From 0d6ea2df2cff92b781366f8cd85b2ae8ec19d8e7 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:27:27 -0700 Subject: [PATCH 09/74] test(serve): customer scoped writes confine a bound key, red first The lead's ruling on the customer scoped writes: the tax exemption writes and the customer priced rules (plain and category, single and bulk) are open to a branch bound key today, so a key pinned to A writes branch B's customers' data: eight write families land their module answer instead of the 403, with the rows written. The unbound key and the user answer the same as each other on every route, each against its own fresh target. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 286 +++++++++++++++++- 1 file changed, 270 insertions(+), 16 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 95ec5834..18854d80 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -23,15 +23,20 @@ import ( "testing" "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/app/orderwire" "github.com/gablelbm/gable/internal/bankrecon" + "github.com/gablelbm/gable/internal/config" + "github.com/gablelbm/gable/internal/customer" "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/gl" glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/internal/location" "github.com/gablelbm/gable/internal/pricing" + "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/quote" "github.com/gablelbm/gable/internal/reporting" "github.com/gablelbm/gable/internal/salesteam" + "github.com/gablelbm/gable/internal/tax" "github.com/gablelbm/gable/internal/techadmin" "github.com/gablelbm/gable/internal/testutil" "github.com/gablelbm/gable/pkg/audit" @@ -48,20 +53,25 @@ import ( // routes through branchWall.locations and the exposure routes through // registerExposureRoutes, both behind the real machine key core. type keyPinFixture struct { - t *testing.T - db *database.DB - srv *httptest.Server - branchA uuid.UUID - branchB uuid.UUID - branchC uuid.UUID - branchC2 uuid.UUID - yardA uuid.UUID - yardB uuid.UUID - quoteA uuid.UUID - quoteB uuid.UUID - custID uuid.UUID - bound string // the key pinned to branch A - unbound string // the same scopes, no pin + t *testing.T + db *database.DB + srv *httptest.Server + branchA uuid.UUID + branchB uuid.UUID + branchC uuid.UUID + branchC2 uuid.UUID + yardA uuid.UUID + yardB uuid.UUID + quoteA uuid.UUID + quoteB uuid.UUID + custID uuid.UUID + custB uuid.UUID // a customer whose branches hold branch B only + category uuid.UUID // one product category the priced rules hang on + catRuleA uuid.UUID // a category rule scoped to the pin's customer + catRuleB uuid.UUID // a category rule scoped to the foreign customer + taxExempt uuid.UUID // a tax exemption row of the foreign customer + bound string // the key pinned to branch A + unbound string // the same scopes, no pin } // pinKeyScopes opens every route under test: the location writes, the branch @@ -71,7 +81,7 @@ var pinKeyScopes = []string{ "users:read", "users:grants", "quotes:read", "quotes:write", "admin:write", "reports:read", "reporting:read", "reporting:write", "events:read", "gl:read", "gl:write", "ap:read", "bankrecon:read", "sales-team:read", - "market-indices:write", + "market-indices:write", "tax:write", "pricing:read", "pricing:write", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -83,6 +93,7 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { f := &keyPinFixture{t: t, db: db, branchA: uuid.New(), branchB: uuid.New(), branchC: uuid.New(), branchC2: uuid.New(), yardA: uuid.New(), yardB: uuid.New(), quoteA: uuid.New(), quoteB: uuid.New(), custID: uuid.New(), + custB: uuid.New(), category: uuid.New(), catRuleA: uuid.New(), catRuleB: uuid.New(), taxExempt: uuid.New(), } for _, r := range []struct { id uuid.UUID @@ -111,13 +122,50 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { VALUES ('pin-both', $1, TRUE, 'test'), ('pin-both', $2, FALSE, 'test')`, f.branchA, f.branchB); err != nil { t.Fatalf("seed grants: %v", err) } + // The customer confinement rows: a customer per branch with its + // customer_branches row, a product category, one priced category rule per + // customer, and a tax exemption of the foreign customer. + if _, err := db.Pool.Exec(ctx, `INSERT INTO customers (id, name, account_number, primary_branch_id) + VALUES ($1, 'pin cust B', $2, $3)`, f.custB, "PINB-"+f.custB.String()[:8], f.branchB); err != nil { + t.Fatalf("seed customer B: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2), ($3, $4)`, + f.custID, f.branchA, f.custB, f.branchB); err != nil { + t.Fatalf("seed customer branches: %v", err) + } + if _, err := db.Pool.Exec(ctx, `INSERT INTO product_categories (id, name, slug, path) + VALUES ($1, 'pin cat', $2, $3)`, f.category, "pin-cat-"+f.category.String()[:8], "pin."+f.category.String()[:8]); err != nil { + t.Fatalf("seed category: %v", err) + } + for _, r := range []struct{ id, customer uuid.UUID }{{f.catRuleA, f.custID}, {f.catRuleB, f.custB}} { + if _, err := db.Pool.Exec(ctx, + `INSERT INTO category_pricing_rules (id, target_type, customer_id, category_id, rule_type, rule_value, created_by) + VALUES ($1, 'ACCOUNT', $2, $3, 'MARKUP', 10, 'test')`, r.id, r.customer, f.category); err != nil { + t.Fatalf("seed category rule: %v", err) + } + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO tax_exemptions (id, customer_id, exempt_reason) VALUES ($1, $2, 'pin')`, f.taxExempt, f.custB); err != nil { + t.Fatalf("seed tax exemption: %v", err) + } t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' AND entity_id IN (SELECT id::text FROM quote_exposure_events WHERE quote_id IN ($1, $2))`, f.quoteA, f.quoteB) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote' AND entity_id IN ($1, $2)`, f.quoteA, f.quoteB) _, _ = db.Pool.Exec(ctx, `DELETE FROM quote_exposure_events WHERE quote_id IN ($1, $2)`, f.quoteA, f.quoteB) _, _ = db.Pool.Exec(ctx, `DELETE FROM quotes WHERE id IN ($1, $2)`, f.quoteA, f.quoteB) - _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, f.custID) + _, _ = db.Pool.Exec(ctx, `DELETE FROM tax_exemptions WHERE customer_id IN ($1, $2) OR id = $3`, f.custID, f.custB, f.taxExempt) + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'category_rule' + AND entity_id IN (SELECT id::text FROM category_pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'pricing_rule' + AND entity_id IN (SELECT id::text FROM pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_audit WHERE rule_id IN (SELECT id FROM category_pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_rules WHERE customer_id IN ($1, $2)`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pricing_rules WHERE customer_id IN ($1, $2)`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM product_categories WHERE id = $1`, f.category) + _, _ = db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id IN ($1, $2)`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id IN ($1, $2)`, f.custID, f.custB) _, _ = db.Pool.Exec(ctx, `DELETE FROM user_locations WHERE user_sub LIKE 'pin-%' OR user_sub = 'pin-both'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE code LIKE 'pin-new-%'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE parent_id IN ($1, $2, $3, $4)`, f.branchA, f.branchB, f.branchC, f.branchC2) @@ -168,6 +216,20 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { salesteam.NewHandler(salesteam.NewRepository(db)). RegisterRoutes(mux, wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) + // The customer confined modules exactly as serve registers them: the tax + // exemption writes and the customer priced rules. + customerSvc := customer.NewService(customer.NewRepository(db)) + productSvc := product.NewService(product.NewRepository(db)) + tax.NewHandler(orderwire.NewTaxService(db, &config.Config{}, slog.Default())). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + pricing.NewHandler(pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), customerSvc, productSvc). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + pricing.NewCategoryHandler( + pricing.NewCategoryPricingService(pricing.NewCategoryRepository(db)).WithTxRunner(db). + WithOutbox(outbox.NewWriter(db, "")). + WithAudit(pricingAuditAdapter{l: audit.NewLogger(db)}), + customerSvc).RegisterCategoryRoutes(mux, middleware.RequireRole("admin", "owner")) + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { t.Helper() @@ -708,3 +770,195 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { t.Errorf("POST /api/v1/gl/accounts: the GL writes are outside the ruling and must not hit the wall: %s", bb) } } + +// pricingRuleBody builds a plain pricing rule create body for a customer. +// label tells the principals' rules apart: a rule's name and scope is unique. +func pricingRuleBody(label string, customer uuid.UUID) string { + return fmt.Sprintf(`{"name":"pin rule %s","rule_type":"job_override","customer_id":%q,"discount_pct":"5"}`, + label, customer.String()) +} + +// categoryRuleBody builds a category rule create body for a customer on a +// fresh category. +func categoryRuleBody(customer, category uuid.UUID) string { + return fmt.Sprintf(`{"target_type":"account","customer_id":%q,"category_id":%q,"rule_type":"markup","value_pct":"5"}`, + customer.String(), category.String()) +} + +// freshCategory inserts a product category no rule hangs on yet, so a create +// or a bulk element never meets the duplicate the seeded rules hold. +func (f *keyPinFixture) freshCategory(t *testing.T) uuid.UUID { + t.Helper() + id := uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO product_categories (id, name, slug, path) VALUES ($1, 'pin cat', $2, $3)`, + id, "pin-cat-"+id.String()[:8], "pin."+id.String()[:8]); err != nil { + t.Fatalf("seed category: %v", err) + } + return id +} + +// seedCatRule inserts a category rule row for a customer on a category. +func (f *keyPinFixture) seedCatRule(t *testing.T, id, customer, category uuid.UUID) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO category_pricing_rules (id, target_type, customer_id, category_id, rule_type, rule_value, created_by) + VALUES ($1, 'ACCOUNT', $2, $3, 'MARKUP', 10, 'test')`, id, customer, category); err != nil { + t.Fatalf("seed category rule: %v", err) + } +} + +// seedTaxExempt inserts a tax exemption row for a customer. +func (f *keyPinFixture) seedTaxExempt(t *testing.T, id, customer uuid.UUID) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO tax_exemptions (id, customer_id, exempt_reason) VALUES ($1, $2, 'pin')`, id, customer); err != nil { + t.Fatalf("seed tax exemption: %v", err) + } +} + +// exemptionsOf counts a customer's tax exemption rows. +func (f *keyPinFixture) exemptionsOf(t *testing.T, customer uuid.UUID) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM tax_exemptions WHERE customer_id = $1`, customer).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// rulesOf counts a customer's priced rules, plain and category. +func (f *keyPinFixture) rulesOf(t *testing.T, customer uuid.UUID) (plain, category int) { + t.Helper() + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT (SELECT COUNT(*) FROM pricing_rules WHERE customer_id = $1), + (SELECT COUNT(*) FROM category_pricing_rules WHERE customer_id = $1)`, customer).Scan(&plain, &category); err != nil { + t.Fatal(err) + } + return plain, category +} + +// catRuleRev reads a category rule row's revision for an If-Match. +func (f *keyPinFixture) catRuleRev(t *testing.T, id uuid.UUID) string { + t.Helper() + var rev int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT revision FROM category_pricing_rules WHERE id = $1`, id).Scan(&rev); err != nil { + t.Fatal(err) + } + return fmt.Sprintf(`"%d"`, rev) +} + +// TestKeyBranchPin_CustomerConfinedWrites holds the pin on the customer +// confined writes (the lead's ruling): the tax exemption writes and the +// customer priced rules (plain and category, single and bulk) reach a branch +// bound key only while its pin is among the customer's branches +// (customer_branches); a customer outside the pin is refused with the audit +// row and no write, the pin's own customer passes, and the unbound key and +// the user keep the same answer as each other on every route, each against +// its own fresh target where the write would otherwise consume it. +func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { + f := newKeyPinFixture(t) + A, B := f.custID.String(), f.custB.String() + + // Tax exemptions: the create's body names the customer, the delete's row + // belongs to one. + f.refuseForeign(t, "POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, B), nil) + if n := f.exemptionsOf(t, f.custB); n != 1 { + t.Errorf("refused exemption create wrote the foreign customer's rows: %d", n) + } + exemptA := uuid.New() + f.seedTaxExempt(t, exemptA, f.custID) + f.allow(t, "bound", "DELETE", "/api/v1/tax/exemptions/"+exemptA.String(), "", nil, http.StatusNoContent) + f.refuseForeign(t, "DELETE", "/api/v1/tax/exemptions/"+f.taxExempt.String(), "", nil) + if n := f.exemptionsOf(t, f.custB); n != 1 { + t.Errorf("refused exemption delete changed the foreign customer's rows: %d", n) + } + f.allow(t, "bound", "POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, A), nil, http.StatusCreated) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, B), nil, http.StatusCreated) + } + for _, who := range []string{"unbound", "user"} { + id := uuid.New() + f.seedTaxExempt(t, id, f.custB) + f.allow(t, who, "DELETE", "/api/v1/tax/exemptions/"+id.String(), "", nil, http.StatusNoContent) + } + + // Plain pricing rules: the create's body names the customer. + f.refuseForeign(t, "POST", "/api/v1/pricing/rules", pricingRuleBody("refused", f.custB), nil) + if _, n := f.rulesOf(t, f.custB); n != 1 { + t.Errorf("refused pricing rule create wrote the foreign customer's rows: %d", n) + } + f.allow(t, "bound", "POST", "/api/v1/pricing/rules", pricingRuleBody("own", f.custID), nil, http.StatusCreated) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/rules", pricingRuleBody(who, f.custB), nil, http.StatusCreated) + } + + // Category rules: the create's body names the customer, the update and + // the delete act on a row that belongs to one. + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", categoryRuleBody(f.custB, f.freshCategory(t)), nil) + if _, n := f.rulesOf(t, f.custB); n != 1 { + t.Errorf("refused category rule create wrote the foreign customer's rows: %d", n) + } + f.allow(t, "bound", "POST", "/api/v1/pricing/category-rules", + categoryRuleBody(f.custID, f.freshCategory(t)), nil, http.StatusCreated) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/category-rules", + categoryRuleBody(f.custB, f.freshCategory(t)), nil, http.StatusCreated) + } + + putBody := `{"rule_type":"markdown","value_pct":"2"}` + putHdr := func(id uuid.UUID) map[string]string { + return map[string]string{"If-Match": f.catRuleRev(t, id)} + } + f.refuseForeign(t, "PUT", "/api/v1/pricing/category-rules/"+f.catRuleB.String(), putBody, putHdr(f.catRuleB)) + f.allow(t, "bound", "PUT", "/api/v1/pricing/category-rules/"+f.catRuleA.String(), putBody, putHdr(f.catRuleA), http.StatusOK) + ruleFor := func(customer uuid.UUID) uuid.UUID { + id := uuid.New() + f.seedCatRule(t, id, customer, f.freshCategory(t)) + return id + } + for _, who := range []string{"unbound", "user"} { + id := ruleFor(f.custB) + f.allow(t, who, "PUT", "/api/v1/pricing/category-rules/"+id.String(), putBody, putHdr(id), http.StatusOK) + } + + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/"+f.catRuleB.String(), "", putHdr(f.catRuleB)) + if _, n := f.rulesOf(t, f.custB); n != 1 { + t.Errorf("refused category rule delete changed the foreign customer's rows: %d", n) + } + deadA := ruleFor(f.custID) + f.allow(t, "bound", "DELETE", "/api/v1/pricing/category-rules/"+deadA.String(), "", putHdr(deadA), http.StatusNoContent) + for _, who := range []string{"unbound", "user"} { + id := ruleFor(f.custB) + f.allow(t, who, "DELETE", "/api/v1/pricing/category-rules/"+id.String(), "", putHdr(id), http.StatusNoContent) + } + + // Bulk: every element's customer must hold the pin, and a bulk delete's + // rows belong to their customers. + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules/bulk", + "["+categoryRuleBody(f.custID, f.freshCategory(t))+","+categoryRuleBody(f.custB, f.freshCategory(t))+"]", nil) + if _, n := f.rulesOf(t, f.custB); n != 1 { + t.Errorf("refused bulk upsert wrote the foreign customer's rows: %d", n) + } + f.allow(t, "bound", "POST", "/api/v1/pricing/category-rules/bulk", + "["+categoryRuleBody(f.custID, f.freshCategory(t))+"]", nil, http.StatusOK) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/category-rules/bulk", + "["+categoryRuleBody(f.custB, f.freshCategory(t))+"]", nil, http.StatusOK) + } + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/bulk", + fmt.Sprintf(`{"ids":[%q]}`, f.catRuleB.String()), nil) + if _, n := f.rulesOf(t, f.custB); n != 1 { + t.Errorf("refused bulk delete changed the foreign customer's rows: %d", n) + } + for _, who := range []string{"unbound", "user"} { + id := ruleFor(f.custB) + f.allow(t, who, "DELETE", "/api/v1/pricing/category-rules/bulk", + fmt.Sprintf(`{"ids":[%q]}`, id.String()), nil, http.StatusNoContent) + } +} From 1d13026027766d185b441a1e63e0dfd26164396e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:30:01 -0700 Subject: [PATCH 10/74] fix(middleware,serve): customer scoped writes confine a bound key to the customer's branches The lead's ruling on the customer scoped writes: the tax exemption writes and the customer priced rules (plain and category, single and bulk) now reach a branch bound key only while every customer the request names, or that the row it acts on belongs to, holds the pin among the customer's branches (customer_branches), the same visibility the customer module holds a caller's branch context to; anything else is the 403 refusal with its key.branch_refused row, the details naming customer_id. A request that names no customer passes (it writes dealer wide data), a body that is not exactly one complete JSON value is refused like the body branch wall refuses it, and a lookup failure answers 500 rather than failing open. The reads keep the role guard alone; users and unbound keys never reach the wall. Signed-off-by: colton --- core/internal/app/serve/serve.go | 6 +- .../app/serve/wire_customer_branch.go | 176 ++++++++++++++++++ .../app/serve/wire_key_branch_pin_test.go | 21 ++- core/pkg/middleware/keybranch.go | 147 ++++++++++++++- 4 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 core/internal/app/serve/wire_customer_branch.go diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index e374c7e9..32a48956 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -373,7 +373,7 @@ func Run() { WithTxRunner(db). WithAudit(pricingAuditAdapter{l: auditLog}) pricingHandler := pricing.NewHandler(pricingSvc, customerSvc, productSvc) - pricingHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + pricingHandler.RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, "admin", "owner")) // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { @@ -384,7 +384,7 @@ func Run() { pricingSvc.WithCategoryPricing(catPricingSvc) catPricingHandler := pricing.NewCategoryHandler(catPricingSvc, customerSvc) - catPricingHandler.RegisterCategoryRoutes(mux, middleware.RequireRole("admin", "owner")) + catPricingHandler.RegisterCategoryRoutes(mux, categoryRulesCustomerWall(wall.keyWall, db, "admin", "owner")) logger.Info("Category-based pricing engine enabled") } else { @@ -602,7 +602,7 @@ func Run() { } taxSvc := orderwire.NewTaxService(db, cfg, logger) taxHandler := tax.NewHandler(taxSvc) - taxHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + taxHandler.RegisterRoutes(mux, taxCustomerWall(wall.keyWall, db, "admin", "owner", "finance")) // Payment Module (with Run Payments gateway) paymentRepo := payment.NewRepository(db) diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go new file mode 100644 index 00000000..65c58f2d --- /dev/null +++ b/core/internal/app/serve/wire_customer_branch.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The customer branch wall on the routes whose request writes a named +// customer's data (ADR 0007 section 5.5, the lead's ruling on the sweep): the +// tax exemption writes and the customer priced rules, plain and category, +// single and bulk. A branch bound key reaches them only while the customer +// its request names, or that the row it acts on belongs to, holds the pin +// among that customer's branches; the reads keep the role guard alone. + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// exemptionCustomerOf resolves the customer a tax exemption row belongs to. +// A row that does not exist names no customer (the handler's own 404 +// answers). +func exemptionCustomerOf(db *database.DB) func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + if db == nil { + return nil, errors.New("tax exemption customer lookup: no database") + } + var customer *uuid.UUID + err := db.GetExecutor(ctx).QueryRow(ctx, + `SELECT customer_id FROM tax_exemptions WHERE id = $1`, id).Scan(&customer) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return customer, nil + } +} + +// categoryRuleCustomerOf resolves the customer a category pricing rule row is +// scoped to. A row that does not exist, or that targets a tier or an account +// rather than a customer, names no customer. +func categoryRuleCustomerOf(db *database.DB) func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + if db == nil { + return nil, errors.New("category rule customer lookup: no database") + } + var customer *uuid.UUID + err := db.GetExecutor(ctx).QueryRow(ctx, + `SELECT customer_id FROM category_pricing_rules WHERE id = $1`, id).Scan(&customer) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return customer, nil + } +} + +// bulkRuleCustomersOf resolves the customers a bulk delete's rule ids belong +// to. A body that is not exactly one complete JSON value is refused for a +// bound key (ErrBodyRefused), and an id that does not parse is left to the +// handler's own 400. +func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + var body struct { + IDs []string `json:"ids"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + ids := make([]uuid.UUID, 0, len(body.IDs)) + for _, raw := range body.IDs { + if id, err := uuid.Parse(raw); err == nil { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, nil + } + rows, err := db.GetExecutor(ctx).Query(ctx, + `SELECT DISTINCT customer_id FROM category_pricing_rules WHERE id = ANY($1) AND customer_id IS NOT NULL`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() + } +} + +// taxCustomerWall composes the tax role guard with the customer branch wall +// on the exemption writes: the create's body names the customer, the delete's +// row belongs to one. The preview and the exemption read keep the role guard +// alone. +func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { + role := middleware.RequireRole(roles...) + create := middleware.Compose(role, keyWall.CustomerBodyBranch()) + remove := middleware.Compose(role, keyWall.CustomerRowBranch(exemptionCustomerOf(db))) + return func(next http.Handler) http.Handler { + createHandler, removeHandler, roleHandler := create(next), remove(next), role(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/tax/exemptions": + createHandler.ServeHTTP(w, r) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/api/v1/tax/exemptions/"): + removeHandler.ServeHTTP(w, r) + default: + roleHandler.ServeHTTP(w, r) + } + }) + } +} + +// pricingRulesCustomerWall composes the pricing role guard with the customer +// branch wall on the rule create (its body names the customer). The reads and +// the calculation keep the role guard alone; a rule that names no customer is +// dealer wide and passes. +func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, roles ...string) func(http.Handler) http.Handler { + role := middleware.RequireRole(roles...) + create := middleware.Compose(role, keyWall.CustomerBodyBranch()) + return func(next http.Handler) http.Handler { + createHandler, roleHandler := create(next), role(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/rules" { + createHandler.ServeHTTP(w, r) + return + } + roleHandler.ServeHTTP(w, r) + }) + } +} + +// categoryRulesCustomerWall composes the category pricing role guard with the +// customer branch wall on the rule writes: the create's body names the +// customer, the update and the delete act on a row that belongs to one, the +// bulk upsert names one per element, and the bulk delete's ids belong to +// theirs. The reads keep the role guard alone. +func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { + role := middleware.RequireRole(roles...) + wall := middleware.Compose(role, keyWall.CustomerBodyBranch()) + byRow := middleware.Compose(role, keyWall.CustomerRowBranch(categoryRuleCustomerOf(db))) + byBulkIDs := middleware.Compose(role, keyWall.CustomerBranch(bulkRuleCustomersOf(db))) + return func(next http.Handler) http.Handler { + wallHandler, rowHandler, bulkHandler, roleHandler := wall(next), byRow(next), byBulkIDs(next), role(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/category-rules/bulk": + wallHandler.ServeHTTP(w, r) // each element names its customer + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/pricing/category-rules/bulk": + bulkHandler.ServeHTTP(w, r) // the rows the ids name belong to theirs + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/category-rules": + wallHandler.ServeHTTP(w, r) + case (r.Method == http.MethodPut || r.Method == http.MethodDelete) && + strings.HasPrefix(r.URL.Path, "/api/v1/pricing/category-rules/"): + rowHandler.ServeHTTP(w, r) + default: + roleHandler.ServeHTTP(w, r) + } + }) + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 18854d80..59cd83af 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -221,14 +221,14 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { customerSvc := customer.NewService(customer.NewRepository(db)) productSvc := product.NewService(product.NewRepository(db)) tax.NewHandler(orderwire.NewTaxService(db, &config.Config{}, slog.Default())). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + RegisterRoutes(mux, taxCustomerWall(wall.keyWall, db, "admin", "owner", "finance")) pricing.NewHandler(pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), customerSvc, productSvc). - RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, "admin", "owner")) pricing.NewCategoryHandler( pricing.NewCategoryPricingService(pricing.NewCategoryRepository(db)).WithTxRunner(db). WithOutbox(outbox.NewWriter(db, "")). WithAudit(pricingAuditAdapter{l: audit.NewLogger(db)}), - customerSvc).RegisterCategoryRoutes(mux, middleware.RequireRole("admin", "owner")) + customerSvc).RegisterCategoryRoutes(mux, categoryRulesCustomerWall(wall.keyWall, db, "admin", "owner")) keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { @@ -927,9 +927,10 @@ func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { f.allow(t, who, "PUT", "/api/v1/pricing/category-rules/"+id.String(), putBody, putHdr(id), http.StatusOK) } + _, beforeCat := f.rulesOf(t, f.custB) f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/"+f.catRuleB.String(), "", putHdr(f.catRuleB)) - if _, n := f.rulesOf(t, f.custB); n != 1 { - t.Errorf("refused category rule delete changed the foreign customer's rows: %d", n) + if _, n := f.rulesOf(t, f.custB); n != beforeCat { + t.Errorf("refused category rule delete changed the foreign customer's rows: %d then %d", beforeCat, n) } deadA := ruleFor(f.custID) f.allow(t, "bound", "DELETE", "/api/v1/pricing/category-rules/"+deadA.String(), "", putHdr(deadA), http.StatusNoContent) @@ -940,10 +941,11 @@ func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { // Bulk: every element's customer must hold the pin, and a bulk delete's // rows belong to their customers. + _, beforeBulk := f.rulesOf(t, f.custB) f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules/bulk", "["+categoryRuleBody(f.custID, f.freshCategory(t))+","+categoryRuleBody(f.custB, f.freshCategory(t))+"]", nil) - if _, n := f.rulesOf(t, f.custB); n != 1 { - t.Errorf("refused bulk upsert wrote the foreign customer's rows: %d", n) + if _, n := f.rulesOf(t, f.custB); n != beforeBulk { + t.Errorf("refused bulk upsert wrote the foreign customer's rows: %d then %d", beforeBulk, n) } f.allow(t, "bound", "POST", "/api/v1/pricing/category-rules/bulk", "["+categoryRuleBody(f.custID, f.freshCategory(t))+"]", nil, http.StatusOK) @@ -951,10 +953,11 @@ func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { f.allow(t, who, "POST", "/api/v1/pricing/category-rules/bulk", "["+categoryRuleBody(f.custB, f.freshCategory(t))+"]", nil, http.StatusOK) } + _, beforeBulkDel := f.rulesOf(t, f.custB) f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/bulk", fmt.Sprintf(`{"ids":[%q]}`, f.catRuleB.String()), nil) - if _, n := f.rulesOf(t, f.custB); n != 1 { - t.Errorf("refused bulk delete changed the foreign customer's rows: %d", n) + if _, n := f.rulesOf(t, f.custB); n != beforeBulkDel { + t.Errorf("refused bulk delete changed the foreign customer's rows: %d then %d", beforeBulkDel, n) } for _, who := range []string{"unbound", "user"} { id := ruleFor(f.custB) diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index d283aca6..d44ff285 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -96,9 +96,22 @@ func (w *KeyBranchWall) BodyBranch() func(http.Handler) http.Handler { } // errRefused tells wrap the request is refused for a bound key rather than -// failed: a body the wall cannot read as one complete JSON value. +// failed: a body the wall cannot read as one complete JSON value. It is +// exported as ErrBodyRefused for resolvers built outside this package. var errRefused = errors.New("key branch wall: refused") +// ErrBodyRefused is the error DecodeOneJSONBody answers for a body that is +// not exactly one complete JSON value. +var ErrBodyRefused = errRefused + +// DecodeOneJSONBody reads the whole body, restores it for the handler, and +// decodes it into v only when it is exactly one complete JSON value. It +// answers ErrBodyRefused for anything else, so a resolver that hands it back +// refuses a bound key the body the handler would have read only in part. +func DecodeOneJSONBody(r *http.Request, v any) error { + return decodeOneJSON(r, v) +} + // decodeOneJSON reads the whole body, restores it for the handler, and decodes // it into v only when it is exactly one complete JSON value. Anything else // (unparsable, truncated, trailing data after the first value) answers @@ -175,6 +188,138 @@ func (w *KeyBranchWall) RefuseBound(reason string) func(http.Handler) http.Handl }) } +// CustomerBranch wraps a handler whose request writes one or more customers' +// data: the tax exemption writes and the customer priced rules. A branch +// bound key may reach it only while every customer the request names holds +// the pin among that customer's branches (customer_branches), the same +// visibility the customer module holds a caller's branch context to; +// anything else is the 403 refusal with its key.branch_refused row, the +// details naming customer_id. A request that names no customer passes +// through: what it writes is not scoped to one. A resolver failure answers +// 500 rather than failing open, and a resolver may hand back +// DecodeOneJSONBody's ErrBodyRefused to refuse a body the handler would have +// read only in part. +func (w *KeyBranchWall) CustomerBranch(customersOf func(ctx context.Context, r *http.Request) ([]uuid.UUID, error)) func(http.Handler) http.Handler { + return w.wrap("customer_id", "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { + customers, err := customersOf(ctx, r) + if err != nil { + return uuid.Nil, false, err + } + if len(customers) == 0 { + return uuid.Nil, false, nil + } + ok, err := w.customersHoldPin(ctx, customers) + if err != nil { + return uuid.Nil, false, err + } + if !ok { + // A customer outside the pin: the nil branch the wrap refuses. + return uuid.Nil, true, nil + } + return uuid.Nil, false, nil + }) +} + +// CustomerBodyBranch is CustomerBranch over a JSON body that names the +// customer in customer_id: one object or an array of them (a bulk body), +// decoded whole exactly as the handler's own decoder would have to. +func (w *KeyBranchWall) CustomerBodyBranch() func(http.Handler) http.Handler { + return w.CustomerBranch(func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return bodyCustomers(r) + }) +} + +// CustomerRowBranch is CustomerBranch over the customer the row the path +// names belongs to. customerOf resolves that customer; a row that does not +// exist or that belongs to no customer names nothing and passes to the +// handler's own answer. +func (w *KeyBranchWall) CustomerRowBranch(customerOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { + return w.CustomerBranch(func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + raw := r.PathValue("id") + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, nil // the handler answers the malformed id + } + customer, err := customerOf(ctx, id) + if err != nil { + return nil, err + } + if customer == nil { + return nil, nil + } + return []uuid.UUID{*customer}, nil + }) +} + +// bodyCustomers reads the customer_ids a body names, one object or an array +// of them. An id that does not parse names nothing the wall can hold, and +// the handler's own validation answers it. +func bodyCustomers(r *http.Request) ([]uuid.UUID, error) { + raw, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("key branch wall: read body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + collect := func(id string, out []uuid.UUID) []uuid.UUID { + if parsed, err := uuid.Parse(id); err == nil { + out = append(out, parsed) + } + return out + } + if trimmed := bytes.TrimSpace(raw); len(trimmed) > 0 && trimmed[0] == '[' { + var many []struct { + CustomerID string `json:"customer_id"` + } + if err := json.Unmarshal(raw, &many); err != nil { + return nil, errRefused + } + out := make([]uuid.UUID, 0, len(many)) + for _, m := range many { + out = collect(m.CustomerID, out) + } + return out, nil + } + var one struct { + CustomerID string `json:"customer_id"` + } + if err := json.Unmarshal(raw, &one); err != nil { + return nil, errRefused + } + if one.CustomerID == "" { + return nil, nil + } + return collect(one.CustomerID, nil), nil +} + +// customersHoldPin answers whether every customer's branch set +// (customer_branches) holds the pin. A customer with no row there holds no +// branch at all, so a bound key is refused it. +func (w *KeyBranchWall) customersHoldPin(ctx context.Context, customers []uuid.UUID) (bool, error) { + if w.db == nil { + return false, errors.New("key branch wall: no database for the customer branch lookup") + } + pin, _ := KeyBranchPin(ctx) + distinct := make(map[uuid.UUID]struct{}, len(customers)) + for _, c := range customers { + distinct[c] = struct{}{} + } + ids := make([]uuid.UUID, 0, len(distinct)) + for c := range distinct { + ids = append(ids, c) + } + var holding int + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT COUNT(DISTINCT customer_id) FROM customer_branches WHERE branch_id = $1 AND customer_id = ANY($2)`, + pin, ids).Scan(&holding) + if err != nil { + return false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) + } + return holding == len(ids), nil +} + // wrap builds the wrapper: no pin passes (a user, an unbound key), the pin's // own branch passes, any other branch is refused with its audit row. field // names the request part that named the branch in the refusal's details; From 63b01680147b2ce9550abc5f22f9c7f5e65f85a8 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:33:08 -0700 Subject: [PATCH 11/74] test(middleware): the key branch wall's own unit tests, fail closed included pkg/middleware had no test file for keybranch.go at all, and the first review's mutant M6 (a branch lookup error fails open) survived every test. The unit tests walk every constructor with a recording auditor and no database: a pin and a foreign branch by path, body (whole, or refused), row and customer, the unparsable and trailing data bodies, the malformed and missing ids that pass to the handler's own answers, the no pin pass through, and the failure paths that must never reach the handler: a lookup error and a nil database answer 500. Applied alone, M6 now dies on the lookup error case (the handler ran, want the wall to fail closed). Signed-off-by: colton --- core/pkg/middleware/keybranch_test.go | 329 ++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 core/pkg/middleware/keybranch_test.go diff --git a/core/pkg/middleware/keybranch_test.go b/core/pkg/middleware/keybranch_test.go new file mode 100644 index 00000000..075ccd00 --- /dev/null +++ b/core/pkg/middleware/keybranch_test.go @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware + +// The key branch wall's own unit tests: every constructor against a handler +// that records whether it ran, with the pin and the key id in the context the +// machine key core puts them in, and no database. The database backed paths +// (the location row lookup, the customer branch membership) are covered here +// only in their failure to fail open: a nil database answers 500 and the +// handler never runs. + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// pinAuditor records the refusals the wall writes. +type pinAuditor struct { + rows []string +} + +func (a *pinAuditor) AuditKeyBranchRefusal(ctx context.Context, keyID string, branch uuid.UUID, method, path string) { + a.rows = append(a.rows, keyID+" "+method+" "+path) +} + +// pinWall builds the wall with a recording auditor and no database: every +// database backed lookup fails, and the tests assert it fails closed. +func pinWall(t *testing.T) (w *KeyBranchWall, a *pinAuditor) { + t.Helper() + a = &pinAuditor{} + return NewKeyBranchWall(nil, a), a +} + +// serveWall routes one request through the wall on a real ServeMux whose +// pattern carries the path values the wall reads, with the principal the +// caller names (a pin, or none for a user and an unbound key), and reports +// the status and whether the handler ran. +func serveWall(t *testing.T, wall func(http.Handler) http.Handler, pin *uuid.UUID, method, pattern, target, body string) (int, bool) { + t.Helper() + ran := false + handler := http.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ran = true + rw.WriteHeader(http.StatusOK) + })) + mux := http.NewServeMux() + mux.Handle(method+" "+pattern, wall(handler)) + req := httptest.NewRequest(method, target, strings.NewReader(body)) + ctx := req.Context() + ctx = WithKeyID(ctx, "key-test") + if pin != nil { + ctx = branchctx.WithKeyBranch(ctx, *pin) + } + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec.Code, ran +} + +// wallPath is serveWall for a route whose pattern is its own path. +func wallPath(t *testing.T, wall func(http.Handler) http.Handler, pin *uuid.UUID, method, path, body string) (int, bool) { + t.Helper() + return serveWall(t, wall, pin, method, path, path, body) +} + +// TestKeyBranchWall_NoPinPasses walks every constructor with a user or an +// unbound key (no pin in the context): the wall passes the request through +// untouched, and no audit row is written. +func TestKeyBranchWall_NoPinPasses(t *testing.T) { + own := uuid.New() + foreign := uuid.New() + cases := []struct { + name string + wall func(*testing.T) func(http.Handler) http.Handler + check func(t *testing.T, code int, ran bool) + }{ + {"named branch", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.NamedBranch("branch_id") + }, nil}, + {"body branch", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.BodyBranch() + }, nil}, + {"row branch", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.RowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return &foreign, nil + }) + }, nil}, + {"refuse bound", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.RefuseBound("the reason") + }, nil}, + {"customer body", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.CustomerBodyBranch() + }, nil}, + {"customer row", func(t *testing.T) func(http.Handler) http.Handler { + w, _ := pinWall(t) + return w.CustomerRowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return &own, nil + }) + }, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + code, ran := wallPath(t, c.wall(t), nil, http.MethodPost, "/api/v1/x", `{"branch_id":"`+foreign.String()+`"}`) + if code != http.StatusOK || !ran { + t.Errorf("no pin: %d ran %v, want 200 and the handler reached", code, ran) + } + }) + } +} + +// TestKeyBranchWall_NamedBranch holds the path named branch to the pin: a +// foreign branch is refused with the audit row, the pin's own branch passes, +// and a malformed path value passes to the handler. +func TestKeyBranchWall_NamedBranch(t *testing.T) { + wall, auditor := pinWall(t) + pin, foreign := uuid.New(), uuid.New() + mux := wall.NamedBranch("branch_id") + pattern := "/api/v1/branches/{branch_id}" + + if code, ran := serveWall(t, mux, &pin, http.MethodGet, pattern, "/api/v1/branches/"+foreign.String(), ""); code != http.StatusForbidden || ran { + t.Errorf("foreign branch: %d ran %v, want 403 and not run", code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("foreign branch audit rows: %v, want one", auditor.rows) + } + if code, ran := serveWall(t, mux, &pin, http.MethodGet, pattern, "/api/v1/branches/"+pin.String(), ""); code != http.StatusOK || !ran { + t.Errorf("own branch: %d ran %v, want 200 and run", code, ran) + } + if code, ran := serveWall(t, mux, &pin, http.MethodGet, pattern, "/api/v1/branches/not-a-uuid", ""); code != http.StatusOK || !ran { + t.Errorf("malformed id: %d ran %v, want 200 and run (the handler answers it)", code, ran) + } + // An empty path segment never reaches the wall: the router itself + // answers it, so the guard in NamedBranch stays defensive. +} + +// TestKeyBranchWall_BodyBranch holds the body named branch to the pin and +// reads the whole body: a foreign branch is refused, the pin's own passes, a +// body that names no branch passes to the handler, and a body that is not +// exactly one complete JSON value (unparsable, truncated, trailing data) is +// refused rather than passed for the handler to read only in part. The body +// that passes is restored for the handler to read. +func TestKeyBranchWall_BodyBranch(t *testing.T) { + pin, foreign := uuid.New(), uuid.New() + refused := []string{ + `{"branch_id":"` + foreign.String() + `"}`, + `{"branch_id":"` + foreign.String() + `"} {}`, + `{"branch_id":"` + foreign.String() + `"}xyz`, + `{"branch_id":"` + foreign.String() + `"} 1`, + `{"branch_id":"` + foreign.String() + `"}]`, + `{"branch_id":"` + foreign.String() + `"`, + `not json at all`, + } + for _, body := range refused { + wall, auditor := pinWall(t) + code, ran := wallPath(t, wall.BodyBranch(), &pin, http.MethodPost, "/api/v1/users/x/branches", body) + if code != http.StatusForbidden || ran { + t.Errorf("body %q: %d ran %v, want 403 and not run", body, code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("body %q audit rows: %v, want one", body, auditor.rows) + } + } + passes := []string{ + `{"branch_id":"` + pin.String() + `"}`, + `{"branch_id":"` + strings.ToUpper(pin.String()) + `"}`, + `{}`, + `{"branch_id":null}`, + `{"other":"` + foreign.String() + `"}`, + } + for _, body := range passes { + wall, auditor := pinWall(t) + code, ran := wallPath(t, wall.BodyBranch(), &pin, http.MethodPost, "/api/v1/users/x/branches", body) + if code != http.StatusOK || !ran { + t.Errorf("body %q: %d ran %v, want 200 and run", body, code, ran) + } + if len(auditor.rows) != 0 { + t.Errorf("body %q audit rows: %v, want none", body, auditor.rows) + } + } +} + +// TestKeyBranchWall_RowBranch holds the row named branch to the pin: a +// foreign row is refused, the pin's own passes, a missing row passes to the +// handler's own answer, and a lookup failure answers 500 with the handler +// never reached: the wall never fails open. +func TestKeyBranchWall_RowBranch(t *testing.T) { + pin, foreign := uuid.New(), uuid.New() + boom := errors.New("lookup boom") + + wall, auditor := pinWall(t) + mux := wall.RowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return &foreign, nil + }) + locPattern := "/api/v1/locations/{id}" + if code, ran := serveWall(t, mux, &pin, http.MethodPut, locPattern, "/api/v1/locations/"+uuid.NewString(), ""); code != http.StatusForbidden || ran { + t.Errorf("foreign row: %d ran %v, want 403 and not run", code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("foreign row audit rows: %v, want one", auditor.rows) + } + + wall, _ = pinWall(t) + mux = wall.RowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return &pin, nil + }) + if code, ran := serveWall(t, mux, &pin, http.MethodPut, locPattern, "/api/v1/locations/"+uuid.NewString(), ""); code != http.StatusOK || !ran { + t.Errorf("own row: %d ran %v, want 200 and run", code, ran) + } + + wall, _ = pinWall(t) + mux = wall.RowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return nil, nil // the row does not exist + }) + if code, ran := serveWall(t, mux, &pin, http.MethodPut, locPattern, "/api/v1/locations/"+uuid.NewString(), ""); code != http.StatusOK || !ran { + t.Errorf("missing row: %d ran %v, want 200 and run (the handler 404s)", code, ran) + } + + // The fail closed path: a lookup error must never reach the handler. + wall, _ = pinWall(t) + mux = wall.RowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return nil, boom + }) + code, ran := serveWall(t, mux, &pin, http.MethodPut, locPattern, "/api/v1/locations/"+uuid.NewString(), "") + if ran { + t.Fatalf("lookup error: the handler ran, want the wall to fail closed") + } + if code != http.StatusInternalServerError && code != http.StatusForbidden { + t.Errorf("lookup error: %d, want 500 or 403, never a pass", code) + } + + // A nil database is the same failure: closed. + wall, _ = pinWall(t) + code, ran = serveWall(t, wall.LocationRowBranch(), &pin, http.MethodPut, locPattern, "/api/v1/locations/"+uuid.NewString(), "") + if ran { + t.Fatalf("nil database: the handler ran, want the wall to fail closed") + } + if code != http.StatusInternalServerError { + t.Errorf("nil database: %d, want 500", code) + } +} + +// TestKeyBranchWall_RefuseBound refuses a bound key outright whatever the +// request carries, and passes everyone else. +func TestKeyBranchWall_RefuseBound(t *testing.T) { + wall, auditor := pinWall(t) + mux := wall.RefuseBound("the reason") + pinned := uuid.New() + code, ran := wallPath(t, mux, &pinned, http.MethodPost, "/api/v1/branches", `{"code":"x"}`) + if code != http.StatusForbidden || ran { + t.Errorf("bound key: %d ran %v, want 403 and not run", code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("audit rows: %v, want one", auditor.rows) + } + if code, ran := wallPath(t, mux, nil, http.MethodPost, "/api/v1/branches", `{"code":"x"}`); code != http.StatusOK || !ran { + t.Errorf("no pin: %d ran %v, want 200 and run", code, ran) + } +} + +// TestKeyBranchWall_CustomerBranch holds the customer named writes to the +// pin: a body that is not exactly one complete JSON value is refused, and a +// resolver failure or a nil database answers 500 with the handler never +// reached (the membership lookup itself needs the database the serve wiring +// tests exercise). +func TestKeyBranchWall_CustomerBranch(t *testing.T) { + pin := uuid.New() + + refused := []string{ + `{"customer_id":"` + uuid.NewString() + `"} trailing`, + `[{"customer_id":"` + uuid.NewString() + `"}] extra`, + `not json`, + } + for _, body := range refused { + wall, auditor := pinWall(t) + code, ran := wallPath(t, wall.CustomerBodyBranch(), &pin, http.MethodPost, "/api/v1/tax/exemptions", body) + if code != http.StatusForbidden || ran { + t.Errorf("body %q: %d ran %v, want 403 and not run", body, code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("body %q audit rows: %v, want one", body, auditor.rows) + } + } + + // A parsable body that names a customer reaches the membership lookup, + // which has no database here and must fail closed. + wall, _ := pinWall(t) + code, ran := wallPath(t, wall.CustomerBodyBranch(), &pin, http.MethodPost, "/api/v1/tax/exemptions", + `{"customer_id":"`+uuid.NewString()+`"}`) + if ran || code != http.StatusInternalServerError { + t.Errorf("membership without a database: %d ran %v, want 500 and not run", code, ran) + } + + // A resolver failure is the same: closed. + wall, _ = pinWall(t) + code, ran = wallPath(t, wall.CustomerBranch(func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return nil, errors.New("resolver boom") + }), &pin, http.MethodPost, "/api/v1/x", "") + if ran || code != http.StatusInternalServerError { + t.Errorf("resolver failure: %d ran %v, want 500 and not run", code, ran) + } + + // A request that names no customer passes; the row variant with no row + // passes too. + wall, auditor := pinWall(t) + if code, ran := wallPath(t, wall.CustomerBodyBranch(), &pin, http.MethodPost, "/api/v1/pricing/rules", `{"name":"dealer wide"}`); code != http.StatusOK || !ran { + t.Errorf("no customer named: %d ran %v, want 200 and run", code, ran) + } + if len(auditor.rows) != 0 { + t.Errorf("no customer named audit rows: %v, want none", auditor.rows) + } + wall, _ = pinWall(t) + if code, ran := serveWall(t, wall.CustomerRowBranch(func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + return nil, nil + }), &pin, http.MethodDelete, "/api/v1/tax/exemptions/{id}", "/api/v1/tax/exemptions/"+uuid.NewString(), ""); code != http.StatusOK || !ran { + t.Errorf("missing row: %d ran %v, want 200 and run", code, ran) + } +} From 100bcd892fc4da0412ebc64901504a21f76fd6e2 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:34:43 -0700 Subject: [PATCH 12/74] fix(middleware): a row that names no branch is outside every pin The second review seeded a legacy location row with a NULL branch (user triggers lifted) and the wall passed it: the bound key PUT and DELETE answered 200 and 204. ErrRowNamesNoBranch now marks a row that exists but belongs to no branch, and RowBranch refuses a bound key it like a foreign branch, red first. The switch off case pins the wall's independence from the multi_branch_enabled switch: with the switch off the pin still refuses the foreign branch and passes its own, and the unbound key and the user keep their reach. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 64 ++++++++++++++++++- core/pkg/middleware/keybranch.go | 18 +++++- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 59cd83af..12b91cea 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -65,6 +65,7 @@ type keyPinFixture struct { quoteA uuid.UUID quoteB uuid.UUID custID uuid.UUID + yardNull uuid.UUID // a legacy row that names no branch custB uuid.UUID // a customer whose branches hold branch B only category uuid.UUID // one product category the priced rules hang on catRuleA uuid.UUID // a category rule scoped to the pin's customer @@ -85,6 +86,15 @@ var pinKeyScopes = []string{ } func newKeyPinFixture(t *testing.T) *keyPinFixture { + t.Helper() + return newKeyPinFixtureSwitched(t, "true") +} + +// newKeyPinFixtureSwitched builds the fixture with the multi_branch_enabled +// switch in the state given: the branch middleware reads it once at +// construction, so the state has to be in the table before the wall is +// built. +func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Helper() testutil.LockOutboxTables(t) // the exposure writes record outbox events db := testutil.RequireDB(t) @@ -94,6 +104,7 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { branchA: uuid.New(), branchB: uuid.New(), branchC: uuid.New(), branchC2: uuid.New(), yardA: uuid.New(), yardB: uuid.New(), quoteA: uuid.New(), quoteB: uuid.New(), custID: uuid.New(), custB: uuid.New(), category: uuid.New(), catRuleA: uuid.New(), catRuleB: uuid.New(), taxExempt: uuid.New(), + yardNull: uuid.New(), } for _, r := range []struct { id uuid.UUID @@ -149,6 +160,18 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { `INSERT INTO tax_exemptions (id, customer_id, exempt_reason) VALUES ($1, $2, 'pin')`, f.taxExempt, f.custB); err != nil { t.Fatalf("seed tax exemption: %v", err) } + // A legacy row that names no branch: the denorm trigger refuses such rows + // today, so the seed lifts it for the insert only. + if _, err := db.Pool.Exec(ctx, `ALTER TABLE locations DISABLE TRIGGER trg_locations_set_branch_id`); err != nil { + t.Fatalf("lift locations trigger: %v", err) + } + if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name) VALUES ($1, 'YARD', $2, 'pin null row')`, + f.yardNull, "pin-null-"+f.yardNull.String()[:8]); err != nil { + t.Fatalf("seed null branch row: %v", err) + } + if _, err := db.Pool.Exec(ctx, `ALTER TABLE locations ENABLE TRIGGER trg_locations_set_branch_id`); err != nil { + t.Fatalf("restore locations trigger: %v", err) + } t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' AND entity_id IN (SELECT id::text FROM quote_exposure_events WHERE quote_id IN ($1, $2))`, f.quoteA, f.quoteB) @@ -169,11 +192,11 @@ func newKeyPinFixture(t *testing.T) *keyPinFixture { _, _ = db.Pool.Exec(ctx, `DELETE FROM user_locations WHERE user_sub LIKE 'pin-%' OR user_sub = 'pin-both'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE code LIKE 'pin-new-%'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE parent_id IN ($1, $2, $3, $4)`, f.branchA, f.branchB, f.branchC, f.branchC2) - _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2, $3, $4, $5, $6)`, - f.branchA, f.branchB, f.branchC, f.branchC2, f.yardA, f.yardB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2, $3, $4, $5, $6, $7)`, + f.branchA, f.branchB, f.branchC, f.branchC2, f.yardA, f.yardB, f.yardNull) }) - setSetting(t, db, "multi_branch_enabled", "true") + setSetting(t, db, "multi_branch_enabled", switchState) setSetting(t, db, "default_branch_required", "false") wall := newBranchWall(db) mux := http.NewServeMux() @@ -965,3 +988,38 @@ func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { fmt.Sprintf(`{"ids":[%q]}`, id.String()), nil, http.StatusNoContent) } } + +// TestKeyBranchPin_SwitchOff pins the wall's independence from the +// multi_branch_enabled switch: with the switch off the pin still refuses the +// foreign branch and still passes the pin's own, and the unbound key and the +// user keep their reach. The wall never reads the switch; this is the case +// that says so. +func TestKeyBranchPin_SwitchOff(t *testing.T) { + f := newKeyPinFixtureSwitched(t, "false") + A, B := f.branchA.String(), f.branchB.String() + + f.refuseForeign(t, "POST", "/api/v1/users/pin-off-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil) + if f.hasGrant(t, "pin-off-grant", f.branchB) { + t.Errorf("switch off refused grant wrote the foreign grant row") + } + f.allow(t, "bound", "POST", "/api/v1/users/pin-off-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, A), nil, http.StatusNoContent) + f.allow(t, "unbound", "POST", "/api/v1/users/pin-off-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + f.allow(t, "user", "POST", "/api/v1/users/pin-off-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, B), nil, http.StatusNoContent) + f.refuseForeign(t, "GET", "/api/v1/branches/"+B+"/users", "", nil) +} + +// TestKeyBranchPin_RowWithoutBranch refuses a bound key a location row that +// names no branch (a legacy shape the denorm trigger refuses today): such a +// row is outside every pin, so it is refused like a foreign branch, while the +// unbound key and the user keep their reach on it. +func TestKeyBranchPin_RowWithoutBranch(t *testing.T) { + f := newKeyPinFixture(t) + f.refuseForeign(t, "PUT", "/api/v1/locations/"+f.yardNull.String(), + fmt.Sprintf(`{"code":"pin-null-%s","path":"pin","name":"no"}`, f.yardNull.String()[:8]), + map[string]string{"If-Match": `"1"`}) + if rev, _, active := f.locState(t, f.yardNull); rev != 1 || !active { + t.Errorf("refused PUT changed the null branch row: revision %d active %v", rev, active) + } + f.putLocation(t, "unbound", f.yardNull, "pin free null edit", http.StatusOK) + f.putLocation(t, "user", f.yardNull, "pin user null edit", http.StatusOK) +} diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index d44ff285..c22071b9 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -128,10 +128,16 @@ func decodeOneJSON(r *http.Request, v any) error { return nil } +// ErrRowNamesNoBranch tells RowBranch the row the path names exists but +// belongs to no branch (a legacy shape the denorm trigger refuses today): +// such a row is outside every pin, so a bound key is refused it. +var ErrRowNamesNoBranch = errors.New("key branch wall: the row names no branch") + // RowBranch wraps a handler whose path names a row ("id") whose branch -// branchOf resolves: a location, a quote. A row that does not exist, or that -// belongs to no branch, names no branch and passes to the handler's own -// answer. A lookup failure answers 500 rather than failing open. +// branchOf resolves: a location, a quote. A row that does not exist names no +// branch and passes to the handler's own answer; a row that exists but +// belongs to no branch (ErrRowNamesNoBranch) is outside every pin and is +// refused. A lookup failure answers 500 rather than failing open. func (w *KeyBranchWall) RowBranch(branchOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { return w.wrap("id", "", func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { raw := r.PathValue("id") @@ -143,6 +149,9 @@ func (w *KeyBranchWall) RowBranch(branchOf func(ctx context.Context, id uuid.UUI return uuid.Nil, false, nil // the handler answers the malformed id } branch, err := branchOf(ctx, id) + if errors.Is(err, ErrRowNamesNoBranch) { + return uuid.Nil, true, nil + } if err != nil { return uuid.Nil, false, err } @@ -174,6 +183,9 @@ func (w *KeyBranchWall) branchOfLocation(ctx context.Context, id uuid.UUID) (*uu if err != nil { return nil, fmt.Errorf("key branch wall: location branch lookup: %w", err) } + if branch == nil { + return nil, ErrRowNamesNoBranch + } return branch, nil } From ee49d1341715d75327b74c2b34011ee06c013a4b Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 10:35:51 -0700 Subject: [PATCH 13/74] docs: the branch bound key's pin as the reviews and the lead ruled it ADR 0007 section 5.5 now states the wall's whole final behaviour: the whole body read, the outright refusals (the directory create, the exposure scan and lists, the market index refresh, the events feed, every reporting route, the dealer wide GL, AP and bank reconciliation reads, the sales team reads, the known users list), the customer_branches confinement of the customer scoped writes, and the branch directory reads limit in one sentence with the PR 39 reference data reason. drafts.md and locations.md say the same for their routes, locations.md adds the home to own branch side effect on the user's other grant rows, the CONTRACT-CHANGES row lists every route and the 403 for a missing branch id where the base answers 404, and its stray trailing blank line is gone. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 16 +++++++------- ...7-drafts-links-and-confirm-gated-scopes.md | 18 +++++++++++++-- docs/modules/drafts.md | 22 ++++++++++++++----- docs/modules/locations.md | 15 ++++++++++--- docs/refactor/CONTRACT-CHANGES.md | 3 +-- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 12b91cea..1cc1b27f 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -161,16 +161,16 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Fatalf("seed tax exemption: %v", err) } // A legacy row that names no branch: the denorm trigger refuses such rows - // today, so the seed lifts it for the insert only. - if _, err := db.Pool.Exec(ctx, `ALTER TABLE locations DISABLE TRIGGER trg_locations_set_branch_id`); err != nil { - t.Fatalf("lift locations trigger: %v", err) - } - if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name) VALUES ($1, 'YARD', $2, 'pin null row')`, - f.yardNull, "pin-null-"+f.yardNull.String()[:8]); err != nil { + // on an insert, but it fires only on INSERT and UPDATE OF parent_id and + // type, so the seed inserts a normal yard and then nulls its branch with + // an update the trigger never sees (no table level trigger lifting, which + // would race the packages that run beside this one). + if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name, parent_id) VALUES ($1, 'YARD', $2, 'pin null row', $3)`, + f.yardNull, "pin-null-"+f.yardNull.String()[:8], f.branchA); err != nil { t.Fatalf("seed null branch row: %v", err) } - if _, err := db.Pool.Exec(ctx, `ALTER TABLE locations ENABLE TRIGGER trg_locations_set_branch_id`); err != nil { - t.Fatalf("restore locations trigger: %v", err) + if _, err := db.Pool.Exec(ctx, `UPDATE locations SET branch_id = NULL WHERE id = $1`, f.yardNull); err != nil { + t.Fatalf("null the branch: %v", err) } t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 4db5a03f..e0f3998f 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -688,8 +688,22 @@ A route that mounts no branch middleware is held to the pin the same way by the key branch wall (`pkg/middleware/keybranch.go`): a branch a request names in a path or body, or the branch of a row a path names, is compared to the pin and another branch is the same 403 with the same `key.branch_refused` -row, while the branch directory's create and the dealer wide routes (the -exposure scan) refuse a bound key outright, being outside every pin. +row. A body is read whole: a body that is not exactly one complete JSON +value (trailing data after the first value, or unparsable) is refused, so +the wall and the handler can never disagree about what a body names. Routes +that act across every branch refuse a bound key outright, being outside +every pin: the branch directory's create, the exposure scan and the two +exposure lists, the market index refresh (it re-checks every branch's +quotes), the events feed, every reporting route, the dealer wide GL, AP and +bank reconciliation reads, the sales team reads and the known users list. +Routes that write a named customer's data are confined to the pin through +that customer's `customer_branches`: the tax exemption writes and the +customer priced rules (plain and category, single and bulk) refuse a bound +key any customer whose branch set does not hold the pin. The branch +directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay +open to a bound key: they are reference data by the PR 39 decision (name, +code, address and tax rate, no dealer transactions). Users and unbound keys +keep exactly their reach on every route. One branch per key, not a set: the repositories' branch idiom filters on one branch or none, and a set would need a second idiom in every module. A diff --git a/docs/modules/drafts.md b/docs/modules/drafts.md index d1da4978..e502d213 100644 --- a/docs/modules/drafts.md +++ b/docs/modules/drafts.md @@ -120,15 +120,27 @@ a bound key naming another branch in a body `branch_id` or a path id, or a row of another branch (`PUT` and `DELETE /api/v1/locations/{id}`), is a 403 `forbidden` in the wire envelope naming the field, audited as `key.branch_refused` -like the header rule; its own branch passes. The directory +like the header rule; its own branch passes, and a body is +read whole, so a body that is not exactly one complete JSON +value (trailing data after the first value, or unparsable) is +refused rather than passed for the handler to read only in +part. The directory create (`POST /api/v1/branches`) refuses a bound key outright, and `PUT` and `DELETE /api/v1/branches/{id}` refuse it another branch. A bound key holding `users:grants` grants, revokes and -moves home only its own branch. The branch directory reads +moves home only its own branch. The routes that act across +every branch refuse a bound key outright: the exposure scan +and the two exposure lists, the market index refresh, the +events feed (`GET /api/v1/events`), every reporting route, +the dealer wide GL, AP and bank reconciliation reads, the +sales team reads and the known users list (`GET /api/v1/users`). +The routes that write a named customer's data are confined to +the pin through that customer's `customer_branches`: the tax +exemption writes and the customer priced rules, plain and +category, single and bulk. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay -reference data (the PR 39 decision), and the exposure lists -and the events feed stay cross branch, stated limits listed -for the lead. An unbound key behaves as before. +reference data (the PR 39 decision). An unbound key behaves +as before. ## Routes diff --git a/docs/modules/locations.md b/docs/modules/locations.md index c56f725f..ce410420 100644 --- a/docs/modules/locations.md +++ b/docs/modules/locations.md @@ -382,13 +382,22 @@ key branch wall (`core/pkg/middleware/keybranch.go`, mounted through (`PUT` and `DELETE /api/v1/locations/{id}`), another branch itself (`PUT` and `DELETE /api/v1/branches/{id}`) or another branch in a grant body or path is a 403 `forbidden` naming the field, audited as -`key.branch_refused`; its own branch passes; the directory create -(`POST /api/v1/branches`) refuses a bound key outright; +`key.branch_refused`; its own branch passes; the grant and home bodies are +read whole, so a body that is not exactly one complete JSON value (trailing +data after the first value, or unparsable) is refused rather than passed for +the handler to read only in part; a location row that names no branch (a +legacy shape the denorm trigger refuses today) is outside every pin and is +refused like a foreign branch; the directory create +(`POST /api/v1/branches`) and the known users list (`GET /api/v1/users`, +every branch's subs) refuse a bound key outright; `GET /api/v1/branches/{id}/users` refuses it another branch and `GET /api/v1/users/{sub}/branches` answers only its branch for it. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data, unwalled by the PR 39 -decision, a stated limit. An unbound key behaves as before. +decision, a stated limit. A bound key that sets a user's home to its own +branch clears `is_home` on that user's other grant rows: a side effect +inside the user's own grant set, not a cross branch grant. An unbound key +behaves as before. ## ADRs that govern this module diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index f21683e9..db9e308c 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -274,5 +274,4 @@ get an entry. | C5-2a | `POST /api/v1/drafts/quotes`, `PUT /api/v1/drafts/quotes/{id}` (and the orders kind's) | No draft routes existed (the payload branch rule was the quote create's alone, PR 37) | The payload branch rule of ADR 0007 section 2.3 applies to every draft route through the same guard: with a branch in the request's context (always, for a bound key) a payload `branch_id` must equal it, else 403 naming `payload.branch_id`; with no context branch an administrator or owner may name any branch, and so may every caller while the single branch kill switch is off, an unbound key, and `AUTH_MODE=dev`; a user with no context branch may name only a branch in their grants; the branch is fixed at create | | C5-2a | `PUT /api/v1/quotes/{id}/file` | No such route: the AI parse's original file could only enter through the quote create, which a draft promotion replaces | The quote file route of ADR 0007 section 10: the raw file bytes with their content type, the same 5 MiB bound (413 above it), `quotes:write`, the revision precondition (428, 409), accepted only while the quote is in status `draft` (else 409 `invalid_state_transition` with the `quote_not_draft` blocker); it replaces any stored file, moves the revision by one, returns the quote with its new `ETag`, applies the branch wall like the read, writes the `quote.file_attached` audit row and no outbox event. Being an entity write on a gated module it is refused to an agent marked session | | C5-2a | `GET /api/v1/links/quotes/{id}`, `GET /api/v1/links/orders/{id}`, `GET /api/v1/links/invoices/{id}`, `GET /api/v1/links/customers/{id}`, `GET /api/v1/links/products/{id}`, `GET /api/v1/links/drafts/quotes/{id}`, `GET /api/v1/links/drafts/orders/{id}` | No link resolver existed: every frontend and agent carried its own route table | The link resolver of ADR 0007 section 8: `{id}` is a UUID or the entity's document number, the record must exist and be visible to the caller (the module's own read, so an invisible record is the same 404 a missing one gets), and the answer carries the desk link (absolute when `GABLE_PUBLIC_URL` is set, origin relative otherwise), the front door's `/?open=` form, `gable://` for the shell, a null portal until it has record screens, and the agent slot from `GABLE_AGENT_URL_TEMPLATE` (null when unset). The scope is read off the URL: the module `read` for an entity link, `propose` or `commit` for a draft link. The declarative table is the only copy and generates `core/api/links.json`; `core/api/link-paths.json` carries the plain segment vectors the shell, the door and their tests share | -| SEC-BOUND-KEY-PIN | every route a machine key reaches that mounts no branch middleware: locations `PUT /api/v1/locations/{id}`, `DELETE /api/v1/locations/{id}`, branches `POST /api/v1/branches`, `PUT /api/v1/branches/{id}`, `DELETE /api/v1/branches/{id}`, grants `POST /api/v1/users/{sub}/branches`, `DELETE /api/v1/users/{sub}/branches/{branch_id}`, `PUT /api/v1/users/{sub}/home-branch`, reads `GET /api/v1/branches/{id}/users`, `GET /api/v1/users/{sub}/branches`, exposure `GET /api/v1/quotes/{id}/exposure`, `POST /api/v1/quotes/{id}/exposure/acknowledge`, `/request-ack`, `/override`, `/escalate-now`, and `POST /api/v1/admin/exposure-scan` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin, and a scope checked key passes the role guards on its scope alone (RequireRole), so on every route above a bound key acted on the branch its body or path named: it edited and archived another branch's locations and branches, created branches, granted, revoked and moved any user's branches, and under AUTH_MODE=dev (whose actor fallback passes a keyless caller as the dev owner) acknowledged, requested, overrode and previewed another branch's quote exposure and ran the dealer wide scan | The key branch wall (`core/pkg/middleware/keybranch.go`, mounted through `WithKeyBranchWall` and `registerExposureRoutes`) reads the pin (branchctx.KeyBranchFromContext) and compares the branch the request names by a path value, a body branch_id, or the branch of the row the path names (a location through its tree, a quote through its branch): a bound key naming another branch is refused 403 `forbidden` in the wire envelope naming the field, audited as `key.branch_refused` (the row the X-Branch-Id rule writes); its own branch passes; the directory create and the exposure scan refuse a bound key outright (outside every pin); `GET /api/v1/branches/{id}/users` refuses another branch and `GET /api/v1/users/{sub}/branches` answers only the pin's branch for a bound key. Users and unbound keys are unchanged on every route, whatever the multi_branch_enabled switch says. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data (PR 39) and the exposure lists and the events feed stay cross branch, stated limits for the lead | - +| SEC-BOUND-KEY-PIN | every route a machine key reaches that mounts no branch middleware: locations `PUT /api/v1/locations/{id}`, `DELETE /api/v1/locations/{id}`, branches `POST /api/v1/branches`, `PUT /api/v1/branches/{id}`, `DELETE /api/v1/branches/{id}`, grants `POST /api/v1/users/{sub}/branches`, `DELETE /api/v1/users/{sub}/branches/{branch_id}`, `PUT /api/v1/users/{sub}/home-branch`, reads `GET /api/v1/branches/{id}/users`, `GET /api/v1/users/{sub}/branches`, `GET /api/v1/users`, exposure `GET /api/v1/quotes/{id}/exposure`, `POST /api/v1/quotes/{id}/exposure/acknowledge`, `/request-ack`, `/override`, `/escalate-now`, `GET /api/v1/quotes/exposure`, `GET /api/v1/reports/exposure`, `POST /api/v1/admin/exposure-scan`, `POST /api/v1/market-indices/{id}/refresh`, reporting `/reports/*` and `/reporting/*` in all three registrations, `GET /api/v1/events`, the GL, AP and bank reconciliation reads (`GET /api/v1/gl/*`, `GET /api/v1/ap/*`, `GET /api/v1/bankrecon/*`), the sales team reads (`GET /api/v1/sales-team`, `GET /api/v1/sales-team/{id}`), and the customer scoped writes `POST /api/v1/tax/exemptions`, `DELETE /api/v1/tax/exemptions/{id}`, `POST /api/v1/pricing/rules`, `POST /api/v1/pricing/category-rules`, `PUT` and `DELETE /api/v1/pricing/category-rules/{id}`, `POST` and `DELETE /api/v1/pricing/category-rules/bulk` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin, and a scope checked key passes the role guards on its scope alone (RequireRole), so on every route above a bound key acted on the branch its body or path named: it edited and archived another branch's locations and branches, created branches, granted, revoked and moved any user's branches, and under AUTH_MODE=dev (whose actor fallback passes a keyless caller as the dev owner) acknowledged, requested, overrode and previewed another branch's quote exposure and ran the dealer wide scan | The key branch wall (`core/pkg/middleware/keybranch.go`, mounted through `WithKeyBranchWall`, `registerExposureRoutes`, the branchWall dealer wide mounts and `wire_customer_branch.go`) reads the pin (branchctx.KeyBranchFromContext) and compares the branch the request names by a path value, a body branch_id, or the branch of the row the path names (a location through its tree, a quote through its branch): a bound key naming another branch is refused 403 `forbidden` in the wire envelope naming the field, audited as `key.branch_refused` (the row the X-Branch-Id rule writes); its own branch passes. A body is read whole: a body that is not exactly one complete JSON value (trailing data after the first value, or unparsable) is refused rather than passed for the handler to read only in part, which closed the bypass where a grant or home body with trailing data wrote the foreign branch with no audit row. A location row that names no branch (a legacy shape the denorm trigger refuses today) is outside every pin and is refused like a foreign branch. The routes that act across every branch refuse a bound key outright: the directory create, the exposure scan and the two exposure lists, the market index refresh (it re-checks every branch's quotes), the events feed, every reporting route, the dealer wide GL, AP and bank reconciliation reads, the sales team reads and the known users list. The customer scoped writes are confined to the pin through the named customer's `customer_branches`: the tax exemption writes and the customer priced rules, plain and category, single and bulk, refuse a bound key any customer whose branch set does not hold the pin (403 naming `customer_id`); a write that names no customer is dealer wide and passes. `GET /api/v1/branches/{id}/users` refuses another branch and `GET /api/v1/users/{sub}/branches` answers only the pin's branch for a bound key. A bound key's answer for a missing or unknown branch id on the named branch and body branch routes is now the 403 with its audit row, where the base answers 404: the wall cannot tell a missing branch from a foreign one and fails closed. Users and unbound keys are unchanged on every route, whatever the multi_branch_enabled switch says. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data (PR 39), the GL, AP and bank reconciliation writes and the index metadata and history carry no branch fact and keep the role guard alone, a stated limit | From 03b1ad482cb40b52447214d2a6e3e712c965d019 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 11:41:00 -0700 Subject: [PATCH 14/74] test(serve): the deny by default round's refusals and confinements, red first The pin fixture now drives the production chain (buildChain) instead of a hand built machine key mount, mounts the staff roster, seeds a job per customer, a tier category rule and a certified tax exemption, and the round 4 findings become assertions: the refresh preview, the GL, AP and bank reconciliation writes, the staff roster, the unfiltered pricing lists and the matrix refuse a bound key; the customer scoped reads (the exemption read by path customer, the category rule list by query customer, the rule audit, calculate, resolve, the tax preview by body customer) are confined; a pricing rule naming a job is confined through the job's customer; a rule naming neither a customer nor a job, and a tier category rule on every verb, are refused; the stated limits (the branch directory reads, the index metadata and history) stay open; and a path no route resolves is refused. The GL writes block that held them open is gone: the ruling changed. Four of the five new tests fail on the base head, the stated limits test pins the open behaviour. Signed-off-by: colton --- .../app/serve/wire_key_branch_allow_test.go | 209 ++++++++++++++++++ .../app/serve/wire_key_branch_pin_test.go | 112 +++++++--- 2 files changed, 289 insertions(+), 32 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_allow_test.go diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go new file mode 100644 index 00000000..16a2be26 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The deny by default round's own tests (ADR 0007 section 5.5): the customer +// scoped reads that name a customer in a path, a query or a body are confined +// through that customer's customer_branches exactly like the writes; a pricing +// rule that names a job is confined through the job's customer; a rule or +// category rule that names neither a customer nor a job is a dealer wide write +// and is refused; and the stated limits stay reachable, with their branch +// directory and index reference data answering a bound key as they answer +// everyone else. All through the production chain the fixture builds. + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" +) + +// TestKeyBranchPin_CustomerConfinedReads holds the pin on the customer scoped +// reads (round 3 P1-2, round 4 P2): a read that names the foreign customer in +// its path or query is refused with the audit row and answers nothing, the +// pin's own customer passes, and the unbound key and the user keep the same +// answer as each other everywhere. +func TestKeyBranchPin_CustomerConfinedReads(t *testing.T) { + f := newKeyPinFixture(t) + A, B := f.custID.String(), f.custB.String() + + // The exemption read names the customer in its path. + f.refuseForeign(t, "GET", "/api/v1/tax/exemptions/"+B, "", nil) + f.allow(t, "bound", "GET", "/api/v1/tax/exemptions/"+A, "", nil, http.StatusOK) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "GET", "/api/v1/tax/exemptions/"+B, "", nil, http.StatusOK) + } + + // The tax preview names the customer in its body: confined when it names + // one, and a preview that names none is a plain rate calculation. + preview := func(customer string) string { + return fmt.Sprintf(`{"customer_id":%q,"document_type":"SalesInvoice","lines":[{"amount":100}]}`, customer) + } + f.refuseForeign(t, "POST", "/api/v1/tax/preview", preview(B), nil) + if status, _ := f.key(t, f.bound, "POST", "/api/v1/tax/preview", preview(A), nil); status == http.StatusForbidden { + t.Errorf("tax preview of the pin's customer was refused: the wall must pass it to the module") + } + f.parity(t, "POST", "/api/v1/tax/preview", preview(B)) + + // The category rule list is confined by its customer_id query; the + // unfiltered list is refused (it carries every customer's rules). + q := "/api/v1/pricing/category-rules?customer_id=" + f.refuseForeign(t, "GET", q+B, "", nil) + f.allow(t, "bound", "GET", q+A, "", nil, http.StatusOK) + f.refuseForeign(t, "GET", "/api/v1/pricing/category-rules", "", nil) + for _, path := range []string{q + B, q + A, "/api/v1/pricing/category-rules"} { + f.parity(t, "GET", path, "") + } + + // The rule audit is confined by its row's customer. + f.refuseForeign(t, "GET", "/api/v1/pricing/category-rules/"+f.catRuleB.String()+"/audit", "", nil) + f.allow(t, "bound", "GET", "/api/v1/pricing/category-rules/"+f.catRuleA.String()+"/audit", "", nil, http.StatusOK) + f.parity(t, "GET", "/api/v1/pricing/category-rules/"+f.catRuleB.String()+"/audit", "") + + // The calculation and the resolution name the customer in their query. + // No product is seeded, so a read that passes the wall reaches the + // module's own 404; a refused one never does. + calc := "/api/v1/pricing/calculate?product_id=" + uuid.NewString() + "&customer_id=" + f.refuseForeign(t, "GET", calc+B+"&quantity=1", "", nil) + f.allow(t, "bound", "GET", calc+A+"&quantity=1", "", nil, http.StatusNotFound) + f.parity(t, "GET", calc+B+"&quantity=1", "") + resolve := "/api/v1/pricing/resolve?product_id=" + uuid.NewString() + "&customer_id=" + f.refuseForeign(t, "GET", resolve+B, "", nil) + f.allow(t, "bound", "GET", resolve+A, "", nil, http.StatusNotFound) + f.parity(t, "GET", resolve+B, "") +} + +// TestKeyBranchPin_PricingRuleJobConfinement closes round 4 P1 and its dealer +// wide sibling P2: a pricing rule that names a job is confined through that +// job's customer (projects.customer_id), a rule that names a customer and a +// foreign job is refused (the resolver checks both), and a rule that names +// neither a customer nor a job prices every branch's customers and is refused. +func TestKeyBranchPin_PricingRuleJobConfinement(t *testing.T) { + f := newKeyPinFixture(t) + rulesOnJob := func(job uuid.UUID) (n int) { + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM pricing_rules WHERE job_id = $1`, job).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + jobRule := func(label string, job uuid.UUID) string { + return fmt.Sprintf(`{"name":"pin job %s","rule_type":"job_override","job_id":%q,"fixed_price_ten_thousandths":10000}`, + label, job.String()) + } + + // The foreign customer's job, with no customer named: refused, no rule. + f.refuseForeign(t, "POST", "/api/v1/pricing/rules", jobRule("refused", f.jobB), nil) + if n := rulesOnJob(f.jobB); n != 0 { + t.Errorf("refused job rule wrote %d rows on the foreign customer's job", n) + } + // A rule naming the pin's customer AND the foreign customer's job: the + // resolver checks both, so it is refused too. + both := fmt.Sprintf(`{"name":"pin job both","rule_type":"job_override","customer_id":%q,"job_id":%q,"fixed_price_ten_thousandths":10000}`, + f.custID.String(), f.jobB.String()) + f.refuseForeign(t, "POST", "/api/v1/pricing/rules", both, nil) + if n := rulesOnJob(f.jobB); n != 0 { + t.Errorf("refused customer plus foreign job rule wrote %d rows", n) + } + // The pin's own customer's job passes. + f.allow(t, "bound", "POST", "/api/v1/pricing/rules", jobRule("own", f.jobA), nil, http.StatusCreated) + // The unbound key and the user keep the foreign job and the dealer wide + // rule. + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/rules", jobRule(who, f.jobB), nil, http.StatusCreated) + } + + // A rule that names neither a customer nor a job is dealer wide: refused + // for the bound key with its audit row, open to everyone else. + wide := `{"name":"pin wide","rule_type":"promotional","discount_pct":"50"}` + f.refuseForeign(t, "POST", "/api/v1/pricing/rules", wide, nil) + var wideRows int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM pricing_rules WHERE name = 'pin wide'`).Scan(&wideRows); err != nil { + t.Fatal(err) + } + if wideRows != 0 { + t.Errorf("refused dealer wide rule wrote %d rows", wideRows) + } + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/rules", wide, nil, http.StatusCreated) + } +} + +// TestKeyBranchPin_CategoryTierRefusals closes the tier half of round 4 P2: a +// tier category rule prices every branch's customers, so a bound key is +// refused it on every verb, and a bulk delete that names a tier rule row is +// refused like one that names a foreign customer's row. +func TestKeyBranchPin_CategoryTierRefusals(t *testing.T) { + f := newKeyPinFixture(t) + // PIN-TIER is not any tier the migrations seed, so the rows this test + // creates are exactly its own and the cleanup can name them. + tier := fmt.Sprintf(`{"target_type":"tier","tier":"PIN-TIER","category_id":%q,"rule_type":"markup","value_pct":"5"}`, + f.freshCategory(t).String()) + + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", tier, nil) + var tierRows int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM category_pricing_rules WHERE tier = 'PIN-TIER'`).Scan(&tierRows); err != nil { + t.Fatal(err) + } + if tierRows != 0 { + t.Errorf("refused tier rule create wrote %d rows", tierRows) + } + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/category-rules", tier, nil, http.StatusCreated) + } + + putBody := `{"rule_type":"markdown","value_pct":"2"}` + f.refuseForeign(t, "PUT", "/api/v1/pricing/category-rules/"+f.catRuleT.String(), putBody, + map[string]string{"If-Match": f.catRuleRev(t, f.catRuleT)}) + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/"+f.catRuleT.String(), "", + map[string]string{"If-Match": f.catRuleRev(t, f.catRuleT)}) + var tierRow int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM category_pricing_rules WHERE id = $1`, f.catRuleT).Scan(&tierRow); err != nil { + t.Fatal(err) + } + if tierRow != 1 { + t.Errorf("tier rule refusals disturbed the seeded tier rule: %d rows", tierRow) + } + f.refuseForeign(t, "GET", "/api/v1/pricing/category-rules/"+f.catRuleT.String()+"/audit", "", nil) + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/bulk", + fmt.Sprintf(`{"ids":[%q]}`, f.catRuleT.String()), nil) + f.parity(t, "GET", "/api/v1/pricing/category-rules/"+f.catRuleT.String()+"/audit", "") +} + +// TestKeyBranchPin_StatedLimits holds the stated limits open: the branch +// directory reads and the market index reference data answer a bound key as +// they answer everyone else, the reach the allowlist grants by design. +func TestKeyBranchPin_StatedLimits(t *testing.T) { + f := newKeyPinFixture(t) + B := f.branchB.String() + idx := uuid.NewString() + + // The branch directory reads stay reference data. + f.allow(t, "bound", "GET", "/api/v1/branches", "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/branches/"+B, "", nil, http.StatusOK) + f.parity(t, "GET", "/api/v1/branches", "") + f.parity(t, "GET", "/api/v1/branches/"+B, "") + + // The index metadata write and the history read are dealer wide reference + // data: the bound key meets the module's own answer, never a refusal. + for _, c := range []struct{ method, path string }{ + {"PUT", "/api/v1/market-indices/" + idx}, + {"GET", "/api/v1/market-indices/" + idx + "/history"}, + {"GET", "/api/v1/market-indices"}, + } { + sb, bb := f.key(t, f.bound, c.method, c.path, "", nil) + sk, _ := f.key(t, f.unbound, c.method, c.path, "", nil) + if sb == http.StatusForbidden { + t.Errorf("bound key %s %s: refused %s, want the module's own answer", c.method, c.path, bb) + } + if sb != sk { + t.Errorf("%s %s: bound %d, unbound %d, want the same answer", c.method, c.path, sb, sk) + } + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 1cc1b27f..decba449 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -70,19 +70,23 @@ type keyPinFixture struct { category uuid.UUID // one product category the priced rules hang on catRuleA uuid.UUID // a category rule scoped to the pin's customer catRuleB uuid.UUID // a category rule scoped to the foreign customer + catRuleT uuid.UUID // a tier category rule that names no customer taxExempt uuid.UUID // a tax exemption row of the foreign customer + jobA uuid.UUID // a project of the pin's customer + jobB uuid.UUID // a project of the foreign customer bound string // the key pinned to branch A unbound string // the same scopes, no pin } // pinKeyScopes opens every route under test: the location writes, the branch -// directory, the user grants, the exposure surface and the dealer wide reads. +// directory, the user grants, the exposure surface, the dealer wide routes, +// the staff roster and the customer scoped pricing and tax surface. var pinKeyScopes = []string{ "locations:write", "branches:read", "branches:write", "users:read", "users:grants", "quotes:read", "quotes:write", "admin:write", - "reports:read", "reporting:read", "reporting:write", "events:read", - "gl:read", "gl:write", "ap:read", "bankrecon:read", "sales-team:read", - "market-indices:write", "tax:write", "pricing:read", "pricing:write", + "admin:staff", "reports:read", "reporting:read", "reporting:write", "events:read", + "gl:read", "gl:write", "ap:read", "ap:write", "bankrecon:read", "bankrecon:write", "sales-team:read", + "market-indices:read", "market-indices:write", "tax:read", "tax:write", "pricing:read", "pricing:write", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -104,7 +108,7 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { branchA: uuid.New(), branchB: uuid.New(), branchC: uuid.New(), branchC2: uuid.New(), yardA: uuid.New(), yardB: uuid.New(), quoteA: uuid.New(), quoteB: uuid.New(), custID: uuid.New(), custB: uuid.New(), category: uuid.New(), catRuleA: uuid.New(), catRuleB: uuid.New(), taxExempt: uuid.New(), - yardNull: uuid.New(), + yardNull: uuid.New(), catRuleT: uuid.New(), jobA: uuid.New(), jobB: uuid.New(), } for _, r := range []struct { id uuid.UUID @@ -156,8 +160,24 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Fatalf("seed category rule: %v", err) } } + // A tier rule names no customer: it prices every branch's customers, so it + // is a dealer wide write a bound key is refused. if _, err := db.Pool.Exec(ctx, - `INSERT INTO tax_exemptions (id, customer_id, exempt_reason) VALUES ($1, $2, 'pin')`, f.taxExempt, f.custB); err != nil { + `INSERT INTO category_pricing_rules (id, target_type, tier, category_id, rule_type, rule_value, created_by) + VALUES ($1, 'TIER', 'CONTRACTOR', $2, 'MARKUP', 10, 'test')`, f.catRuleT, f.category); err != nil { + t.Fatalf("seed tier rule: %v", err) + } + // A project (a job) belongs to exactly one customer: a pricing rule that + // names a job prices that customer, so the job resolves to its customer. + for _, j := range []struct{ id, customer uuid.UUID }{{f.jobA, f.custID}, {f.jobB, f.custB}} { + if _, err := db.Pool.Exec(ctx, + `INSERT INTO projects (id, customer_id, name, status) VALUES ($1, $2, 'pin job', 'Active')`, j.id, j.customer); err != nil { + t.Fatalf("seed project: %v", err) + } + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO tax_exemptions (id, customer_id, exempt_reason, certificate_number, issuing_state) + VALUES ($1, $2, 'pin', 'PIN-CERT-1', 'WA')`, f.taxExempt, f.custB); err != nil { t.Fatalf("seed tax exemption: %v", err) } // A legacy row that names no branch: the denorm trigger refuses such rows @@ -180,12 +200,14 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { _, _ = db.Pool.Exec(ctx, `DELETE FROM quotes WHERE id IN ($1, $2)`, f.quoteA, f.quoteB) _, _ = db.Pool.Exec(ctx, `DELETE FROM tax_exemptions WHERE customer_id IN ($1, $2) OR id = $3`, f.custID, f.custB, f.taxExempt) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'category_rule' - AND entity_id IN (SELECT id::text FROM category_pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) + AND entity_id IN (SELECT id::text FROM category_pricing_rules WHERE customer_id IN ($1, $2) OR tier = 'PIN-TIER')`, f.custID, f.custB) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'pricing_rule' - AND entity_id IN (SELECT id::text FROM pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) - _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_audit WHERE rule_id IN (SELECT id FROM category_pricing_rules WHERE customer_id IN ($1, $2))`, f.custID, f.custB) - _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_rules WHERE customer_id IN ($1, $2)`, f.custID, f.custB) - _, _ = db.Pool.Exec(ctx, `DELETE FROM pricing_rules WHERE customer_id IN ($1, $2)`, f.custID, f.custB) + AND entity_id IN (SELECT id::text FROM pricing_rules WHERE customer_id IN ($1, $2) OR job_id IN ($3, $4) + OR (customer_id IS NULL AND job_id IS NULL AND name LIKE 'pin %'))`, f.custID, f.custB, f.jobA, f.jobB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_audit WHERE rule_id IN (SELECT id FROM category_pricing_rules WHERE customer_id IN ($1, $2) OR tier = 'PIN-TIER')`, f.custID, f.custB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM category_pricing_rules WHERE customer_id IN ($1, $2) OR id = $3 OR tier = 'PIN-TIER'`, f.custID, f.custB, f.catRuleT) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pricing_rules WHERE customer_id IN ($1, $2) OR job_id IN ($3, $4) OR (customer_id IS NULL AND job_id IS NULL AND name LIKE 'pin %')`, f.custID, f.custB, f.jobA, f.jobB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM projects WHERE id IN ($1, $2)`, f.jobA, f.jobB) _, _ = db.Pool.Exec(ctx, `DELETE FROM product_categories WHERE id = $1`, f.category) _, _ = db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id IN ($1, $2)`, f.custID, f.custB) _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id IN ($1, $2)`, f.custID, f.custB) @@ -239,6 +261,9 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { salesteam.NewHandler(salesteam.NewRepository(db)). RegisterRoutes(mux, wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) + // The staff roster surface exactly as serve registers it. + wireStaffAdmin(mux, db, audit.NewLogger(db), "") + // The customer confined modules exactly as serve registers them: the tax // exemption writes and the customer priced rules. customerSvc := customer.NewService(customer.NewRepository(db)) @@ -265,7 +290,18 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { f.bound = mint("pin bound to A", &f.branchA) f.unbound = mint("pin unbound", nil) mkAuth := middleware.NewMachineKeyAuth(pinKeyValidator{svc: keys}, audit.NewLogger(db), []string{"/api/integration/"}, nil) - f.srv = httptest.NewServer(mkAuth.Handler(pinClaims(mux))) + // The production chain, built by the same builder Run uses, so every test + // here drives the wiring serve serves: the machine key core, the request + // size limit, the idempotency layer and the mux mounts above. Only the + // claims a JWT would carry are a stand in, injected outside the chain for + // the user requests (a keyed request never carries them). + chain := buildChain(ChainDeps{ + Mux: mux, + DB: db, + AuditLog: audit.NewLogger(db), + MachineKeyAuth: mkAuth, + }) + f.srv = httptest.NewServer(pinClaims(chain)) t.Cleanup(f.srv.Close) return f } @@ -721,16 +757,17 @@ func TestKeyBranchPin_ExposureRoutes(t *testing.T) { } } -// TestKeyBranchPin_DealerWideReads holds the pin on the dealer wide surface: -// every reporting route, the events feed, the two exposure lists, the GL, AP, -// bank reconciliation and sales team reads, the known users list and the -// market index refresh refuse a branch bound key outright with their audit -// row, while the unbound key and the user keep the same answer as each other -// on every route, and the GL writes (no branch fact, outside the ruling) keep -// their reach for every principal alike. +// TestKeyBranchPin_DealerWideReads holds the deny by default refusals on the +// dealer wide surface: every reporting route, the events feed, the two exposure +// lists, the GL, AP, bank reconciliation and sales team routes, the known +// users list, the staff roster, the market index refresh and its preview, and +// the unfiltered pricing lists refuse a branch bound key outright with their +// audit row, while the unbound key and the user keep the same answer as each +// other on every route. func TestKeyBranchPin_DealerWideReads(t *testing.T) { f := newKeyPinFixture(t) preview := `{"entity_type":"invoices","definition":{}}` + idx := uuid.NewString() for _, c := range []struct{ method, path, body string }{ {"GET", "/api/v1/reports/sales-summary", ""}, {"GET", "/api/v1/reports/daily-till", ""}, @@ -753,15 +790,28 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { {"GET", "/api/v1/gl/profit-and-loss", ""}, {"GET", "/api/v1/gl/balance-sheet", ""}, {"GET", "/api/v1/gl/fiscal-periods", ""}, + {"POST", "/api/v1/gl/accounts", `{"name":"pin wall probe"}`}, + {"POST", "/api/v1/gl/journal-entries", `{"lines":[]}`}, + {"POST", "/api/v1/gl/fiscal-periods/" + uuid.NewString() + "/close", ""}, {"GET", "/api/v1/ap/invoices", ""}, {"GET", "/api/v1/ap/payments", ""}, {"GET", "/api/v1/ap/aging", ""}, + {"POST", "/api/v1/ap/invoices", `{"vendor_id":"` + uuid.NewString() + `"}`}, + {"POST", "/api/v1/ap/payments", `{"invoice_id":"` + uuid.NewString() + `"}`}, {"GET", "/api/v1/bankrecon/accounts", ""}, {"GET", "/api/v1/bankrecon/sessions", ""}, + {"POST", "/api/v1/bankrecon/import", ""}, + {"POST", "/api/v1/bankrecon/match", `{"bank_tx_id":"` + uuid.NewString() + `"}`}, + {"POST", "/api/v1/bankrecon/sessions", `{"bank_account_id":"` + uuid.NewString() + `"}`}, {"GET", "/api/v1/sales-team", ""}, {"GET", "/api/v1/sales-team/" + uuid.NewString(), ""}, {"GET", "/api/v1/users", ""}, - {"POST", "/api/v1/market-indices/" + uuid.NewString() + "/refresh", ""}, + {"GET", "/api/v1/admin/staff", ""}, + {"POST", "/api/v1/admin/staff", `{"name":"pin"}`}, + {"POST", "/api/v1/market-indices/" + idx + "/refresh", ""}, + {"POST", "/api/v1/market-indices/" + idx + "/refresh/preview", `{"new_value":120}`}, + {"GET", "/api/v1/pricing/rules", ""}, + {"GET", "/api/v1/pricing/matrix", ""}, } { f.refuseForeign(t, c.method, c.path, c.body, nil) f.parity(t, c.method, c.path, c.body) @@ -774,23 +824,20 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { "/api/v1/reports/customer-statement/" + f.custID.String(), "/api/v1/events", "/api/v1/quotes/exposure", "/api/v1/gl/accounts", "/api/v1/ap/invoices", "/api/v1/sales-team", "/api/v1/users", + "/api/v1/admin/staff", "/api/v1/pricing/rules", "/api/v1/pricing/matrix", } { if sk := f.parity(t, "GET", path, ""); sk != http.StatusOK { t.Errorf("unbound key GET %s: %d, want 200", path, sk) } } - // The GL writes carry no branch fact and are outside the ruling: every - // principal meets the same module answer, none a wall refusal. - body := `{"name":"pin wall probe"}` - sk, _ := f.key(t, f.unbound, "POST", "/api/v1/gl/accounts", body, nil) - su, _ := f.user(t, "POST", "/api/v1/gl/accounts", body, nil) - sb, bb := f.key(t, f.bound, "POST", "/api/v1/gl/accounts", body, nil) - if sk != su || sb != sk { - t.Errorf("POST /api/v1/gl/accounts: unbound %d, user %d, bound %d (%s), want one answer", sk, su, sb, bb) - } - if sb == http.StatusForbidden && strings.Contains(string(bb), "branch bound key") { - t.Errorf("POST /api/v1/gl/accounts: the GL writes are outside the ruling and must not hit the wall: %s", bb) + // A route the router resolves to no pattern at all is refused the same + // way: an unregistered path inside a machine key module fails closed for + // a bound key, and answers 404 to everyone else. + f.refuseForeign(t, "GET", "/api/v1/tax/exemptions", "", nil) + sk := f.parity(t, "GET", "/api/v1/tax/exemptions", "") + if sk != http.StatusNotFound { + t.Errorf("GET /api/v1/tax/exemptions: unbound and user answer %d, want 404 both", sk) } } @@ -835,7 +882,8 @@ func (f *keyPinFixture) seedCatRule(t *testing.T, id, customer, category uuid.UU func (f *keyPinFixture) seedTaxExempt(t *testing.T, id, customer uuid.UUID) { t.Helper() if _, err := f.db.Pool.Exec(context.Background(), - `INSERT INTO tax_exemptions (id, customer_id, exempt_reason) VALUES ($1, $2, 'pin')`, id, customer); err != nil { + `INSERT INTO tax_exemptions (id, customer_id, exempt_reason, certificate_number, issuing_state) + VALUES ($1, $2, 'pin', 'PIN-CERT-1', 'WA')`, id, customer); err != nil { t.Fatalf("seed tax exemption: %v", err) } } From 6a2428af8ad95c91a40f72e64bc085644804df42 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 11:50:24 -0700 Subject: [PATCH 15/74] feat(middleware): the branch bound key route allowlist, deny by default One table, core/pkg/middleware/keybranch_allow.go: every route a machine key can reach (345 of them, method plus pattern exactly as the census names them) carries exactly one class. WALLED (183) runs behind the branch middleware; CONFINED (28) is held by the key branch wall or the customer branch wall, each entry naming its test; STATED LIMIT (69) is reachable unconfined by design, each entry with its one line reason; REFUSED (65) answers a bound key 403 forbidden with the key.branch_refused row. The check runs once: KeyBranchRouteGate, mounted in buildChain inside auth (it reads the pin the machine key core sets) and outside the confirm gate and idempotency (a refused request claims nothing). It resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, including a route the router resolves to no pattern: a missed mount can no longer fail open, whatever the wiring beneath the chain does. A request with no pin passes untouched. The per mount refusals the opt in approach added are gone (the gate is the one place refusals live): the reporting, events feed, GL, AP, bank reconciliation and sales team mounts return to their role guards, the index admin guard loses its suffix matching (which missed the refresh preview), the exposure wall keeps only its by id confinement, and the location handler drops the directory create and known users walls. RefuseBound leaves the wall's API with its test. The census test fails when a machine key reachable census route is unclassified, when an entry matches no census route, and when a confined or stated limit entry carries no justification; it runs in the untagged suite with no database. TestKeyBranchPin_AllowlistGate- CoversCensus drives every reachable census route through the production chain with stub handlers: the bound key is refused exactly the 65 REFUSED entries, and the unbound key passes all 345. The pin fixture now builds its chain with buildChain, the builder Run uses. Signed-off-by: colton --- core/internal/app/serve/chain.go | 16 +- core/internal/app/serve/serve.go | 24 +- core/internal/app/serve/wire_branch_wall.go | 44 +- core/internal/app/serve/wire_exposure.go | 60 +- .../app/serve/wire_key_branch_allow_test.go | 176 +++++- .../app/serve/wire_key_branch_pin_test.go | 25 +- core/internal/location/handler.go | 24 +- core/pkg/middleware/keybranch.go | 11 - core/pkg/middleware/keybranch_allow.go | 549 ++++++++++++++++++ .../middleware/keybranch_allow_census_test.go | 95 +++ core/pkg/middleware/keybranch_test.go | 22 - 11 files changed, 909 insertions(+), 137 deletions(-) create mode 100644 core/pkg/middleware/keybranch_allow.go create mode 100644 core/pkg/middleware/keybranch_allow_census_test.go diff --git a/core/internal/app/serve/chain.go b/core/internal/app/serve/chain.go index 22cbecbc..191b1378 100644 --- a/core/internal/app/serve/chain.go +++ b/core/internal/app/serve/chain.go @@ -66,8 +66,8 @@ type AuditSink = confirmgate.AuditSink // // RequestLogger -> HTTPMetrics -> RequestID -> Recovery // -> RateLimit -> CORS -> Actor -> [Auth or MachineKeyAuth] -// -> ConfirmGate -> MaxRequestSize -> Idempotency -// -> CacheControl -> Mux +// -> KeyBranchRouteGate -> ConfirmGate -> MaxRequestSize +// -> Idempotency -> CacheControl -> Mux // // so the confirm gate sits INSIDE auth (the gate sees the key id the // machine-key core set, so ADR 0007 5.4 "keyed requests are governed by @@ -103,6 +103,18 @@ func buildChain(d ChainDeps) http.Handler { // Request size limit (10MB default). finalHandler = middleware.MaxRequestSize(10 << 20)(finalHandler) + // The branch bound key's route allowlist gate (ADR 0007 section 5.5, deny + // by default): inside auth, so it reads the pin the machine key core set, + // and outside the confirm gate and idempotency, so a refused request + // claims nothing. It refuses a bound key every route the allowlist + // (pkg/middleware/keybranch_allow.go) does not admit, whatever the module + // mounts beneath it; a request with no pin passes untouched. + var branchAuditor middleware.BranchRefusalAuditor + if ba, ok := d.AuditLog.(middleware.BranchRefusalAuditor); ok { + branchAuditor = ba + } + finalHandler = middleware.KeyBranchRouteGate(d.Mux, branchAuditor)(finalHandler) + // Auth (JWT verification; a Bearer machine key dispatches to the // machine-key core inside it). In AUTH_MODE=dev the JWT layer is off but // machine keys still authenticate and scope check exactly as behind it. diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 32a48956..a2e2f7a1 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -319,11 +319,13 @@ func Run() { WithAudit(customeraudit.New(auditLog)) wall.customers(mux, customerSvc) - // Sales Team Module + // Sales Team Module: the roster spans every branch, so a branch bound + // key is refused it outright by the route allowlist gate (ADR 0007 + // section 5.5). salesTeamRepo := salesteam.NewRepository(db) salesTeamHandler := salesteam.NewHandler(salesTeamRepo) salesTeamHandler.RegisterRoutes(mux, - wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) + middleware.RequireRole("admin", "owner", "sales")) // CRM Module: on the wire contract, its writes in one transaction with // their audit row and activity.* event, every route behind the branch @@ -343,12 +345,14 @@ func Run() { WithAudit(auditLog) wall.quotes(mux, quoteSvc) - // GL Module (Full General Ledger) + // GL Module (Full General Ledger): the books span every branch, so a + // branch bound key is refused the whole surface outright by the route + // allowlist gate (ADR 0007 section 5.5). glAdapter := glint.NewMockGLAdapter() glRepo := gl.NewRepository(db) glSvc := gl.NewService(glRepo, glAdapter, logger) glHandler := gl.NewHandler(glSvc) - glHandler.RegisterRoutes(mux, wall.dealerWideReads("the general ledger spans every branch", "admin", "owner")) + glHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) // Account Module: the AR core, the single writer of the subledger, the // balance, the applications and the AR columns of the documents (ADR 0005 @@ -667,23 +671,25 @@ func Run() { posHandler := pos.NewHandler(posSvc) posHandler.RegisterRoutes(mux, scoped("admin", "owner", "cashier")) - // Accounts Payable Module + // Accounts Payable Module: payables span every branch, so a branch bound + // key is refused the whole surface outright by the route allowlist gate. apRepo := ap.NewRepository(db) apSvc := ap.NewService(db, apRepo, glSvc, logger) apHandler := ap.NewHandler(apSvc) - apHandler.RegisterRoutes(mux, wall.dealerWideReads("payables span every branch", "admin", "owner", "finance")) + apHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) // 3-Way PO Matching Module matchingRepo := matching.NewRepository(db) matchingSvc := matching.NewService(db, matchingRepo, poSvc, apSvc, logger) wall.matching(mux, matchingSvc) - // Bank Reconciliation Module + // Bank Reconciliation Module: bank reconciliation spans every branch, so + // a branch bound key is refused the whole surface outright by the route + // allowlist gate. bankreconRepo := bankrecon.NewRepository(db) bankreconSvc := bankrecon.NewService(db, bankreconRepo, glSvc, logger) bankreconHandler := bankrecon.NewHandler(bankreconSvc) - bankreconHandler.RegisterRoutes(mux, - wall.dealerWideReads("bank reconciliation spans every branch", "admin", "owner", "finance")) + bankreconHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) // Reporting Module reportingRepo := reporting.NewRepository(db) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 1bda8c98..247d1883 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -54,54 +54,30 @@ func newBranchWall(db *database.DB) *branchWall { return w } -// reporting mounts the reporting surface in its three registrations: every -// report spans every branch's rows (the summaries, the till, the aging, the -// statements, the builder, the exports, the saved runs and the schedules), so -// a branch bound key is refused outright and the role guard alone serves -// everyone else (ADR 0007 section 5.5). Three methods because serve builds +// reporting mounts the reporting surface in its three registrations. Every +// report spans every branch's rows, so a branch bound key is refused the +// surface outright by the route allowlist gate (ADR 0007 section 5.5) and the +// role guard alone serves everyone else. Three methods because serve builds // the scheduler between the registrations. func (w *branchWall) reporting(mux *http.ServeMux, h *reporting.Handler) { - h.RegisterRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner", "finance"), - w.keyWall.RefuseBound("reports span every branch"))) + h.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) } // reportingSchedules is reporting over the builder and schedule routes. func (w *branchWall) reportingSchedules(mux *http.ServeMux, h *reporting.Handler, executor reporting.ScheduleExecutor) { - wireReportSchedules(mux, h, executor, w.keyWall.RefuseBound("reports span every branch")) + wireReportSchedules(mux, h, executor) } // reportingBIIntegration is reporting over the BI export routes. func (w *branchWall) reportingBIIntegration(mux *http.ServeMux, h *reporting.Handler) { - h.RegisterBIIntegrationRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner"), - w.keyWall.RefuseBound("reports span every branch"))) + h.RegisterBIIntegrationRoutes(mux, middleware.RequireRole("admin", "owner")) } // eventsFeed mounts the events feed: the outbox carries every branch's events -// with their payloads, so a branch bound key is refused outright and the role -// guard alone serves everyone else. +// with their payloads, so a branch bound key is refused it outright by the +// route allowlist gate and the role guard alone serves everyone else. func (w *branchWall) eventsFeed(mux *http.ServeMux, h *events.Handler) { - h.RegisterRoutes(mux, middleware.Compose(middleware.RequireRole("admin", "owner"), - w.keyWall.RefuseBound("the event feed spans every branch"))) -} - -// dealerWideReads is the guard for a module whose books carry no branch -// dimension (the GL, AP and bank reconciliation) and the sales team roster: -// the reads span every branch, so a branch bound key is refused them, while -// the writes, which carry no branch fact, keep the role guard alone for every -// principal alike. -func (w *branchWall) dealerWideReads(reason string, roles ...string) func(http.Handler) http.Handler { - role := middleware.RequireRole(roles...) - reads := middleware.Compose(role, w.keyWall.RefuseBound(reason)) - return func(next http.Handler) http.Handler { - readHandler, writeHandler := reads(next), role(next) - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet || r.Method == http.MethodHead { - readHandler.ServeHTTP(rw, r) - return - } - writeHandler.ServeHTTP(rw, r) - }) - } + h.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) } // chargeCodes mounts the charge code master: every role that prices a line diff --git a/core/internal/app/serve/wire_exposure.go b/core/internal/app/serve/wire_exposure.go index 4f97e512..1760c7c6 100644 --- a/core/internal/app/serve/wire_exposure.go +++ b/core/internal/app/serve/wire_exposure.go @@ -8,7 +8,6 @@ import ( "errors" "log/slog" "net/http" - "strings" "github.com/gablelbm/gable/internal/delivery" "github.com/gablelbm/gable/internal/order" @@ -176,10 +175,10 @@ type exposureRoutes struct { func registerExposureRoutes(mux *http.ServeMux, r exposureRoutes) { // Salesperson + owner surface: at-risk list, per-quote detail and actions, // portfolio report, admin scan trigger. The key branch wall holds a branch - // bound key to its pin on the by id routes (their quote's branch) and - // refuses it the dealer wide routes outright: the scan re-checks every - // branch's quotes and the two lists (the at-risk and portfolio reports) - // return every branch's exposure rows. + // bound key to its pin on the by id routes (their quote's branch, + // CONFINED in the route allowlist); the two lists (the at-risk and + // portfolio reports) and the admin scan act across every branch and are + // refused a bound key outright by the route allowlist gate. var keyAuditor middleware.BranchRefusalAuditor // a nil logger must not ride a non nil interface if r.AuditLog != nil { keyAuditor = r.AuditLog @@ -194,30 +193,18 @@ func registerExposureRoutes(mux *http.ServeMux, r exposureRoutes) { // Buyer/admin surface: index refresh (+ dry-run preview), metadata edit, // history time-series. Deliberately does NOT re-register // GET /api/v1/market-indices: that belongs to pricing.EscalatorHandler and - // a duplicate pattern would panic the ServeMux. The refresh re-checks - // every branch's quotes, the same dealer wide effect the scan refusal - // closes, so a branch bound key is refused it; the index metadata and - // history are dealer wide reference data and keep the role guard alone. + // a duplicate pattern would panic the ServeMux. The refresh and its + // preview re-check every branch's quotes, so the route allowlist gate + // refuses a branch bound key both; the index metadata and history are + // dealer wide reference data, stated limits of the allowlist. pricing.NewIndexAdminHandler(r.Escalators, r.Exposure, r.Scanner, r.DB, r.Logger). - RegisterRoutes(mux, indexAdminGuard(keyWall)) + RegisterRoutes(mux, indexAdminGuard()) } -// indexAdminGuard holds the market index admin surface to a branch bound key's -// pin where it acts across branches: the refresh suffix route is refused -// outright, everything else keeps the role guard alone. -func indexAdminGuard(keyWall *middleware.KeyBranchWall) func(http.Handler) http.Handler { - role := middleware.RequireRole("admin", "owner") - refresh := middleware.Compose(role, keyWall.RefuseBound("the index refresh re-checks every branch's quotes")) - return func(next http.Handler) http.Handler { - refreshHandler, otherHandler := refresh(next), role(next) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/refresh") { - refreshHandler.ServeHTTP(w, r) - return - } - otherHandler.ServeHTTP(w, r) - }) - } +// indexAdminGuard is the market index admin surface's role guard. The refresh +// and preview refusals live in the route allowlist gate, not here. +func indexAdminGuard() func(http.Handler) http.Handler { + return middleware.RequireRole("admin", "owner") } // quoteBranchOf resolves the branch a quote belongs to, the row branch the @@ -239,24 +226,21 @@ func (r exposureRoutes) quoteBranchOf(ctx context.Context, id uuid.UUID) (*uuid. return branch, nil } -// exposureKeyBranchWall routes the exposure surface's handlers through the -// wall their request shape demands: the five by id quote routes hold their -// quote's branch to the pin, and the admin scan and the two list routes (the -// at-risk and portfolio reports, which return every branch's rows) refuse a -// bound key outright. +// exposureKeyBranchWall routes the exposure surface's five by id quote routes +// through the key branch wall: each holds its quote's branch to a bound key's +// pin (CONFINED in the route allowlist, proven by +// TestKeyBranchPin_ExposureRoutes). The two lists and the admin scan are +// REFUSED by the route allowlist gate before the handler runs, so they keep +// the role guard alone here. func exposureKeyBranchWall(keyWall *middleware.KeyBranchWall, quoteBranchOf func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error)) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { byID := keyWall.RowBranch(quoteBranchOf)(next) - dealerWide := keyWall.RefuseBound("the exposure surface acts across every branch")(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/api/v1/admin/exposure-scan": - dealerWide.ServeHTTP(w, r) - case r.PathValue("id") != "": + if r.PathValue("id") != "" { byID.ServeHTTP(w, r) - default: - dealerWide.ServeHTTP(w, r) + return } + next.ServeHTTP(w, r) }) } } diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index 16a2be26..e6883730 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -16,8 +16,16 @@ import ( "context" "fmt" "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" + "github.com/gablelbm/gable/internal/techadmin" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) @@ -195,7 +203,6 @@ func TestKeyBranchPin_StatedLimits(t *testing.T) { for _, c := range []struct{ method, path string }{ {"PUT", "/api/v1/market-indices/" + idx}, {"GET", "/api/v1/market-indices/" + idx + "/history"}, - {"GET", "/api/v1/market-indices"}, } { sb, bb := f.key(t, f.bound, c.method, c.path, "", nil) sk, _ := f.key(t, f.unbound, c.method, c.path, "", nil) @@ -207,3 +214,170 @@ func TestKeyBranchPin_StatedLimits(t *testing.T) { } } } + +// allowlistGateFixture is the allowlist gate over the whole census: a stub +// handler registered for every machine key reachable census route, behind the +// production chain (buildChain) and the real machine key core, with one key +// bound to branch A and one unbound, each holding every scope the grammar can +// grant. A stub answers 200, so the only 403 a bound key can meet is the +// gate's, and the test says exactly which routes the allowlist refuses. +type allowlistGateFixture struct { + t *testing.T + srv *httptest.Server + bound string + unbound string +} + +func newAllowlistGateFixture(t *testing.T) *allowlistGateFixture { + t.Helper() + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + t.Setenv("AUTH_MODE", "dev") + ctx := context.Background() + branchA := uuid.New() + if _, err := db.Pool.Exec(ctx, + `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'pin gate branch')`, + branchA, "pin-gate-"+branchA.String()[:8]); err != nil { + t.Fatalf("seed branch: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, branchA) + }) + + // One stub per machine key reachable census route: the pattern the gate + // resolves is the registered one, exactly as the census names it. + mux := http.NewServeMux() + stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"stub":true}`)) + }) + for _, line := range allowlistCensusLines(t) { + fields := strings.Split(line, "\t") + mux.HandleFunc(fields[0]+" "+fields[1], stub) + } + + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) + mint := func(name string, branch *uuid.UUID) string { + t.Helper() + raw, _, err := keys.GenerateKey(ctx, name, middleware.ValidScopeGrammar(), branch) + if err != nil { + t.Fatalf("mint %s: %v", name, err) + } + return raw + } + f := &allowlistGateFixture{t: t, + bound: mint("pin gate bound", &branchA), + unbound: mint("pin gate unbound", nil), + } + mkAuth := middleware.NewMachineKeyAuth(pinKeyValidator{svc: keys}, audit.NewLogger(db), []string{"/api/integration/"}, nil) + chain := buildChain(ChainDeps{Mux: mux, DB: db, AuditLog: audit.NewLogger(db), MachineKeyAuth: mkAuth}) + f.srv = httptest.NewServer(pinClaims(chain)) + t.Cleanup(f.srv.Close) + return f +} + +// allowlistCensusLines reads the committed census from the serve package. +func allowlistCensusLines(t *testing.T) []string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "api", "ROUTES.txt")) + if err != nil { + t.Fatalf("read api/ROUTES.txt: %v", err) + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, "\t") + if len(fields) < 2 || !middleware.KeyBranchRouteReachable(fields[0], fields[1]) { + continue + } + out = append(out, line) + } + if len(out) == 0 { + t.Fatal("the census carries no machine key reachable routes") + } + return out +} + +func (f *allowlistGateFixture) call(t *testing.T, key, method, path string) int { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+key) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + return res.StatusCode +} + +// TestKeyBranchPin_AllowlistGateCoversCensus drives every machine key +// reachable census route through the production chain: the bound key is +// refused exactly the REFUSED entries and passes everything else, the +// unbound key and the user pass everything. This is the one test that says +// the gate and the table agree with the census on every route at once, so a +// route reclassified, unclassified or misclassified fails here by name. +func TestKeyBranchPin_AllowlistGateCoversCensus(t *testing.T) { + f := newAllowlistGateFixture(t) + refused, admitted := 0, 0 + for _, line := range allowlistCensusLines(t) { + fields := strings.Split(line, "\t") + method, pattern := fields[0], fields[1] + // A census pattern is the registered shape; drive one instance of it. + path := allowlistInstantiate(pattern) + want403 := false + if class, ok := middleware.KeyBranchRouteClassFor(method, pattern); ok { + want403 = class == middleware.KeyBranchClassRefused + } + if got := f.call(t, f.bound, method, path); (got == http.StatusForbidden) != want403 { + t.Errorf("bound key %s %s: %d, want %s", method, pattern, got, allowlistWant(want403)) + } + if got := f.call(t, f.unbound, method, path); got != http.StatusOK { + t.Errorf("unbound key %s %s: %d, want 200 (the stub answers)", method, pattern, got) + } + if want403 { + refused++ + } else { + admitted++ + } + } + if refused == 0 || admitted == 0 { + t.Fatalf("the census drove %d refused and %d admitted routes; both halves must be exercised", refused, admitted) + } + t.Logf("allowlist gate over the census: %d routes, %d refused to the bound key, %d admitted", refused+admitted, refused, admitted) +} + +// allowlistInstantiate replaces a census pattern's wildcards with instance +// values the router accepts. +func allowlistInstantiate(pattern string) string { + out := pattern + out = strings.ReplaceAll(out, "{id}", "11111111-1111-1111-1111-111111111111") + out = strings.ReplaceAll(out, "{customerId}", "22222222-2222-2222-2222-222222222222") + out = strings.ReplaceAll(out, "{customerID}", "22222222-2222-2222-2222-222222222222") + out = strings.ReplaceAll(out, "{sub}", "pin-sub") + out = strings.ReplaceAll(out, "{branch_id}", "33333333-3333-3333-3333-333333333333") + out = strings.ReplaceAll(out, "{module_id}", "m") + out = strings.ReplaceAll(out, "{code}", "EA") + out = strings.ReplaceAll(out, "{key}", "millwork") + out = strings.ReplaceAll(out, "{entity}", "customers") + out = strings.ReplaceAll(out, "{po_id}", "44444444-4444-4444-4444-444444444444") + out = strings.ReplaceAll(out, "{delivery_id}", "44444444-4444-4444-4444-444444444444") + out = strings.ReplaceAll(out, "{itemId}", "1") + out = strings.ReplaceAll(out, "{freightId}", "55555555-5555-5555-5555-555555555555") + out = strings.ReplaceAll(out, "{collateralId}", "66666666-6666-6666-6666-666666666666") + out = strings.ReplaceAll(out, "{mediaId}", "77777777-7777-7777-7777-777777777777") + return out +} + +func allowlistWant(refused bool) string { + if refused { + return "403 (the allowlist refuses it)" + } + return "200 (the stub answers)" +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index decba449..c1ac0a17 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -246,20 +246,22 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { // The dealer wide modules exactly as serve registers them: the reporting // surface in its three registrations, the events feed, the GL, AP and bank - // reconciliation books and the sales team reads. + // reconciliation books and the sales team reads. A branch bound key is + // refused them by the route allowlist gate in the chain, not by these + // mounts. reportingHandler := reporting.NewHandler(reporting.NewService(reporting.NewRepository(db))) wall.reporting(mux, reportingHandler) wall.reportingSchedules(mux, reportingHandler, nil) wall.reportingBIIntegration(mux, reportingHandler) wall.eventsFeed(mux, events.NewHandler(db)) glSvc := gl.NewService(gl.NewRepository(db), glint.NewMockGLAdapter(), slog.Default()) - gl.NewHandler(glSvc).RegisterRoutes(mux, wall.dealerWideReads("the general ledger spans every branch", "admin", "owner")) + gl.NewHandler(glSvc).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) ap.NewHandler(ap.NewService(db, ap.NewRepository(db), glSvc, slog.Default())). - RegisterRoutes(mux, wall.dealerWideReads("payables span every branch", "admin", "owner", "finance")) + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) bankrecon.NewHandler(bankrecon.NewService(db, bankrecon.NewRepository(db), glSvc, slog.Default())). - RegisterRoutes(mux, wall.dealerWideReads("bank reconciliation spans every branch", "admin", "owner", "finance")) + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) salesteam.NewHandler(salesteam.NewRepository(db)). - RegisterRoutes(mux, wall.dealerWideReads("the sales team roster spans every branch", "admin", "owner", "sales")) + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) // The staff roster surface exactly as serve registers it. wireStaffAdmin(mux, db, audit.NewLogger(db), "") @@ -832,12 +834,17 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { } // A route the router resolves to no pattern at all is refused the same - // way: an unregistered path inside a machine key module fails closed for - // a bound key, and answers 404 to everyone else. + // way: a method no route registers on that path (the mux answers 405) or + // a path no route registers (404) fails closed for a bound key, and the + // unbound key and the user keep the router's own answer, never a refusal. f.refuseForeign(t, "GET", "/api/v1/tax/exemptions", "", nil) sk := f.parity(t, "GET", "/api/v1/tax/exemptions", "") - if sk != http.StatusNotFound { - t.Errorf("GET /api/v1/tax/exemptions: unbound and user answer %d, want 404 both", sk) + if sk == http.StatusForbidden { + t.Errorf("GET /api/v1/tax/exemptions: unbound and user answer 403; the router own answer was expected") + } + f.refuseForeign(t, "GET", "/api/v1/quotes/analytics/unregistered/deeper", "", nil) + if sk := f.parity(t, "GET", "/api/v1/quotes/analytics/unregistered/deeper", ""); sk != http.StatusNotFound { + t.Errorf("GET /api/v1/quotes/analytics/unregistered/deeper: unbound and user answer %d, want 404 both", sk) } } diff --git a/core/internal/location/handler.go b/core/internal/location/handler.go index b057b356..b3a73292 100644 --- a/core/internal/location/handler.go +++ b/core/internal/location/handler.go @@ -52,12 +52,14 @@ func (h *Handler) WithBranchWall(g LocationGuard, branchMw func(http.Handler) ht // WithKeyBranchWall puts the key branch wall on the routes that mount no // branch middleware (ADR 0007 section 5.5): the by id location writes, the -// branch directory verbs and the user grant routes hold a branch bound key +// branch directory writes and the user grant routes hold a branch bound key // to its pin, refusing it another branch with the key.branch_refused row. // The branch reads the wall narrows are /branches/{id}/users (refused for -// another branch) and /users/{sub}/branches (filtered to the pin), and the -// known users list (/users, every branch's subs) refuses a bound key -// outright. Without it nothing changes, so serve always sets it. +// another branch) and /users/{sub}/branches (filtered to the pin). The +// directory create and the known users list (/users, every branch's subs) +// refuse a bound key outright through the route allowlist gate, which covers +// every registration of these routes. Without it nothing changes, so serve +// always sets it. func (h *Handler) WithKeyBranchWall(w *middleware.KeyBranchWall) *Handler { h.keyWall = w return h @@ -99,9 +101,11 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand // The key branch wall on the routes that mount no branch middleware: the // by id location writes hold the row's branch to a bound key's pin, the - // directory create refuses one outright, the directory writes and the - // branch users read hold the path branch to it, and the grant routes hold - // the body or path branch to it (ADR 0007 section 5.5). + // branch directory writes and the branch users read hold the path branch + // to it, and the grant routes hold the body or path branch to it (ADR + // 0007 section 5.5). The directory create (POST /api/v1/branches) and the + // known users list (GET /api/v1/users) refuse a bound key outright, which + // the route allowlist gate does for every registration of these routes. wall := func(mw func(http.Handler) http.Handler) func(http.HandlerFunc) http.HandlerFunc { if mw == nil || h.keyWall == nil { return func(hf http.HandlerFunc) http.HandlerFunc { return hf } @@ -112,8 +116,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand branchIdWall := wall(h.keyWall.NamedBranch("id")) bodyBranchWall := wall(h.keyWall.BodyBranch()) branchIdPathWall := wall(h.keyWall.NamedBranch("branch_id")) - directoryWall := wall(h.keyWall.RefuseBound("the branch directory is outside a branch bound key")) - knownUsersWall := wall(h.keyWall.RefuseBound("the known users list spans every branch")) // Legacy / shared location endpoints. create := http.HandlerFunc(h.CreateLocation) @@ -132,7 +134,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand // Branch CRUD. mux.HandleFunc("GET /api/v1/branches", guard(h.ListBranches)) - mux.HandleFunc("POST /api/v1/branches", adminGuard(directoryWall(h.CreateBranch))) + mux.HandleFunc("POST /api/v1/branches", adminGuard(h.CreateBranch)) mux.HandleFunc("GET /api/v1/branches/{id}", guard(h.GetBranch)) mux.HandleFunc("PUT /api/v1/branches/{id}", adminGuard(branchIdWall(h.UpdateBranch))) mux.HandleFunc("DELETE /api/v1/branches/{id}", adminGuard(branchIdWall(h.DeleteBranch))) @@ -145,7 +147,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand // User-branch grants. if h.userRepo != nil { mux.HandleFunc("GET /api/v1/me/branches", guard(h.ListMyBranches)) - mux.HandleFunc("GET /api/v1/users", adminGuard(knownUsersWall(h.ListKnownUsers))) + mux.HandleFunc("GET /api/v1/users", adminGuard(h.ListKnownUsers)) mux.HandleFunc("GET /api/v1/users/{sub}/branches", adminGuard(h.ListUserBranches)) mux.HandleFunc("POST /api/v1/users/{sub}/branches", adminGuard(bodyBranchWall(h.GrantUserBranch))) mux.HandleFunc("DELETE /api/v1/users/{sub}/branches/{branch_id}", adminGuard(branchIdPathWall(h.RevokeUserBranch))) diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index c22071b9..3824719f 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -189,17 +189,6 @@ func (w *KeyBranchWall) branchOfLocation(ctx context.Context, id uuid.UUID) (*uu return branch, nil } -// RefuseBound wraps a handler a branch bound key may never reach: a route -// that administers the branch directory or acts across every branch and so -// cannot be confined to one. reason says why in the refusal message. -func (w *KeyBranchWall) RefuseBound(reason string) func(http.Handler) http.Handler { - return w.wrap("", reason, func(ctx context.Context, r *http.Request) (uuid.UUID, bool, error) { - // A branch is named that no pin can equal, so every bound key is - // refused whatever the request carries. - return uuid.Nil, true, nil - }) -} - // CustomerBranch wraps a handler whose request writes one or more customers' // data: the tax exemption writes and the customer priced rules. A branch // bound key may reach it only while every customer the request names holds diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go new file mode 100644 index 00000000..9e6b4e02 --- /dev/null +++ b/core/pkg/middleware/keybranch_allow.go @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware + +import ( + "net/http" + "sort" + "strings" + + "github.com/gablelbm/gable/pkg/branchctx" +) + +// The branch bound key's route allowlist (ADR 0007 section 5.5, deny by +// default). The opt in approach failed in kind: every route the sweep missed +// stayed open, and each review round found another. This table is the inverse: +// a branch bound machine key is refused every route it does not appear in, and +// every route a machine key can reach appears in it exactly once, with one of +// four classes: +// +// - WALLED: the route runs behind the branch middleware, which honours the +// pin the machine key core sets (lists, reads, drafts, the feed, links +// and the payload guard of ADR 0007 section 2.3); +// - CONFINED: the route mounts the key branch wall or the customer branch +// wall, which holds the request to the pin by the branch, customer or row +// it names (the note names the wall and the test that proves it); +// - STATED LIMIT: the route is reachable unconfined by design (the note +// carries its one line reason) and is listed in ADR 0007 section 5.5; +// - REFUSED: a bound key is answered 403 forbidden with the +// key.branch_refused audit row (the note carries the reason). +// +// The census test holds the table against core/api/ROUTES.txt in both +// directions, so a route added without a class decision, and a table entry +// whose route is gone, both fail CI. The one runtime check is the gate below +// (KeyBranchRouteGate), mounted once in serve's chain. + +// KeyBranchRouteClass is the class of one route in the allowlist. +type KeyBranchRouteClass int + +const ( + // KeyBranchClassWalled: the branch middleware holds the pin. + KeyBranchClassWalled KeyBranchRouteClass = iota + // KeyBranchClassConfined: the key branch wall or the customer branch wall + // holds the pin. + KeyBranchClassConfined + // KeyBranchClassStatedLimit: reachable unconfined by design, stated in + // ADR 0007 section 5.5. + KeyBranchClassStatedLimit + // KeyBranchClassRefused: a branch bound key is refused outright. + KeyBranchClassRefused +) + +// String names the class for tests, refusal messages and the census test's +// failure lines. +func (c KeyBranchRouteClass) String() string { + switch c { + case KeyBranchClassWalled: + return "walled" + case KeyBranchClassConfined: + return "confined" + case KeyBranchClassStatedLimit: + return "stated_limit" + case KeyBranchClassRefused: + return "refused" + } + return "unknown" +} + +// KeyBranchRouteNote pairs a class with its one line justification: a CONFINED +// entry names the wall and the test that proves it, a STATED LIMIT entry its +// reason, a REFUSED entry its reason, a WALLED entry its mount. +type KeyBranchRouteNote struct { + Class KeyBranchRouteClass + Note string +} + +// keyBranchRoutes is the allowlist itself: every route (method plus pattern, +// exactly as core/api/ROUTES.txt names them) a machine key can reach, with +// exactly one class. A route a machine key can reach that is not here, and a +// route here that no longer exists, fail the census test; at runtime either +// is refused, because the gate allows only what the table admits. +var keyBranchRoutes = map[string]KeyBranchRouteNote{ + "DELETE /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, + "DELETE /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, + "DELETE /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBranch(ids to rows customers); TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/tax/exemptions/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/users/{sub}/branches/{branch_id}": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, + "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, + "GET /api/v1/pricing/resolve": {KeyBranchClassConfined, "CustomerBranch(query customer_id, when named); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/quotes/{id}/exposure": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "GET /api/v1/tax/exemptions/{customerID}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "the handler answers only the pin branch; TestKeyBranchPin_LocationReads"}, + "POST /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBodyBranch, tier targets refused; TestKeyBranchPin_CategoryTierRefusals"}, + "POST /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBodyBranch per element; TestKeyBranchPin_CustomerConfinedWrites"}, + "POST /api/v1/pricing/rules": {KeyBranchClassConfined, "CustomerBranch(customer_id and job_id to projects.customer_id); TestKeyBranchPin_PricingRuleJobConfinement"}, + "POST /api/v1/quotes/{id}/exposure/acknowledge": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/escalate-now": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/override": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/request-ack": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/tax/exemptions": {KeyBranchClassConfined, "CustomerBodyBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "POST /api/v1/tax/preview": {KeyBranchClassConfined, "CustomerBranch(body customer, when named); TestKeyBranchPin_CustomerConfinedReads"}, + "POST /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "PUT /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, + "PUT /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, + "PUT /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "PUT /api/v1/users/{sub}/home-branch": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "DELETE /api/v1/admin/staff/{id}/modules/{module_id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "DELETE /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "DELETE /api/v1/reporting/schedules/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "GET /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "GET /api/v1/ap/aging": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/bankrecon/sessions/{id}": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/events": {KeyBranchClassRefused, "the feed carries every branch events"}, + "GET /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/balance-sheet": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/fiscal-periods": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/journal-entries/{id}": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/profit-and-loss": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/trial-balance": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/pricing/matrix": {KeyBranchClassRefused, "the unfiltered matrix cannot be confined to a branch"}, + "GET /api/v1/pricing/rules": {KeyBranchClassRefused, "the unfiltered rule list names every customer"}, + "GET /api/v1/quotes/exposure": {KeyBranchClassRefused, "the list returns every branch exposure"}, + "GET /api/v1/reporting/export/{entity}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/saved": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/ar-aging": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/customer-statement/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/daily-till": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/exposure": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/sales-summary": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/sales-team": {KeyBranchClassRefused, "the sales team roster spans every branch"}, + "GET /api/v1/sales-team/{id}": {KeyBranchClassRefused, "the sales team roster spans every branch"}, + "GET /api/v1/users": {KeyBranchClassRefused, "the known users list spans every branch"}, + "POST /api/v1/admin/exposure-scan": {KeyBranchClassRefused, "the scan re-checks every branch quotes"}, + "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/invoices/{id}/approve": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/import": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/match": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/sessions/{id}/complete": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/unmatch": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/branches": {KeyBranchClassRefused, "the branch directory create is outside every pin"}, + "POST /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/fiscal-periods/{id}/close": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/fiscal-periods/{id}/reopen": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/post": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/reverse": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/void": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/market-indices/{id}/refresh": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, + "POST /api/v1/market-indices/{id}/refresh/preview": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, + "POST /api/v1/reporting/builder/export": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/builder/preview": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/save": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/saved/{id}/run": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, + "PUT /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "PUT /api/v1/gl/accounts/{id}": {KeyBranchClassRefused, "the books span every branch"}, + "PUT /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "DELETE /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "DELETE /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "DELETE /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "DELETE /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "DELETE /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/admin/modules": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, + "GET /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "GET /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "GET /api/v1/apps": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "GET /api/v1/branches": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, + "GET /api/v1/branches/{id}": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, + "GET /api/v1/configurator/options": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/configurator/presets": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/configurator/rules": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/edi/partners/{id}/catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "GET /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "GET /api/v1/market-indices": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "GET /api/v1/market-indices/{id}/history": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "GET /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, + "GET /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "GET /api/v1/millwork/options/{id}": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "GET /api/v1/price_levels": {KeyBranchClassStatedLimit, "the dealer wide price level vocabulary"}, + "GET /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "GET /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/pricing/rebates/programs/{id}": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/pricing/rebates/programs/{id}/claims": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "GET /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "GET /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "GET /api/v1/vendors/{id}": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "POST /api/v1/apps/{key}/disable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "POST /api/v1/apps/{key}/enable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "POST /api/v1/configurator/build-sku": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "POST /api/v1/configurator/validate": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "POST /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/drivers/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/vehicles/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "POST /api/v1/edi/partners/{id}/import-catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "POST /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "POST /api/v1/governance/rfcs/{id}/transitions": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "POST /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "POST /api/v1/parsing/upload": {KeyBranchClassStatedLimit, "the upload parses dealer wide product data"}, + "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, + "POST /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "POST /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "POST /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "POST /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, + "PUT /api/v1/admin/modules/{id}": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, + "PUT /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "PUT /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "PUT /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "PUT /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "PUT /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "PUT /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "PUT /api/v1/market-indices/{id}": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "PUT /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, + "PUT /api/v1/pricing/categories/{id}": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "PUT /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/accounts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/aging": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/reconciliation": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/inventory-alerts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/order-activity": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/revenue-trend": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/top-customers": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/deliveries/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/deliveries/{id}/pod-photos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes/{id}/deliveries": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/documents/print/invoice/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/documents/print/pickticket/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders/feed": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes/feed": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/inventory": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices/{id}/payments": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/matching/exceptions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/matching/results/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/fulfillment-requests": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/catalog": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/products/search": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/detail": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/collateral": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/media": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/dimensions": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/lead-time": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/margins": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/pim/media/{mediaId}/primary": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/adjust-qty": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/pod-photo": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/optimize": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/reorder": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/inventory/adjust": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/inventory/transfer": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/email": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/fulfillment-requests/{delivery_id}/retry": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/fulfillments": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/card": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/image": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/seo": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/refresh-reorder-targets": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, +} + +// KeyBranchRouteClassFor returns the class the table gives a method and +// registered pattern. ok is false for a route the table does not carry: deny +// by default, the caller refuses. +func KeyBranchRouteClassFor(method, pattern string) (KeyBranchRouteClass, bool) { + note, ok := keyBranchRoutes[method+" "+pattern] + return note.Class, ok +} + +// KeyBranchRouteAllowed reports whether a branch bound machine key may pass +// to the route the method and registered pattern name: true only for a +// table entry of class WALLED, CONFINED or STATED LIMIT. A REFUSED entry, a +// route the table does not carry, and an empty pattern (the router matched +// nothing) are all refused. +func KeyBranchRouteAllowed(method, pattern string) bool { + class, ok := KeyBranchRouteClassFor(method, pattern) + return ok && class != KeyBranchClassRefused +} + +// KeyBranchAllowlistRoutes returns every table entry, sorted by route, for +// the census test and tooling. +func KeyBranchAllowlistRoutes() []string { + out := make([]string, 0, len(keyBranchRoutes)) + for route := range keyBranchRoutes { + out = append(out, route) + } + sort.Strings(out) + return out +} + +// KeyBranchAllowlistNoteFor returns an entry's one line justification. It is +// empty for a route the table does not carry; the class accessor reports +// that case. +func KeyBranchAllowlistNoteFor(method, pattern string) string { + return keyBranchRoutes[method+" "+pattern].Note +} + +// KeyBranchRouteReachable reports whether a machine key can reach a census +// route at all: under /api/v1, on a module the scope vocabulary allows, and +// not under a user only prefix (key management, the me routes). It is the +// census test's rule in one place, so the middleware table and the test can +// never disagree about which routes need a class. +func KeyBranchRouteReachable(method, pattern string) bool { + if !strings.HasPrefix(pattern, "/api/v1/") { + return false + } + module, ok := PolicyModuleForPath(method, pattern) + if !ok || ModuleScopePolicyFor(module) != ModuleScopeAllowed { + return false + } + path := pattern + if i := strings.IndexByte(path, ' '); i >= 0 { + path = path[i+1:] + } + for _, prefix := range MachineKeyUserOnlyPrefixes() { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return false + } + } + return true +} + +// patternResolver is the surface of *http.ServeMux the gate resolves a +// request's registered pattern through. Serving still goes through the mux +// itself (only the mux sets r.Pattern and the path values on the request it +// serves), so the gate resolves once for its decision and never serves the +// pre matched handler. +type patternResolver interface { + Handler(r *http.Request) (h http.Handler, pattern string) +} + +// KeyBranchRouteGate is the one runtime check: a middleware that refuses a +// branch bound machine key every route the allowlist does not admit, before +// the request reaches the confirm gate, the idempotency layer or any handler. +// A request with no pin (a user, an unbound key, another seam) passes +// untouched, so only a bound key ever pays the routing lookup. mux is the +// serve mux the patterns resolve against; auditor writes the +// key.branch_refused row and may be nil (the refusal is still served). +func KeyBranchRouteGate(mux http.Handler, auditor BranchRefusalAuditor) func(http.Handler) http.Handler { + g := &keyBranchRouteGate{mux: mux, auditor: auditor} + if resolver, ok := mux.(patternResolver); ok { + g.resolve = resolver + } + return g.wrap +} + +type keyBranchRouteGate struct { + mux http.Handler + resolve patternResolver + auditor BranchRefusalAuditor +} + +func (g *keyBranchRouteGate) wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pin, ok := branchctx.KeyBranchFromContext(r.Context()) + if !ok { + next.ServeHTTP(w, r) + return + } + // The resolver answers the registered pattern with its method prefix + // ("GET /api/v1/quotes/{id}"), and the method that matched, which a + // HEAD request reads from its GET pattern. An empty answer (no route, + // or only another method on the path) fails the cut and is refused. + method, pattern := "", "" + if g.resolve != nil { + _, resolved := g.resolve.Handler(r) + if m, rest, cut := strings.Cut(resolved, " "); cut { + method, pattern = m, rest + } + } + if KeyBranchRouteAllowed(method, pattern) { + next.ServeHTTP(w, r) + return + } + if keyID, ok := KeyIDFromContext(r.Context()); ok && g.auditor != nil { + g.auditor.AuditKeyBranchRefusal(r.Context(), keyID, pin, r.Method, r.URL.Path) + } + respondAuthError(w, r, http.StatusForbidden, "forbidden", + "a branch bound key may act only on its own branch: the route is not admitted to it") + }) +} diff --git a/core/pkg/middleware/keybranch_allow_census_test.go b/core/pkg/middleware/keybranch_allow_census_test.go new file mode 100644 index 00000000..67a5affe --- /dev/null +++ b/core/pkg/middleware/keybranch_allow_census_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware_test + +import ( + "strings" + "testing" + + "github.com/gablelbm/gable/pkg/middleware" +) + +// TestKeyBranchAllowlistCoversEveryReachableCensusRoute is the deny by +// default round's drift gate (ADR 0007 section 5.5): every route the census +// registers that a machine key can reach carries exactly one class in the +// allowlist, and every allowlist entry names a reachable census route. A route +// added without a class decision fails closed at runtime (the gate refuses +// it) and fails here, naming it; a stale entry fails here too, so the table +// cannot silently rot. A CONFINED entry must name the test that proves it and +// a STATED LIMIT entry must carry its one line reason, so no entry can ship +// with no justification. +func TestKeyBranchAllowlistCoversEveryReachableCensusRoute(t *testing.T) { + reachable := map[string]bool{} + for _, line := range readCensus(t) { + fields := strings.Split(line, "\t") + if len(fields) < 2 { + t.Fatalf("census line is not tab separated method/pattern/...: %q", line) + } + method, pattern := fields[0], fields[1] + if !middleware.KeyBranchRouteReachable(method, pattern) { + continue + } + key := method + " " + pattern + reachable[key] = true + class, ok := middleware.KeyBranchRouteClassFor(method, pattern) + if !ok { + t.Errorf("%s is a machine key reachable census route the allowlist names nowhere; give it a class decision (it is refused at runtime until then)", key) + continue + } + note := middleware.KeyBranchAllowlistNoteFor(method, pattern) + switch class { + case middleware.KeyBranchClassConfined: + if !strings.Contains(note, "Test") { + t.Errorf("%s is CONFINED but its note names no test; each confined entry names the test that proves it (note %q)", key, note) + } + case middleware.KeyBranchClassStatedLimit: + if strings.TrimSpace(note) == "" { + t.Errorf("%s is a stated limit with no reason; each stated limit carries its one line reason", key) + } + } + } + if len(reachable) == 0 { + t.Fatal("the census carries no machine key reachable routes; the test is not exercising anything") + } + + // Reverse direction: the table names only routes that exist and are + // reachable, so a moved or renamed route fails here, naming the stale + // entry. + for _, key := range middleware.KeyBranchAllowlistRoutes() { + if !reachable[key] { + t.Errorf("allowlist entry %q matches no machine key reachable census route; dead or renamed entry", key) + } + } + + // The classes are exhaustive on the table itself, whatever the census + // says, and every entry carries a note. + for _, key := range middleware.KeyBranchAllowlistRoutes() { + method, pattern, _ := strings.Cut(key, " ") + class, _ := middleware.KeyBranchRouteClassFor(method, pattern) + if class.String() == "unknown" { + t.Errorf("allowlist entry %q carries an unknown class", key) + } + } +} + +// TestKeyBranchRouteAllowedIsDenyByDefault pins the runtime rule the gate +// applies: the three admitted classes pass, REFUSED does not, and a route the +// table does not carry at all (added by a module, or a pattern the router +// never resolved) does not either. +func TestKeyBranchRouteAllowedIsDenyByDefault(t *testing.T) { + for _, key := range middleware.KeyBranchAllowlistRoutes() { + method, pattern, _ := strings.Cut(key, " ") + class, _ := middleware.KeyBranchRouteClassFor(method, pattern) + want := class != middleware.KeyBranchClassRefused + if got := middleware.KeyBranchRouteAllowed(method, pattern); got != want { + t.Errorf("KeyBranchRouteAllowed(%s) = %v, want %v (class %s)", key, got, want, class) + } + } + if middleware.KeyBranchRouteAllowed("GET", "/api/v1/quotes/zz-unclassified") { + t.Error("a route the table does not carry is allowed; deny by default requires the refusal") + } + if middleware.KeyBranchRouteAllowed("GET", "") { + t.Error("an empty pattern (the router matched nothing) is allowed; deny by default requires the refusal") + } +} diff --git a/core/pkg/middleware/keybranch_test.go b/core/pkg/middleware/keybranch_test.go index 075ccd00..1b21b2f2 100644 --- a/core/pkg/middleware/keybranch_test.go +++ b/core/pkg/middleware/keybranch_test.go @@ -95,10 +95,6 @@ func TestKeyBranchWall_NoPinPasses(t *testing.T) { return &foreign, nil }) }, nil}, - {"refuse bound", func(t *testing.T) func(http.Handler) http.Handler { - w, _ := pinWall(t) - return w.RefuseBound("the reason") - }, nil}, {"customer body", func(t *testing.T) func(http.Handler) http.Handler { w, _ := pinWall(t) return w.CustomerBodyBranch() @@ -251,24 +247,6 @@ func TestKeyBranchWall_RowBranch(t *testing.T) { } } -// TestKeyBranchWall_RefuseBound refuses a bound key outright whatever the -// request carries, and passes everyone else. -func TestKeyBranchWall_RefuseBound(t *testing.T) { - wall, auditor := pinWall(t) - mux := wall.RefuseBound("the reason") - pinned := uuid.New() - code, ran := wallPath(t, mux, &pinned, http.MethodPost, "/api/v1/branches", `{"code":"x"}`) - if code != http.StatusForbidden || ran { - t.Errorf("bound key: %d ran %v, want 403 and not run", code, ran) - } - if len(auditor.rows) != 1 { - t.Errorf("audit rows: %v, want one", auditor.rows) - } - if code, ran := wallPath(t, mux, nil, http.MethodPost, "/api/v1/branches", `{"code":"x"}`); code != http.StatusOK || !ran { - t.Errorf("no pin: %d ran %v, want 200 and run", code, ran) - } -} - // TestKeyBranchWall_CustomerBranch holds the customer named writes to the // pin: a body that is not exactly one complete JSON value is refused, and a // resolver failure or a nil database answers 500 with the handler never From 5332367582003f3f6e4f587cd1d742c2fce1136d Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 11:54:12 -0700 Subject: [PATCH 16/74] feat(serve): the customer wall completed: jobs, reads and tier targets The customer branch wall now resolves every field that names a customer scoped row, not only customer_id: - a pricing rule's job_id resolves to its projects row's customer, and a body naming a customer and a foreign job is refused (both are checked), closing round 4 P1: a bound key can no longer set the price a branch B customer pays on a branch B job with no audit row; - a rule that names neither a customer nor a job is a dealer wide write and is refused (round 4 P2), as is a category rule or bulk element that names no customer (a tier target prices every branch), through CustomerBodyBranchRequired, which refuses an object or element with no customer_id; - the customer scoped reads are confined like the writes (round 3 P1-2, round 4 P2): the exemption read by its path customer, the category rule list by its query customer (absent is the unfiltered list and is refused), the rule audit by its row's customer, calculate and resolve by their query customer, and the tax preview by its body customer when it names one. A category row that targets a tier names no customer and is refused on every verb, and a bulk delete naming a tier row is refused whole. The walls dispatch on r.Pattern, the registered pattern the mux sets, not on path suffixes or prefixes. Signed-off-by: colton --- core/internal/app/serve/serve.go | 2 +- .../app/serve/wire_customer_branch.go | 257 ++++++++++++++---- .../app/serve/wire_key_branch_allow_test.go | 31 ++- .../app/serve/wire_key_branch_pin_test.go | 2 +- core/pkg/middleware/keybranch.go | 42 ++- 5 files changed, 262 insertions(+), 72 deletions(-) diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index a2e2f7a1..8972ddc7 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -377,7 +377,7 @@ func Run() { WithTxRunner(db). WithAudit(pricingAuditAdapter{l: auditLog}) pricingHandler := pricing.NewHandler(pricingSvc, customerSvc, productSvc) - pricingHandler.RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, "admin", "owner")) + pricingHandler.RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 65c58f2d..3bad196d 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -3,18 +3,24 @@ package serve -// The customer branch wall on the routes whose request writes a named -// customer's data (ADR 0007 section 5.5, the lead's ruling on the sweep): the -// tax exemption writes and the customer priced rules, plain and category, -// single and bulk. A branch bound key reaches them only while the customer -// its request names, or that the row it acts on belongs to, holds the pin -// among that customer's branches; the reads keep the role guard alone. +// The customer branch wall on the routes whose request reads or writes a +// named customer's data (ADR 0007 section 5.5): the tax exemption routes and +// preview, the customer priced rules (plain and category, single and bulk) +// and the customer scoped reads that name a customer in a path, a query or a +// body. A branch bound key reaches them only while every customer the +// request names, or that the rows it acts on belong to, holds the pin among +// that customer's branches. A request that names no customer passes, except +// where the write would then be dealer wide: a pricing rule that names +// neither a customer nor a job, and a category rule (or a bulk element) that +// names no customer, price every branch's customers, so the resolver refuses +// them (ErrBodyRefused). The routes no customer can confine (the unfiltered +// lists, the matrix) are REFUSED by the route allowlist gate before these +// walls run. import ( "context" "errors" "net/http" - "strings" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -43,16 +49,130 @@ func exemptionCustomerOf(db *database.DB) func(ctx context.Context, id uuid.UUID } } -// categoryRuleCustomerOf resolves the customer a category pricing rule row is -// scoped to. A row that does not exist, or that targets a tier or an account -// rather than a customer, names no customer. -func categoryRuleCustomerOf(db *database.DB) func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { - return func(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { +// exemptionCustomerPathOf resolves the customer the exemption read's path +// names (the {customerID} value): the customer named is the customer read, +// whoever's rows they are, so it is confined exactly like a write that names +// one. +func exemptionCustomerPathOf() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + raw := r.PathValue("customerID") + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, nil // the handler answers the malformed id + } + return []uuid.UUID{id}, nil + } +} + +// previewCustomerOf resolves the customer the tax preview's body names. A +// preview that names no customer is a plain rate calculation and passes; one +// that names a customer reads that customer's exemptions and is confined. +func previewCustomerOf() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + var body struct { + CustomerID string `json:"customer_id"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + if id, err := uuid.Parse(body.CustomerID); err == nil { + return []uuid.UUID{id}, nil + } + return nil, nil + } +} + +// queryCustomerOf resolves the customer a query parameter names. A value that +// does not parse names nothing the wall can hold and passes to the handler's +// own validation; an absent value passes unless required says otherwise (the +// category rule list with no customer is the dealer wide list). +func queryCustomerOf(name string, required bool) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + vals := r.URL.Query()[name] + if len(vals) == 0 { + if required { + return nil, middleware.ErrBodyRefused + } + return nil, nil + } + if id, err := uuid.Parse(vals[0]); err == nil { + return []uuid.UUID{id}, nil + } + return nil, nil + } +} + +// pricingRuleCustomersOf resolves the customers a pricing rule create names: +// its body customer_id, and the customer of the job its job_id names (a +// projects row belongs to exactly one customer, and a rule on the job prices +// that customer whoever else it names, so both are checked). A body that +// names neither is a dealer wide write and is refused (ErrBodyRefused). +func pricingRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("pricing rule customer lookup: no database") + } + var body struct { + CustomerID string `json:"customer_id"` + JobID string `json:"job_id"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + var out []uuid.UUID + if id, err := uuid.Parse(body.CustomerID); err == nil { + out = append(out, id) + } + if raw := body.JobID; raw != "" { + if job, err := uuid.Parse(raw); err == nil { + var customer *uuid.UUID + err := db.GetExecutor(r.Context()).QueryRow(r.Context(), + `SELECT customer_id FROM projects WHERE id = $1`, job).Scan(&customer) + if errors.Is(err, pgx.ErrNoRows) { + // A job that does not exist names nothing; the handler's + // own foreign key answer covers it. + if len(out) == 0 { + return nil, middleware.ErrBodyRefused + } + return out, nil + } + if err != nil { + return nil, err + } + if customer != nil { + out = append(out, *customer) + } + } + } + if len(out) == 0 { + return nil, middleware.ErrBodyRefused + } + return out, nil + } +} + +// categoryRuleRowCustomerOf resolves the customer a category rule row is +// scoped to. A row that does not exist names nothing (the handler's own 404 +// answers); a row that targets a tier or the whole account names no customer, +// is dealer wide, and is refused. +func categoryRuleRowCustomerOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { if db == nil { return nil, errors.New("category rule customer lookup: no database") } + raw := r.PathValue("id") + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, nil // the handler answers the malformed id + } var customer *uuid.UUID - err := db.GetExecutor(ctx).QueryRow(ctx, + err = db.GetExecutor(r.Context()).QueryRow(r.Context(), `SELECT customer_id FROM category_pricing_rules WHERE id = $1`, id).Scan(&customer) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -60,16 +180,22 @@ func categoryRuleCustomerOf(db *database.DB) func(ctx context.Context, id uuid.U if err != nil { return nil, err } - return customer, nil + if customer == nil { + return nil, middleware.ErrBodyRefused + } + return []uuid.UUID{*customer}, nil } } // bulkRuleCustomersOf resolves the customers a bulk delete's rule ids belong -// to. A body that is not exactly one complete JSON value is refused for a -// bound key (ErrBodyRefused), and an id that does not parse is left to the -// handler's own 400. +// to. An id that does not parse is left to the handler's own 400; an id that +// names a tier or account row (no customer) is a dealer wide delete and +// refuses the whole request. func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("category rule customer lookup: no database") + } var body struct { IDs []string `json:"ids"` } @@ -85,7 +211,16 @@ func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Requ if len(ids) == 0 { return nil, nil } - rows, err := db.GetExecutor(ctx).Query(ctx, + var dealerWide int + if err := db.GetExecutor(r.Context()).QueryRow(r.Context(), + `SELECT COUNT(*) FROM category_pricing_rules WHERE id = ANY($1) AND customer_id IS NULL`, ids). + Scan(&dealerWide); err != nil { + return nil, err + } + if dealerWide > 0 { + return nil, middleware.ErrBodyRefused + } + rows, err := db.GetExecutor(r.Context()).Query(r.Context(), `SELECT DISTINCT customer_id FROM category_pricing_rules WHERE id = ANY($1) AND customer_id IS NOT NULL`, ids) if err != nil { return nil, err @@ -103,22 +238,29 @@ func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Requ } } -// taxCustomerWall composes the tax role guard with the customer branch wall -// on the exemption writes: the create's body names the customer, the delete's -// row belongs to one. The preview and the exemption read keep the role guard -// alone. +// taxCustomerWall composes the tax role guard with the customer branch wall: +// the exemption create's body names the customer, the delete's row belongs +// to one, the read's path names one, and the preview's body names one when +// it names any. func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) create := middleware.Compose(role, keyWall.CustomerBodyBranch()) remove := middleware.Compose(role, keyWall.CustomerRowBranch(exemptionCustomerOf(db))) + read := middleware.Compose(role, keyWall.CustomerBranch(exemptionCustomerPathOf())) + preview := middleware.Compose(role, keyWall.CustomerBranch(previewCustomerOf())) return func(next http.Handler) http.Handler { - createHandler, removeHandler, roleHandler := create(next), remove(next), role(next) + createHandler, removeHandler, readHandler, previewHandler, roleHandler := + create(next), remove(next), read(next), preview(next), role(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/tax/exemptions": + switch r.Pattern { + case "POST /api/v1/tax/exemptions": createHandler.ServeHTTP(w, r) - case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/api/v1/tax/exemptions/"): + case "DELETE /api/v1/tax/exemptions/{id}": removeHandler.ServeHTTP(w, r) + case "GET /api/v1/tax/exemptions/{customerID}": + readHandler.ServeHTTP(w, r) + case "POST /api/v1/tax/preview": + previewHandler.ServeHTTP(w, r) default: roleHandler.ServeHTTP(w, r) } @@ -127,47 +269,60 @@ func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles . } // pricingRulesCustomerWall composes the pricing role guard with the customer -// branch wall on the rule create (its body names the customer). The reads and -// the calculation keep the role guard alone; a rule that names no customer is -// dealer wide and passes. -func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, roles ...string) func(http.Handler) http.Handler { +// branch wall on the rule create (its body names the customer, its job names +// one, and a body naming neither is dealer wide and refused) and on the +// calculation (its query names the customer). The unfiltered rule list is +// REFUSED by the route allowlist gate and never reaches these arms. +func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - create := middleware.Compose(role, keyWall.CustomerBodyBranch()) + create := middleware.Compose(role, keyWall.CustomerBranch(pricingRuleCustomersOf(db))) + calculate := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", false))) return func(next http.Handler) http.Handler { - createHandler, roleHandler := create(next), role(next) + createHandler, calculateHandler, roleHandler := create(next), calculate(next), role(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/rules" { + switch r.Pattern { + case "POST /api/v1/pricing/rules": createHandler.ServeHTTP(w, r) - return + case "GET /api/v1/pricing/calculate": + calculateHandler.ServeHTTP(w, r) + default: + roleHandler.ServeHTTP(w, r) } - roleHandler.ServeHTTP(w, r) }) } } // categoryRulesCustomerWall composes the category pricing role guard with the -// customer branch wall on the rule writes: the create's body names the -// customer, the update and the delete act on a row that belongs to one, the -// bulk upsert names one per element, and the bulk delete's ids belong to -// theirs. The reads keep the role guard alone. +// customer branch wall: the list's query names the customer (the unfiltered +// list is refused by the gate, so the parameter is required here), the create +// and the bulk upsert name one per element (a tier target names none and is +// refused), the update, the delete and the audit act on a row that belongs +// to one, the bulk delete's ids belong to theirs, and the resolve preview +// names a customer when its query carries one. The matrix is REFUSED by the +// route allowlist gate; the categories routes are stated limits. func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - wall := middleware.Compose(role, keyWall.CustomerBodyBranch()) - byRow := middleware.Compose(role, keyWall.CustomerRowBranch(categoryRuleCustomerOf(db))) + body := middleware.Compose(role, keyWall.CustomerBodyBranchRequired()) + byRow := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleRowCustomerOf(db))) byBulkIDs := middleware.Compose(role, keyWall.CustomerBranch(bulkRuleCustomersOf(db))) + list := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", true))) + resolve := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", false))) return func(next http.Handler) http.Handler { - wallHandler, rowHandler, bulkHandler, roleHandler := wall(next), byRow(next), byBulkIDs(next), role(next) + bodyHandler, rowHandler, bulkHandler, listHandler, resolveHandler, roleHandler := + body(next), byRow(next), byBulkIDs(next), list(next), resolve(next), role(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/category-rules/bulk": - wallHandler.ServeHTTP(w, r) // each element names its customer - case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/pricing/category-rules/bulk": - bulkHandler.ServeHTTP(w, r) // the rows the ids name belong to theirs - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/pricing/category-rules": - wallHandler.ServeHTTP(w, r) - case (r.Method == http.MethodPut || r.Method == http.MethodDelete) && - strings.HasPrefix(r.URL.Path, "/api/v1/pricing/category-rules/"): + switch r.Pattern { + case "GET /api/v1/pricing/category-rules": + listHandler.ServeHTTP(w, r) + case "POST /api/v1/pricing/category-rules", "POST /api/v1/pricing/category-rules/bulk": + bodyHandler.ServeHTTP(w, r) + case "DELETE /api/v1/pricing/category-rules/bulk": + bulkHandler.ServeHTTP(w, r) + case "PUT /api/v1/pricing/category-rules/{id}", "DELETE /api/v1/pricing/category-rules/{id}", + "GET /api/v1/pricing/category-rules/{id}/audit": rowHandler.ServeHTTP(w, r) + case "GET /api/v1/pricing/resolve": + resolveHandler.ServeHTTP(w, r) default: roleHandler.ServeHTTP(w, r) } diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index e6883730..f55c0f41 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -78,9 +78,12 @@ func TestKeyBranchPin_CustomerConfinedReads(t *testing.T) { f.refuseForeign(t, "GET", calc+B+"&quantity=1", "", nil) f.allow(t, "bound", "GET", calc+A+"&quantity=1", "", nil, http.StatusNotFound) f.parity(t, "GET", calc+B+"&quantity=1", "") + // The resolve preview answers a bare product with no match rather than a + // 404; what matters is that the pin's customer reaches that answer and + // the foreign one never reaches the module. resolve := "/api/v1/pricing/resolve?product_id=" + uuid.NewString() + "&customer_id=" f.refuseForeign(t, "GET", resolve+B, "", nil) - f.allow(t, "bound", "GET", resolve+A, "", nil, http.StatusNotFound) + f.allow(t, "bound", "GET", resolve+A, "", nil, http.StatusOK) f.parity(t, "GET", resolve+B, "") } @@ -125,19 +128,22 @@ func TestKeyBranchPin_PricingRuleJobConfinement(t *testing.T) { } // A rule that names neither a customer nor a job is dealer wide: refused - // for the bound key with its audit row, open to everyone else. - wide := `{"name":"pin wide","rule_type":"promotional","discount_pct":"50"}` - f.refuseForeign(t, "POST", "/api/v1/pricing/rules", wide, nil) + // for the bound key with its audit row, open to everyone else (each on + // its own name: a rule's name and scope are unique). + wide := func(label string) string { + return fmt.Sprintf(`{"name":"pin wide %s","rule_type":"promotional","discount_pct":"50"}`, label) + } + f.refuseForeign(t, "POST", "/api/v1/pricing/rules", wide("refused"), nil) var wideRows int if err := f.db.Pool.QueryRow(context.Background(), - `SELECT COUNT(*) FROM pricing_rules WHERE name = 'pin wide'`).Scan(&wideRows); err != nil { + `SELECT COUNT(*) FROM pricing_rules WHERE name LIKE 'pin wide %'`).Scan(&wideRows); err != nil { t.Fatal(err) } if wideRows != 0 { t.Errorf("refused dealer wide rule wrote %d rows", wideRows) } for _, who := range []string{"unbound", "user"} { - f.allow(t, who, "POST", "/api/v1/pricing/rules", wide, nil, http.StatusCreated) + f.allow(t, who, "POST", "/api/v1/pricing/rules", wide(who), nil, http.StatusCreated) } } @@ -148,11 +154,14 @@ func TestKeyBranchPin_PricingRuleJobConfinement(t *testing.T) { func TestKeyBranchPin_CategoryTierRefusals(t *testing.T) { f := newKeyPinFixture(t) // PIN-TIER is not any tier the migrations seed, so the rows this test - // creates are exactly its own and the cleanup can name them. - tier := fmt.Sprintf(`{"target_type":"tier","tier":"PIN-TIER","category_id":%q,"rule_type":"markup","value_pct":"5"}`, - f.freshCategory(t).String()) + // creates are exactly its own and the cleanup can name them (each on its + // own category: a target and category pair is unique). + tier := func() string { + return fmt.Sprintf(`{"target_type":"tier","tier":"PIN-TIER","category_id":%q,"rule_type":"markup","value_pct":"5"}`, + f.freshCategory(t).String()) + } - f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", tier, nil) + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", tier(), nil) var tierRows int if err := f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM category_pricing_rules WHERE tier = 'PIN-TIER'`).Scan(&tierRows); err != nil { @@ -162,7 +171,7 @@ func TestKeyBranchPin_CategoryTierRefusals(t *testing.T) { t.Errorf("refused tier rule create wrote %d rows", tierRows) } for _, who := range []string{"unbound", "user"} { - f.allow(t, who, "POST", "/api/v1/pricing/category-rules", tier, nil, http.StatusCreated) + f.allow(t, who, "POST", "/api/v1/pricing/category-rules", tier(), nil, http.StatusCreated) } putBody := `{"rule_type":"markdown","value_pct":"2"}` diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index c1ac0a17..34720034 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -273,7 +273,7 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { tax.NewHandler(orderwire.NewTaxService(db, &config.Config{}, slog.Default())). RegisterRoutes(mux, taxCustomerWall(wall.keyWall, db, "admin", "owner", "finance")) pricing.NewHandler(pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), customerSvc, productSvc). - RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, "admin", "owner")) + RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) pricing.NewCategoryHandler( pricing.NewCategoryPricingService(pricing.NewCategoryRepository(db)).WithTxRunner(db). WithOutbox(outbox.NewWriter(db, "")). diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index 3824719f..bdff24dd 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -226,7 +226,17 @@ func (w *KeyBranchWall) CustomerBranch(customersOf func(ctx context.Context, r * // decoded whole exactly as the handler's own decoder would have to. func (w *KeyBranchWall) CustomerBodyBranch() func(http.Handler) http.Handler { return w.CustomerBranch(func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { - return bodyCustomers(r) + return bodyCustomers(r, false) + }) +} + +// CustomerBodyBranchRequired is CustomerBodyBranch over a body whose every +// element must name a customer: a write that names none (a tier or account +// target, a dealer wide rule) is refused for a bound key rather than passed, +// because what it writes prices or deletes every branch's rows. +func (w *KeyBranchWall) CustomerBodyBranchRequired() func(http.Handler) http.Handler { + return w.CustomerBranch(func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return bodyCustomers(r, true) }) } @@ -257,18 +267,23 @@ func (w *KeyBranchWall) CustomerRowBranch(customerOf func(ctx context.Context, i // bodyCustomers reads the customer_ids a body names, one object or an array // of them. An id that does not parse names nothing the wall can hold, and -// the handler's own validation answers it. -func bodyCustomers(r *http.Request) ([]uuid.UUID, error) { +// the handler's own validation answers it. When required is set, an object +// or an array element that names no customer at all is refused +// (errRefused): the write it asks for is dealer wide. +func bodyCustomers(r *http.Request, required bool) ([]uuid.UUID, error) { raw, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("key branch wall: read body: %w", err) } r.Body = io.NopCloser(bytes.NewReader(raw)) - collect := func(id string, out []uuid.UUID) []uuid.UUID { + collect := func(id string, out []uuid.UUID) ([]uuid.UUID, error) { if parsed, err := uuid.Parse(id); err == nil { - out = append(out, parsed) + return append(out, parsed), nil } - return out + if required && id != "" { + return nil, errRefused + } + return out, nil } if trimmed := bytes.TrimSpace(raw); len(trimmed) > 0 && trimmed[0] == '[' { var many []struct { @@ -277,9 +292,17 @@ func bodyCustomers(r *http.Request) ([]uuid.UUID, error) { if err := json.Unmarshal(raw, &many); err != nil { return nil, errRefused } + if required && len(many) == 0 { + return nil, errRefused + } out := make([]uuid.UUID, 0, len(many)) for _, m := range many { - out = collect(m.CustomerID, out) + if out, err = collect(m.CustomerID, out); err != nil { + return nil, err + } + } + if required && len(out) != len(many) { + return nil, errRefused } return out, nil } @@ -290,9 +313,12 @@ func bodyCustomers(r *http.Request) ([]uuid.UUID, error) { return nil, errRefused } if one.CustomerID == "" { + if required { + return nil, errRefused + } return nil, nil } - return collect(one.CustomerID, nil), nil + return collect(one.CustomerID, nil) } // customersHoldPin answers whether every customer's branch set From f7a04ced0c000a005853781750099df49b88926c Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 11:56:02 -0700 Subject: [PATCH 17/74] test: the wall's fail closed arms and serve's confinement call sites Round 3 P3-1, both arms now pinned: the location row lookup's SQL error arm fails closed (a cancelled query answers 500 with the handler never reached; the mutant that swallows the error into no branch named makes the test fail), and a bulk category delete body that is not exactly one complete JSON value is refused for a bound key (the decode error mutant makes the test fail). Both mutants were green on the previous head. Round 3 P2-2, the source half: the behaviour tests drive the fixture's mounts, so Run() dropping a confinement call would leave them green while production lost its wall. TestServeWiringMountsTheConfinements pins serve.go's five confinement call sites and chain.go's gate mount; the behaviour half (the fixture builds its chain with buildChain, and TestKeyBranchPin_AllowlistGateCoversCensus drives the gate over every reachable census route) makes a dropped gate fail red on its own. Signed-off-by: colton --- .../app/serve/wire_confinement_source_test.go | 46 ++++++++++++++++ .../app/serve/wire_key_branch_pin_test.go | 12 +++++ core/pkg/middleware/keybranch_db_test.go | 52 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 core/internal/app/serve/wire_confinement_source_test.go create mode 100644 core/pkg/middleware/keybranch_db_test.go diff --git a/core/internal/app/serve/wire_confinement_source_test.go b/core/internal/app/serve/wire_confinement_source_test.go new file mode 100644 index 00000000..8594dbb5 --- /dev/null +++ b/core/internal/app/serve/wire_confinement_source_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "os" + "strings" + "testing" +) + +// TestServeWiringMountsTheConfinements is the source level half of the round +// 3 P2-2 fix: the behaviour tests drive the fixture's own mounts, so a +// confinement dropped from Run() itself would leave them green while +// production lost its wall. The deny by default gate already refuses an +// unclassified route whatever the mount does, but a CONFINED route that lost +// its wall would pass the gate and reach the handler unconfined, so Run()'s +// confinement call sites are pinned here: this test fails when serve.go or +// chain.go stops calling one of them. +func TestServeWiringMountsTheConfinements(t *testing.T) { + serveSrc, err := os.ReadFile("serve.go") + if err != nil { + t.Fatalf("read serve.go: %v", err) + } + chainSrc, err := os.ReadFile("chain.go") + if err != nil { + t.Fatalf("read chain.go: %v", err) + } + exposureSrc, err := os.ReadFile("wire_exposure.go") + if err != nil { + t.Fatalf("read wire_exposure.go: %v", err) + } + for _, pin := range []struct{ file, call, what string }{ + {string(serveSrc), "wall.locations(mux, locationHandler)", "the location routes' key branch wall (WithKeyBranchWall)"}, + {string(serveSrc), "wireExposure(exposureDeps{", "the exposure wiring"}, + {string(exposureSrc), "registerExposureRoutes(deps.Mux", "the exposure by id quote confinement"}, + {string(serveSrc), "taxCustomerWall(wall.keyWall", "the tax exemption and preview confinement"}, + {string(serveSrc), "pricingRulesCustomerWall(wall.keyWall", "the pricing rule create and calculate confinement"}, + {string(serveSrc), "categoryRulesCustomerWall(wall.keyWall", "the category rule confinement"}, + {string(chainSrc), "middleware.KeyBranchRouteGate(d.Mux", "the route allowlist gate in the chain"}, + } { + if !strings.Contains(pin.file, pin.call) { + t.Errorf("serve's wiring no longer calls %s (%s); the confinement it mounts is gone from production", pin.call, pin.what) + } + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 34720034..c566eab1 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -1037,6 +1037,18 @@ func TestKeyBranchPin_CustomerConfinedWrites(t *testing.T) { if _, n := f.rulesOf(t, f.custB); n != beforeBulkDel { t.Errorf("refused bulk delete changed the foreign customer's rows: %d then %d", beforeBulkDel, n) } + // A bulk delete body that is not exactly one complete JSON value never + // reaches the handler for a bound key: the wall reads the whole body, and + // the handler's own stricter decoder would have answered it 400 with the + // delete never attempted. + for _, body := range []string{ + fmt.Sprintf(`{"ids":[%q]} {}`, f.catRuleB.String()), + fmt.Sprintf(`{"ids":[%q}] trailing`, f.catRuleB.String()), + `[{"ids":[]}]`, + ``, + } { + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/bulk", body, nil) + } for _, who := range []string{"unbound", "user"} { id := ruleFor(f.custB) f.allow(t, who, "DELETE", "/api/v1/pricing/category-rules/bulk", diff --git a/core/pkg/middleware/keybranch_db_test.go b/core/pkg/middleware/keybranch_db_test.go new file mode 100644 index 00000000..e39e6e3a --- /dev/null +++ b/core/pkg/middleware/keybranch_db_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware + +// The key branch wall's database backed unit tests: the location row lookup's +// SQL error arm must fail closed (500, the handler never reached), not fail +// open by swallowing the query error into "no branch named". + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// TestKeyBranchWall_LocationRowQueryErrorFailsClosed drives the location row +// lookup with a context the database refuses (cancelled before the query +// runs): the wall answers 500 and the handler never runs. A lookup that +// swallowed its query error into "no branch named" would pass the request +// through, and this test fails. +func TestKeyBranchWall_LocationRowQueryErrorFailsClosed(t *testing.T) { + db := testutil.RequireDB(t) + w := NewKeyBranchWall(db, nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + pin := uuid.New() + ctx = branchctx.WithKeyBranch(ctx, pin) + + ran := false + handler := w.LocationRowBranch()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ran = true + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPut, "/api/v1/locations/"+uuid.NewString(), nil) + req = req.WithContext(ctx) + req.SetPathValue("id", uuid.NewString()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("cancelled query: %d, want 500 (fail closed)", rec.Code) + } + if ran { + t.Error("the handler ran under a failed branch lookup; the wall failed open") + } +} From cfc268a505d98a0b45fe6d06cf088e2683e0af5a Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 11:57:54 -0700 Subject: [PATCH 18/74] docs: deny by default, the allowlist record ADR 0007 section 5.5 now states the deny by default rule itself: the four classes, the one gate, the census test, the completed confinement contract (jobs resolve to their projects row customer, the customer scoped reads are confined, dealer wide writes are refused), the stated limits each with their reason, the do not mint advice round 3 P3-2 asked for, and the refused surfaces (the GL, AP and bank reconciliation writes included). The SEC-BOUND-KEY-PIN row, the drafts module page and the locations module page say the same shape. Signed-off-by: colton --- ...7-drafts-links-and-confirm-gated-scopes.md | 81 ++++++++++++++----- docs/modules/drafts.md | 71 ++++++++++------ docs/modules/locations.md | 20 +++-- docs/refactor/CONTRACT-CHANGES.md | 2 +- 4 files changed, 120 insertions(+), 54 deletions(-) diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index e0f3998f..9d181584 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -684,26 +684,67 @@ null branch is today's behaviour. A bound key: `payload.branch_id`, by the branch rule of section 2.3, on draft routes and on the module's own create alike (PR 37's guard). -A route that mounts no branch middleware is held to the pin the same way by -the key branch wall (`pkg/middleware/keybranch.go`): a branch a request -names in a path or body, or the branch of a row a path names, is compared to -the pin and another branch is the same 403 with the same `key.branch_refused` -row. A body is read whole: a body that is not exactly one complete JSON -value (trailing data after the first value, or unparsable) is refused, so -the wall and the handler can never disagree about what a body names. Routes -that act across every branch refuse a bound key outright, being outside -every pin: the branch directory's create, the exposure scan and the two -exposure lists, the market index refresh (it re-checks every branch's -quotes), the events feed, every reporting route, the dealer wide GL, AP and -bank reconciliation reads, the sales team reads and the known users list. -Routes that write a named customer's data are confined to the pin through -that customer's `customer_branches`: the tax exemption writes and the -customer priced rules (plain and category, single and bulk) refuse a bound -key any customer whose branch set does not hold the pin. The branch -directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay -open to a bound key: they are reference data by the PR 39 decision (name, -code, address and tax rate, no dealer transactions). Users and unbound keys -keep exactly their reach on every route. +A route that mounts no branch middleware is denied by default, not left to a +per route wall: one allowlist, `pkg/middleware/keybranch_allow.go`, +classifies every route a machine key can reach (method plus pattern, exactly +as the route census `api/ROUTES.txt` names them) as exactly one of four +classes. WALLED: the route runs behind the branch middleware, which honours +the pin. CONFINED: the route mounts the key branch wall or the customer +branch wall, which holds the request to the pin by the branch, customer, job +or row it names. STATED LIMIT: the route is reachable unconfined by design, +each entry with its one line reason. REFUSED: a bound key is answered 403 +`forbidden` with the `key.branch_refused` row. The one runtime check is +`KeyBranchRouteGate`, mounted once in serve's chain inside auth and outside +the confirm gate: it resolves the request's registered pattern against the +serve mux and refuses a bound key every route the table does not admit, +including a route the router resolves to no pattern, so a route a sweep +missed, a route added without a class decision and a method no route +registers all fail closed instead of open. A census test fails when a +machine key reachable census route is unclassified or a table entry is +stale, so a new route cannot appear without a class decision. Users and +unbound keys keep exactly their reach on every route. + +The confinement contract the CONFINED routes hold: a body is read whole (a +body that is not exactly one complete JSON value is refused, so the wall and +the handler can never disagree about what a body names); a location row that +names no branch is outside every pin and refused; a request that names +customers is confined through their `customer_branches` (the tax exemption +routes and the preview, the customer priced rules plain and category single +and bulk, and the reads that name a customer in a path or query: the +exemption read by its path customer, the category rule list by its +`customer_id`, the rule audit by its row's customer, and resolve and +calculate by their `customer_id`). A pricing rule names a customer through +its `customer_id` or its `job_id` (a projects row belongs to exactly one +customer, and a rule on the job prices that customer), and both are checked. +A pricing rule that names neither a customer nor a job, and a category rule +or bulk element that targets a tier, price every branch's customers and are +refused. The unfiltered lists (`GET /pricing/rules`, the category rule list +with no `customer_id`, `GET /pricing/matrix`) cannot be confined to one +branch and are REFUSED. + +The stated limits, each with its one line reason in the table: the branch +directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`, +reference data by the PR 39 decision); the dealer wide reference data that +carries no branch fact (the unit catalogue, vendors, EDI partners and their +catalogs, millwork and configurator options, governance rfcs, the vision +scan, the parsing upload, the product pricing categories, the rebate +programs and their claims, the price level vocabulary, the market index +metadata list edit and history, the price escalation calculator, the match +tolerances); the delivery fleet (drivers and vehicles carry no branch +column); and the dealer wide switches (admin settings, the admin module kill +switches, app enablement). An operator should not mint on a bound key the +scopes that buy only these limits: `admin:settings`, `admin:modules`, +`apps:write`, `units:write`, `vendors:write`, `configurator:write`, +`millwork:write`, `vision:write`, `edi:write`, `governance:write` and +`parsing:write`; `market-indices:write` and `pricing:write` reach stated +limit writes (the index metadata; the categories and rebates) beside their +confined uses, and `delivery:write` reaches the fleet, so mint them on a +bound key only for those confined uses. REFUSED outright, whatever scope a +key holds: the GL, AP and bank reconciliation surfaces (reads and writes +alike), every report and builder route, the events feed, the sales team +roster, the staff roster and its module grants, the known users list, the +exposure lists and the scan, the market index refresh and its preview, the +branch directory create, and the unfiltered pricing lists above. One branch per key, not a set: the repositories' branch idiom filters on one branch or none, and a set would need a second idiom in every module. A diff --git a/docs/modules/drafts.md b/docs/modules/drafts.md index e502d213..1800318e 100644 --- a/docs/modules/drafts.md +++ b/docs/modules/drafts.md @@ -114,33 +114,54 @@ is read (`pkg/middleware/machinekey.go`, the `principal.BranchID != nil` branch in `handle`), and the routes that mount no branch middleware (the user-only prefixes `/api/v1/admin/keys` and `/api/v1/me`) are JWT user routes, so a machine key cannot -reach them, bound or not. The routes that mount no branch middleware are held to the -pin by the key branch wall (`core/pkg/middleware/keybranch.go`): -a bound key naming another branch in a body `branch_id` or a -path id, or a row of another branch (`PUT` and `DELETE -/api/v1/locations/{id}`), is a 403 `forbidden` in the wire -envelope naming the field, audited as `key.branch_refused` -like the header rule; its own branch passes, and a body is -read whole, so a body that is not exactly one complete JSON -value (trailing data after the first value, or unparsable) is +reach them, bound or not. A bound key is denied by default on the routes that mount no +branch middleware: one allowlist +(`core/pkg/middleware/keybranch_allow.go`) classifies every +route a machine key can reach, method plus pattern as the +route census names them, as WALLED (behind the branch +middleware), CONFINED (held by the key branch wall of +`core/pkg/middleware/keybranch.go` or the customer branch +wall), a STATED LIMIT (reachable unconfined by design, with +its reason) or REFUSED, and the one runtime check, +`KeyBranchRouteGate` in serve's chain inside auth, refuses a +bound key every route the table does not admit (a route the +router resolves to no pattern included), with the 403 +`forbidden` of the wire envelope and the `key.branch_refused` +row the header rule writes. A census test fails when a +machine key reachable census route is unclassified, so a new +route cannot appear without a class decision. + +On the CONFINED routes a bound key naming another branch in a +body `branch_id` or a path id, or a row of another branch +(`PUT` and `DELETE /api/v1/locations/{id}`), is that same 403 +naming the field; its own branch passes, and a body is read +whole, so a body that is not exactly one complete JSON value +(trailing data after the first value, or unparsable) is refused rather than passed for the handler to read only in -part. The directory -create (`POST /api/v1/branches`) refuses a bound key outright, -and `PUT` and `DELETE /api/v1/branches/{id}` refuse it another -branch. A bound key holding `users:grants` grants, revokes and -moves home only its own branch. The routes that act across -every branch refuse a bound key outright: the exposure scan -and the two exposure lists, the market index refresh, the -events feed (`GET /api/v1/events`), every reporting route, -the dealer wide GL, AP and bank reconciliation reads, the -sales team reads and the known users list (`GET /api/v1/users`). -The routes that write a named customer's data are confined to -the pin through that customer's `customer_branches`: the tax -exemption writes and the customer priced rules, plain and -category, single and bulk. The branch directory reads +part. A bound key holding `users:grants` grants, revokes and +moves home only its own branch. A request that names +customers is confined through their `customer_branches`: the +tax exemption routes and the preview, the customer priced +rules plain and category single and bulk, and the reads that +name a customer in a path or query (the exemption read, the +category rule list by its `customer_id`, the rule audit, +resolve and calculate). A pricing rule names a customer +through its `customer_id` or its `job_id` (the job's projects +row belongs to one customer), and a rule or category rule +that would be dealer wide (naming neither, or targeting a +tier) is refused. REFUSED outright, whatever the scope: the +branch directory create, the exposure scan and the two +exposure lists, the market index refresh and its preview, +the events feed (`GET /api/v1/events`), every reporting +route, the GL, AP and bank reconciliation surfaces, the +sales team reads, the staff roster, the known users list +(`GET /api/v1/users`) and the unfiltered pricing lists +(`GET /pricing/rules`, `GET /pricing/matrix`, the category +rule list with no `customer_id`). The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay -reference data (the PR 39 decision). An unbound key behaves -as before. +reference data (the PR 39 decision), the stated limits with +their reasons are listed in ADR 0007 section 5.5. An unbound +key behaves as before. ## Routes diff --git a/docs/modules/locations.md b/docs/modules/locations.md index ce410420..a9cfc256 100644 --- a/docs/modules/locations.md +++ b/docs/modules/locations.md @@ -375,9 +375,13 @@ before the switch is read, `BranchMiddleware` in `core/pkg/middleware/branch.go`). Its lists, by id reads and tree see that branch only, a request naming another branch in `X-Branch-Id` is a 403 `forbidden` audited as `key.branch_refused`, -and a body `parent_id` or path id of another branch is a 403. The -routes that mount no branch middleware are held to the pin by the -key branch wall (`core/pkg/middleware/keybranch.go`, mounted through +and a body `parent_id` or path id of another branch is a 403. A bound key is denied by default on the routes that mount no +branch middleware: one allowlist +(`core/pkg/middleware/keybranch_allow.go`) classifies every route a machine key can +reach and the gate `KeyBranchRouteGate` in serve's chain refuses +it every route the table does not admit (ADR 0007 section 5.5). +The routes this module holds CONFINED run the key branch wall +(`core/pkg/middleware/keybranch.go`, mounted through `WithKeyBranchWall`): a bound key writing another branch's location (`PUT` and `DELETE /api/v1/locations/{id}`), another branch itself (`PUT` and `DELETE /api/v1/branches/{id}`) or another branch in a @@ -387,12 +391,12 @@ read whole, so a body that is not exactly one complete JSON value (trailing data after the first value, or unparsable) is refused rather than passed for the handler to read only in part; a location row that names no branch (a legacy shape the denorm trigger refuses today) is outside every pin and is -refused like a foreign branch; the directory create -(`POST /api/v1/branches`) and the known users list (`GET /api/v1/users`, -every branch's subs) refuse a bound key outright; -`GET /api/v1/branches/{id}/users` refuses it another branch and +refused like a foreign branch; and +`GET /api/v1/branches/{id}/users` refuses it another branch while `GET /api/v1/users/{sub}/branches` answers only its branch for it. -The branch directory reads (`GET /api/v1/branches`, `GET +The directory create (`POST /api/v1/branches`) and the known users +list (`GET /api/v1/users`, every branch's subs) are REFUSED by the +gate. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data, unwalled by the PR 39 decision, a stated limit. A bound key that sets a user's home to its own branch clears `is_home` on that user's other grant rows: a side effect diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index db9e308c..17294b40 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -274,4 +274,4 @@ get an entry. | C5-2a | `POST /api/v1/drafts/quotes`, `PUT /api/v1/drafts/quotes/{id}` (and the orders kind's) | No draft routes existed (the payload branch rule was the quote create's alone, PR 37) | The payload branch rule of ADR 0007 section 2.3 applies to every draft route through the same guard: with a branch in the request's context (always, for a bound key) a payload `branch_id` must equal it, else 403 naming `payload.branch_id`; with no context branch an administrator or owner may name any branch, and so may every caller while the single branch kill switch is off, an unbound key, and `AUTH_MODE=dev`; a user with no context branch may name only a branch in their grants; the branch is fixed at create | | C5-2a | `PUT /api/v1/quotes/{id}/file` | No such route: the AI parse's original file could only enter through the quote create, which a draft promotion replaces | The quote file route of ADR 0007 section 10: the raw file bytes with their content type, the same 5 MiB bound (413 above it), `quotes:write`, the revision precondition (428, 409), accepted only while the quote is in status `draft` (else 409 `invalid_state_transition` with the `quote_not_draft` blocker); it replaces any stored file, moves the revision by one, returns the quote with its new `ETag`, applies the branch wall like the read, writes the `quote.file_attached` audit row and no outbox event. Being an entity write on a gated module it is refused to an agent marked session | | C5-2a | `GET /api/v1/links/quotes/{id}`, `GET /api/v1/links/orders/{id}`, `GET /api/v1/links/invoices/{id}`, `GET /api/v1/links/customers/{id}`, `GET /api/v1/links/products/{id}`, `GET /api/v1/links/drafts/quotes/{id}`, `GET /api/v1/links/drafts/orders/{id}` | No link resolver existed: every frontend and agent carried its own route table | The link resolver of ADR 0007 section 8: `{id}` is a UUID or the entity's document number, the record must exist and be visible to the caller (the module's own read, so an invisible record is the same 404 a missing one gets), and the answer carries the desk link (absolute when `GABLE_PUBLIC_URL` is set, origin relative otherwise), the front door's `/?open=` form, `gable://` for the shell, a null portal until it has record screens, and the agent slot from `GABLE_AGENT_URL_TEMPLATE` (null when unset). The scope is read off the URL: the module `read` for an entity link, `propose` or `commit` for a draft link. The declarative table is the only copy and generates `core/api/links.json`; `core/api/link-paths.json` carries the plain segment vectors the shell, the door and their tests share | -| SEC-BOUND-KEY-PIN | every route a machine key reaches that mounts no branch middleware: locations `PUT /api/v1/locations/{id}`, `DELETE /api/v1/locations/{id}`, branches `POST /api/v1/branches`, `PUT /api/v1/branches/{id}`, `DELETE /api/v1/branches/{id}`, grants `POST /api/v1/users/{sub}/branches`, `DELETE /api/v1/users/{sub}/branches/{branch_id}`, `PUT /api/v1/users/{sub}/home-branch`, reads `GET /api/v1/branches/{id}/users`, `GET /api/v1/users/{sub}/branches`, `GET /api/v1/users`, exposure `GET /api/v1/quotes/{id}/exposure`, `POST /api/v1/quotes/{id}/exposure/acknowledge`, `/request-ack`, `/override`, `/escalate-now`, `GET /api/v1/quotes/exposure`, `GET /api/v1/reports/exposure`, `POST /api/v1/admin/exposure-scan`, `POST /api/v1/market-indices/{id}/refresh`, reporting `/reports/*` and `/reporting/*` in all three registrations, `GET /api/v1/events`, the GL, AP and bank reconciliation reads (`GET /api/v1/gl/*`, `GET /api/v1/ap/*`, `GET /api/v1/bankrecon/*`), the sales team reads (`GET /api/v1/sales-team`, `GET /api/v1/sales-team/{id}`), and the customer scoped writes `POST /api/v1/tax/exemptions`, `DELETE /api/v1/tax/exemptions/{id}`, `POST /api/v1/pricing/rules`, `POST /api/v1/pricing/category-rules`, `PUT` and `DELETE /api/v1/pricing/category-rules/{id}`, `POST` and `DELETE /api/v1/pricing/category-rules/bulk` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin, and a scope checked key passes the role guards on its scope alone (RequireRole), so on every route above a bound key acted on the branch its body or path named: it edited and archived another branch's locations and branches, created branches, granted, revoked and moved any user's branches, and under AUTH_MODE=dev (whose actor fallback passes a keyless caller as the dev owner) acknowledged, requested, overrode and previewed another branch's quote exposure and ran the dealer wide scan | The key branch wall (`core/pkg/middleware/keybranch.go`, mounted through `WithKeyBranchWall`, `registerExposureRoutes`, the branchWall dealer wide mounts and `wire_customer_branch.go`) reads the pin (branchctx.KeyBranchFromContext) and compares the branch the request names by a path value, a body branch_id, or the branch of the row the path names (a location through its tree, a quote through its branch): a bound key naming another branch is refused 403 `forbidden` in the wire envelope naming the field, audited as `key.branch_refused` (the row the X-Branch-Id rule writes); its own branch passes. A body is read whole: a body that is not exactly one complete JSON value (trailing data after the first value, or unparsable) is refused rather than passed for the handler to read only in part, which closed the bypass where a grant or home body with trailing data wrote the foreign branch with no audit row. A location row that names no branch (a legacy shape the denorm trigger refuses today) is outside every pin and is refused like a foreign branch. The routes that act across every branch refuse a bound key outright: the directory create, the exposure scan and the two exposure lists, the market index refresh (it re-checks every branch's quotes), the events feed, every reporting route, the dealer wide GL, AP and bank reconciliation reads, the sales team reads and the known users list. The customer scoped writes are confined to the pin through the named customer's `customer_branches`: the tax exemption writes and the customer priced rules, plain and category, single and bulk, refuse a bound key any customer whose branch set does not hold the pin (403 naming `customer_id`); a write that names no customer is dealer wide and passes. `GET /api/v1/branches/{id}/users` refuses another branch and `GET /api/v1/users/{sub}/branches` answers only the pin's branch for a bound key. A bound key's answer for a missing or unknown branch id on the named branch and body branch routes is now the 403 with its audit row, where the base answers 404: the wall cannot tell a missing branch from a foreign one and fails closed. Users and unbound keys are unchanged on every route, whatever the multi_branch_enabled switch says. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data (PR 39), the GL, AP and bank reconciliation writes and the index metadata and history carry no branch fact and keep the role guard alone, a stated limit | +| SEC-BOUND-KEY-PIN | the whole `/api/v1` surface a machine key can reach (345 census routes, method plus pattern), held by one allowlist, `core/pkg/middleware/keybranch_allow.go` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin: a scope checked key passes the role guards on scope alone, and the fix that walled each route the sweep found was opt in, so every route a round missed failed open. Rounds 3 and 4 proved it by probe: the market index refresh preview returned other branches' customers and exposure; the customer scoped pricing and tax reads answered about branch B; a pricing rule naming a branch B customer's job set that customer's price with no audit row; dealer wide pricing writes (a rule naming neither customer nor job, a tier category rule) passed; and the GL, AP and bank reconciliation writes, the staff roster and the unfiltered pricing lists were open | Deny by default. One allowlist classifies every machine key reachable route as exactly one of WALLED (183, behind the branch middleware), CONFINED (28, held by the key branch wall or the customer branch wall, each entry naming its test), STATED LIMIT (69, reachable unconfined by design, each entry with its one line reason) or REFUSED (65, answered 403 `forbidden` with the `key.branch_refused` row). The one runtime check is `KeyBranchRouteGate`, mounted once in serve's chain inside auth: it resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, a route the router resolves to no pattern included, so a missed or new route fails closed. A census test (untagged, no database) fails when a machine key reachable census route is unclassified or a table entry is stale. The confinements are completed: `job_id` resolves to its projects row's customer beside `customer_id` (both are checked), the customer scoped reads that name a customer in a path, query or body are confined like the writes (the exemption read, the category rule list by `customer_id` with the unfiltered list refused, the rule audit, resolve, calculate, the tax preview), and a write that would be dealer wide is refused: a pricing rule naming neither a customer nor a job, a category rule or bulk element targeting a tier, and a bulk delete naming a tier row. REFUSED outright beside the round 3 list: the GL, AP and bank reconciliation writes (reads already refused), the staff roster and its module grants, the index refresh preview, `GET /pricing/rules`, `GET /pricing/matrix` and the unfiltered category rule list. Stated limits stay reachable with their reasons in the table, and ADR 0007 section 5.5 names them and advises which scopes not to mint on a bound key. Users and unbound keys keep exactly their reach on every route | From 5b20837c0103404763a18777f61b8f7901d3389f Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 12:02:07 -0700 Subject: [PATCH 19/74] style: gofmt the allowlist table Signed-off-by: colton --- core/pkg/middleware/keybranch_allow.go | 694 ++++++++++++------------- 1 file changed, 347 insertions(+), 347 deletions(-) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 9e6b4e02..23a8fcf2 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -80,351 +80,351 @@ type KeyBranchRouteNote struct { // route here that no longer exists, fail the census test; at runtime either // is refused, because the gate allows only what the table admits. var keyBranchRoutes = map[string]KeyBranchRouteNote{ - "DELETE /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, - "DELETE /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, - "DELETE /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBranch(ids to rows customers); TestKeyBranchPin_CustomerConfinedWrites"}, - "DELETE /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, - "DELETE /api/v1/tax/exemptions/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, - "DELETE /api/v1/users/{sub}/branches/{branch_id}": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, - "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, - "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id); TestKeyBranchPin_CustomerConfinedReads"}, - "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, - "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, - "GET /api/v1/pricing/resolve": {KeyBranchClassConfined, "CustomerBranch(query customer_id, when named); TestKeyBranchPin_CustomerConfinedReads"}, - "GET /api/v1/quotes/{id}/exposure": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, - "GET /api/v1/tax/exemptions/{customerID}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_CustomerConfinedReads"}, - "GET /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "the handler answers only the pin branch; TestKeyBranchPin_LocationReads"}, - "POST /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBodyBranch, tier targets refused; TestKeyBranchPin_CategoryTierRefusals"}, - "POST /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBodyBranch per element; TestKeyBranchPin_CustomerConfinedWrites"}, - "POST /api/v1/pricing/rules": {KeyBranchClassConfined, "CustomerBranch(customer_id and job_id to projects.customer_id); TestKeyBranchPin_PricingRuleJobConfinement"}, - "POST /api/v1/quotes/{id}/exposure/acknowledge": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, - "POST /api/v1/quotes/{id}/exposure/escalate-now": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, - "POST /api/v1/quotes/{id}/exposure/override": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, - "POST /api/v1/quotes/{id}/exposure/request-ack": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, - "POST /api/v1/tax/exemptions": {KeyBranchClassConfined, "CustomerBodyBranch; TestKeyBranchPin_CustomerConfinedWrites"}, - "POST /api/v1/tax/preview": {KeyBranchClassConfined, "CustomerBranch(body customer, when named); TestKeyBranchPin_CustomerConfinedReads"}, - "POST /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, - "PUT /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, - "PUT /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, - "PUT /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, - "PUT /api/v1/users/{sub}/home-branch": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, - "DELETE /api/v1/admin/staff/{id}/modules/{module_id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "DELETE /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "DELETE /api/v1/reporting/schedules/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "GET /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "GET /api/v1/ap/aging": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "GET /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "GET /api/v1/bankrecon/sessions/{id}": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "GET /api/v1/events": {KeyBranchClassRefused, "the feed carries every branch events"}, - "GET /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/balance-sheet": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/fiscal-periods": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/journal-entries/{id}": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/profit-and-loss": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/gl/trial-balance": {KeyBranchClassRefused, "the books span every branch"}, - "GET /api/v1/pricing/matrix": {KeyBranchClassRefused, "the unfiltered matrix cannot be confined to a branch"}, - "GET /api/v1/pricing/rules": {KeyBranchClassRefused, "the unfiltered rule list names every customer"}, - "GET /api/v1/quotes/exposure": {KeyBranchClassRefused, "the list returns every branch exposure"}, - "GET /api/v1/reporting/export/{entity}": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reporting/saved": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reports/ar-aging": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reports/customer-statement/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reports/daily-till": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reports/exposure": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/reports/sales-summary": {KeyBranchClassRefused, "reports span every branch"}, - "GET /api/v1/sales-team": {KeyBranchClassRefused, "the sales team roster spans every branch"}, - "GET /api/v1/sales-team/{id}": {KeyBranchClassRefused, "the sales team roster spans every branch"}, - "GET /api/v1/users": {KeyBranchClassRefused, "the known users list spans every branch"}, - "POST /api/v1/admin/exposure-scan": {KeyBranchClassRefused, "the scan re-checks every branch quotes"}, - "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/ap/invoices/{id}/approve": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/bankrecon/import": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/bankrecon/match": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/bankrecon/sessions/{id}/complete": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/bankrecon/unmatch": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, - "POST /api/v1/branches": {KeyBranchClassRefused, "the branch directory create is outside every pin"}, - "POST /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/fiscal-periods/{id}/close": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/fiscal-periods/{id}/reopen": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/journal-entries/{id}/post": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/journal-entries/{id}/reverse": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/gl/journal-entries/{id}/void": {KeyBranchClassRefused, "the books span every branch"}, - "POST /api/v1/market-indices/{id}/refresh": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, - "POST /api/v1/market-indices/{id}/refresh/preview": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, - "POST /api/v1/reporting/builder/export": {KeyBranchClassRefused, "reports span every branch"}, - "POST /api/v1/reporting/builder/preview": {KeyBranchClassRefused, "reports span every branch"}, - "POST /api/v1/reporting/save": {KeyBranchClassRefused, "reports span every branch"}, - "POST /api/v1/reporting/saved/{id}/run": {KeyBranchClassRefused, "reports span every branch"}, - "POST /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, - "PUT /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "PUT /api/v1/gl/accounts/{id}": {KeyBranchClassRefused, "the books span every branch"}, - "PUT /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "DELETE /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "DELETE /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "DELETE /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "DELETE /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "DELETE /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "GET /api/v1/admin/modules": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, - "GET /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "GET /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "GET /api/v1/apps": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, - "GET /api/v1/branches": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, - "GET /api/v1/branches/{id}": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, - "GET /api/v1/configurator/options": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "GET /api/v1/configurator/presets": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "GET /api/v1/configurator/rules": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "GET /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "GET /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "GET /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "GET /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "GET /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "GET /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "GET /api/v1/edi/partners/{id}/catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "GET /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "GET /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "GET /api/v1/market-indices": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, - "GET /api/v1/market-indices/{id}/history": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, - "GET /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, - "GET /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, - "GET /api/v1/millwork/options/{id}": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, - "GET /api/v1/price_levels": {KeyBranchClassStatedLimit, "the dealer wide price level vocabulary"}, - "GET /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, - "GET /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "GET /api/v1/pricing/rebates/programs/{id}": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "GET /api/v1/pricing/rebates/programs/{id}/claims": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "GET /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, - "GET /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, - "GET /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, - "GET /api/v1/vendors/{id}": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, - "POST /api/v1/apps/{key}/disable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, - "POST /api/v1/apps/{key}/enable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, - "POST /api/v1/configurator/build-sku": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "POST /api/v1/configurator/validate": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "POST /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/drivers/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/vehicles/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "POST /api/v1/edi/partners/{id}/import-catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "POST /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "POST /api/v1/governance/rfcs/{id}/transitions": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "POST /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, - "POST /api/v1/parsing/upload": {KeyBranchClassStatedLimit, "the upload parses dealer wide product data"}, - "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, - "POST /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, - "POST /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "POST /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, - "POST /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, - "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, - "PUT /api/v1/admin/modules/{id}": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, - "PUT /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "PUT /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "PUT /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "PUT /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "PUT /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "PUT /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "PUT /api/v1/market-indices/{id}": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, - "PUT /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, - "PUT /api/v1/pricing/categories/{id}": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, - "PUT /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, - "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/accounts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/aging": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/reconciliation": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/inventory-alerts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/order-activity": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/revenue-trend": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/top-customers": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/deliveries/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/deliveries/{id}/pod-photos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes/{id}/deliveries": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/documents/print/invoice/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/documents/print/pickticket/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders/feed": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes/feed": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/inventory": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices/{id}/payments": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/matching/exceptions": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/matching/results/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/fulfillment-requests": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/catalog": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/products/search": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/detail": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/collateral": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/media": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/dimensions": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/lead-time": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/margins": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/pim/media/{mediaId}/primary": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/adjust-qty": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/pod-photo": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/optimize": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/reorder": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/inventory/adjust": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/inventory/transfer": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/email": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, + "DELETE /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, + "DELETE /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBranch(ids to rows customers); TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/tax/exemptions/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "DELETE /api/v1/users/{sub}/branches/{branch_id}": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, + "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, + "GET /api/v1/pricing/resolve": {KeyBranchClassConfined, "CustomerBranch(query customer_id, when named); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/quotes/{id}/exposure": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "GET /api/v1/tax/exemptions/{customerID}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "the handler answers only the pin branch; TestKeyBranchPin_LocationReads"}, + "POST /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBodyBranch, tier targets refused; TestKeyBranchPin_CategoryTierRefusals"}, + "POST /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBodyBranch per element; TestKeyBranchPin_CustomerConfinedWrites"}, + "POST /api/v1/pricing/rules": {KeyBranchClassConfined, "CustomerBranch(customer_id and job_id to projects.customer_id); TestKeyBranchPin_PricingRuleJobConfinement"}, + "POST /api/v1/quotes/{id}/exposure/acknowledge": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/escalate-now": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/override": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/quotes/{id}/exposure/request-ack": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, + "POST /api/v1/tax/exemptions": {KeyBranchClassConfined, "CustomerBodyBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "POST /api/v1/tax/preview": {KeyBranchClassConfined, "CustomerBranch(body customer, when named); TestKeyBranchPin_CustomerConfinedReads"}, + "POST /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "PUT /api/v1/branches/{id}": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationWrites"}, + "PUT /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, + "PUT /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, + "PUT /api/v1/users/{sub}/home-branch": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "DELETE /api/v1/admin/staff/{id}/modules/{module_id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "DELETE /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "DELETE /api/v1/reporting/schedules/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "GET /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "GET /api/v1/ap/aging": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/bankrecon/sessions/{id}": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/events": {KeyBranchClassRefused, "the feed carries every branch events"}, + "GET /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/balance-sheet": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/fiscal-periods": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/journal-entries/{id}": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/profit-and-loss": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/gl/trial-balance": {KeyBranchClassRefused, "the books span every branch"}, + "GET /api/v1/pricing/matrix": {KeyBranchClassRefused, "the unfiltered matrix cannot be confined to a branch"}, + "GET /api/v1/pricing/rules": {KeyBranchClassRefused, "the unfiltered rule list names every customer"}, + "GET /api/v1/quotes/exposure": {KeyBranchClassRefused, "the list returns every branch exposure"}, + "GET /api/v1/reporting/export/{entity}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/saved": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/ar-aging": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/customer-statement/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/daily-till": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/exposure": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/reports/sales-summary": {KeyBranchClassRefused, "reports span every branch"}, + "GET /api/v1/sales-team": {KeyBranchClassRefused, "the sales team roster spans every branch"}, + "GET /api/v1/sales-team/{id}": {KeyBranchClassRefused, "the sales team roster spans every branch"}, + "GET /api/v1/users": {KeyBranchClassRefused, "the known users list spans every branch"}, + "POST /api/v1/admin/exposure-scan": {KeyBranchClassRefused, "the scan re-checks every branch quotes"}, + "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/invoices/{id}/approve": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/import": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/match": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/sessions/{id}/complete": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/bankrecon/unmatch": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "POST /api/v1/branches": {KeyBranchClassRefused, "the branch directory create is outside every pin"}, + "POST /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/fiscal-periods/{id}/close": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/fiscal-periods/{id}/reopen": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/post": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/reverse": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/gl/journal-entries/{id}/void": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/market-indices/{id}/refresh": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, + "POST /api/v1/market-indices/{id}/refresh/preview": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, + "POST /api/v1/reporting/builder/export": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/builder/preview": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/save": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/saved/{id}/run": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, + "PUT /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "PUT /api/v1/gl/accounts/{id}": {KeyBranchClassRefused, "the books span every branch"}, + "PUT /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, + "DELETE /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "DELETE /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "DELETE /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "DELETE /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "DELETE /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/admin/modules": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, + "GET /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "GET /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "GET /api/v1/apps": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "GET /api/v1/branches": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, + "GET /api/v1/branches/{id}": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, + "GET /api/v1/configurator/options": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/configurator/presets": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/configurator/rules": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "GET /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "GET /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/edi/partners/{id}/catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "GET /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "GET /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "GET /api/v1/market-indices": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "GET /api/v1/market-indices/{id}/history": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "GET /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, + "GET /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "GET /api/v1/millwork/options/{id}": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "GET /api/v1/price_levels": {KeyBranchClassStatedLimit, "the dealer wide price level vocabulary"}, + "GET /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "GET /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/pricing/rebates/programs/{id}": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/pricing/rebates/programs/{id}/claims": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "GET /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "GET /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "GET /api/v1/vendors/{id}": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "POST /api/v1/apps/{key}/disable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "POST /api/v1/apps/{key}/enable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, + "POST /api/v1/configurator/build-sku": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "POST /api/v1/configurator/validate": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, + "POST /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/drivers/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/delivery/vehicles/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "POST /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "POST /api/v1/edi/partners/{id}/import-catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "POST /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "POST /api/v1/governance/rfcs/{id}/transitions": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "POST /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "POST /api/v1/parsing/upload": {KeyBranchClassStatedLimit, "the upload parses dealer wide product data"}, + "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, + "POST /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "POST /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "POST /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "POST /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, + "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, + "PUT /api/v1/admin/modules/{id}": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, + "PUT /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "PUT /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, + "PUT /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "PUT /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, + "PUT /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "PUT /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "PUT /api/v1/market-indices/{id}": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, + "PUT /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, + "PUT /api/v1/pricing/categories/{id}": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, + "PUT /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, + "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/accounts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/aging": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/reconciliation": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/inventory-alerts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/order-activity": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/revenue-trend": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/dashboard/top-customers": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/deliveries/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/deliveries/{id}/pod-photos": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/delivery/routes/{id}/deliveries": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/documents/print/invoice/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/documents/print/pickticket/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders/feed": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes/feed": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/inventory": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/invoices/{id}/payments": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/matching/exceptions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/matching/results/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/fulfillment-requests": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/catalog": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/products/search": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/detail": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/collateral": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/pim/media": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/dimensions": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/lead-time": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/margins": {KeyBranchClassWalled, "the branch middleware"}, + "PATCH /api/v1/products/{id}/pim/media/{mediaId}/primary": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/credit-memos/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/adjust-qty": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/pod-photo": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/deliveries/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/optimize": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/reorder": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/delivery/routes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/drafts/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/inventory/adjust": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/inventory/transfer": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/email": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/orders/fulfillment-requests/{delivery_id}/retry": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/fulfillments": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/card": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/image": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/seo": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/refresh-reorder-targets": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/fulfillments": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/card": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/image": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/products/{id}/pim/generate/seo": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/refresh-reorder-targets": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, + "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, } // KeyBranchRouteClassFor returns the class the table gives a method and @@ -513,9 +513,9 @@ func KeyBranchRouteGate(mux http.Handler, auditor BranchRefusalAuditor) func(htt } type keyBranchRouteGate struct { - mux http.Handler - resolve patternResolver - auditor BranchRefusalAuditor + mux http.Handler + resolve patternResolver + auditor BranchRefusalAuditor } func (g *keyBranchRouteGate) wrap(next http.Handler) http.Handler { From a4eebd671265d77f031d8f0a4ca4a314d6d4df85 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:19:08 -0700 Subject: [PATCH 20/74] security: a category rule element that targets a tier never passes the key branch wall Round 6 P1-1: a tier category rule that also named a customer passed the customer body wall (it reads customer_id only), was written, and priced every branch's customers of that tier while the allowlist said tier targets are refused. The wall's category body resolver now decodes the same target the handler parses (target_type, tier, customer_id, per element of a bulk body) and refuses any element whose target is not an account rule or that carries a tier; the handler keeps the invariant on its own side (a tier rule carries no customer, an account rule no tier); migration 107 puts the invariant on the table, checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when any exists, validated on add otherwise. Signed-off-by: colton --- .../app/serve/wire_customer_branch.go | 79 +++++++++++++++++-- .../app/serve/wire_key_branch_allow_test.go | 33 ++++++++ core/internal/pricing/category_handler.go | 7 ++ .../107_category_rules_single_target.sql | 40 ++++++++++ .../107_category_rules_single_target_down.sql | 13 +++ core/pkg/middleware/keybranch_allow.go | 4 +- 6 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 core/migrations/107_category_rules_single_target.sql create mode 100644 core/migrations/down/107_category_rules_single_target_down.sql diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 3bad196d..5a9a5374 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -11,17 +11,22 @@ package serve // request names, or that the rows it acts on belong to, holds the pin among // that customer's branches. A request that names no customer passes, except // where the write would then be dealer wide: a pricing rule that names -// neither a customer nor a job, and a category rule (or a bulk element) that -// names no customer, price every branch's customers, so the resolver refuses -// them (ErrBodyRefused). The routes no customer can confine (the unfiltered -// lists, the matrix) are REFUSED by the route allowlist gate before these -// walls run. +// neither a customer nor a job, and a category rule (or a bulk element) whose +// target is not an account rule or that carries a tier, price every branch's +// customers, so the resolver refuses them (ErrBodyRefused). The routes no +// customer can confine (the unfiltered lists, the matrix) are REFUSED by the +// route allowlist gate before these walls run. import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "net/http" + "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" @@ -238,6 +243,68 @@ func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Requ } } +// categoryRuleBodyCustomers resolves the customers a category rule create (or +// a bulk element) names, holding the request to the same target the handler +// parses: every element must be an account rule (target_type "account") that +// carries no tier. A tier rule prices every branch's customers of that tier +// whether or not it also names a customer, so a tier element is refused +// however many branches of the customer it names hold the pin (round 6 P1-1); +// a target that is not an account rule names no branch the wall can hold and +// is refused with it. Both refusals are ErrBodyRefused, so the bound key +// never reaches the handler on them. +func categoryRuleBodyCustomers(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + raw, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("key branch wall: read body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + element := func(e struct { + TargetType string `json:"target_type"` + CustomerID string `json:"customer_id"` + Tier string `json:"tier"` + }) ([]uuid.UUID, error) { + if t, ok := pricing.ParseTargetType(e.TargetType); !ok || t != pricing.TargetTypeAccount { + return nil, middleware.ErrBodyRefused + } + if e.Tier != "" { + return nil, middleware.ErrBodyRefused + } + id, err := uuid.Parse(e.CustomerID) + if err != nil { + return nil, middleware.ErrBodyRefused + } + return []uuid.UUID{id}, nil + } + if trimmed := bytes.TrimSpace(raw); len(trimmed) > 0 && trimmed[0] == '[' { + var many []struct { + TargetType string `json:"target_type"` + CustomerID string `json:"customer_id"` + Tier string `json:"tier"` + } + if err := json.Unmarshal(raw, &many); err != nil { + return nil, middleware.ErrBodyRefused + } + out := make([]uuid.UUID, 0, len(many)) + for _, m := range many { + ids, err := element(m) + if err != nil { + return nil, err + } + out = append(out, ids...) + } + return out, nil + } + var one struct { + TargetType string `json:"target_type"` + CustomerID string `json:"customer_id"` + Tier string `json:"tier"` + } + if err := json.Unmarshal(raw, &one); err != nil { + return nil, middleware.ErrBodyRefused + } + return element(one) +} + // taxCustomerWall composes the tax role guard with the customer branch wall: // the exemption create's body names the customer, the delete's row belongs // to one, the read's path names one, and the preview's body names one when @@ -302,7 +369,7 @@ func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB // route allowlist gate; the categories routes are stated limits. func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - body := middleware.Compose(role, keyWall.CustomerBodyBranchRequired()) + body := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleBodyCustomers)) byRow := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleRowCustomerOf(db))) byBulkIDs := middleware.Compose(role, keyWall.CustomerBranch(bulkRuleCustomersOf(db))) list := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", true))) diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index f55c0f41..adae2e17 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -174,6 +174,39 @@ func TestKeyBranchPin_CategoryTierRefusals(t *testing.T) { f.allow(t, who, "POST", "/api/v1/pricing/category-rules", tier(), nil, http.StatusCreated) } + // A tier rule that also names the pin's own customer is still a tier rule: + // it prices every branch's customers of that tier, so the wall refuses the + // element however many branches of the named customer hold the pin (round + // 6 P1-1). The handler and the migration keep the same invariant: a tier + // rule carries no customer, an account rule no tier. + tierNamingCustomer := func(customer uuid.UUID) string { + return fmt.Sprintf(`{"target_type":"tier","tier":"PIN-TIER","customer_id":%q,"category_id":%q,"rule_type":"markup","value_pct":"5"}`, + customer.String(), f.freshCategory(t).String()) + } + for _, customer := range []uuid.UUID{f.custID, f.custB} { + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", tierNamingCustomer(customer), nil) + } + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules/bulk", + "["+tierNamingCustomer(f.custID)+","+categoryRuleBody(f.custID, f.freshCategory(t))+"]", nil) + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM category_pricing_rules WHERE tier = 'PIN-TIER' AND customer_id IS NOT NULL`).Scan(&tierRows); err != nil { + t.Fatal(err) + } + if tierRows != 0 { + t.Errorf("refused tier rule naming a customer wrote %d rows", tierRows) + } + // A target that names no customer and no tier is neither an account rule + // nor a tier rule: refused for the bound key, and the handler's own 400 + // answers everyone else. + f.refuseForeign(t, "POST", "/api/v1/pricing/category-rules", + fmt.Sprintf(`{"target_type":"account","category_id":%q,"rule_type":"markup","value_pct":"5"}`, f.freshCategory(t).String()), nil) + // The unbound key and the user pass the wall on the same body and meet the + // handler's own invariant answer: a tier rule that names a customer is a + // 400 naming customer_id, never a write. + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/category-rules", tierNamingCustomer(f.custID), nil, http.StatusBadRequest) + } + putBody := `{"rule_type":"markdown","value_pct":"2"}` f.refuseForeign(t, "PUT", "/api/v1/pricing/category-rules/"+f.catRuleT.String(), putBody, map[string]string{"If-Match": f.catRuleRev(t, f.catRuleT)}) diff --git a/core/internal/pricing/category_handler.go b/core/internal/pricing/category_handler.go index 0ac4557e..63fa07ab 100644 --- a/core/internal/pricing/category_handler.go +++ b/core/internal/pricing/category_handler.go @@ -409,10 +409,17 @@ func (req *categoryRuleWriteRequest) parse(v *httpx.Validator) CategoryPricingRu } } req.categoryRuleValues.parseValues(v, &rule) + // A rule targets exactly one kind of customer scope: an account rule names + // a customer and carries no tier, a tier rule names a tier and carries no + // customer (the invariant migration 107 puts on the table). A tier rule + // that also names a customer would price every branch's customers of that + // tier, so the pair is refused here and by the key branch wall. if rule.TargetType == TargetTypeAccount { v.Check(rule.CustomerID != nil, "customer_id", "is required on an account rule") + v.Check(rule.Tier == "", "tier", "an account rule carries no tier") } else { v.Check(rule.Tier != "", "tier", "is required on a tier rule") + v.Check(rule.CustomerID == nil, "customer_id", "a tier rule carries no customer") } return rule } diff --git a/core/migrations/107_category_rules_single_target.sql b/core/migrations/107_category_rules_single_target.sql new file mode 100644 index 00000000..1c437e86 --- /dev/null +++ b/core/migrations/107_category_rules_single_target.sql @@ -0,0 +1,40 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Migration: 107_category_rules_single_target +-- Description: A category pricing rule targets exactly one customer scope: an +-- account rule names a customer and carries no tier, a tier rule names a tier +-- and carries no customer. The table's original check only required each +-- target kind to carry its own key, so a tier rule could also name a customer +-- and then price every branch's customers of that tier while looking +-- customer scoped to the branch walls (round 6 P1-1). Existing rows are +-- checked first: a row that breaks the invariant is reported by a NOTICE +-- naming the count and the ids, never rewritten, and the constraint goes on +-- NOT VALID when any exists so the report and the schema can be reconciled by +-- hand, and validated immediately when none does. + +DO $$ +DECLARE + bad_count INTEGER; + bad_ids TEXT; +BEGIN + SELECT COUNT(*), COALESCE(string_agg(id::text, ', ' ORDER BY id), '') + INTO bad_count, bad_ids + FROM category_pricing_rules + WHERE (target_type = 'TIER' AND customer_id IS NOT NULL) + OR (target_type = 'ACCOUNT' AND tier IS NOT NULL); + + IF bad_count > 0 THEN + RAISE NOTICE '107: % category pricing rule row(s) name both a tier and a customer (or an account rule carrying a tier); not rewritten: %', bad_count, bad_ids; + EXECUTE 'ALTER TABLE category_pricing_rules + ADD CONSTRAINT category_pricing_rules_single_target_check + CHECK ((target_type = ''ACCOUNT'' AND tier IS NULL) + OR (target_type = ''TIER'' AND customer_id IS NULL)) NOT VALID'; + ELSE + RAISE NOTICE '107: no category pricing rule row names both a tier and a customer; constraint validated on add'; + EXECUTE 'ALTER TABLE category_pricing_rules + ADD CONSTRAINT category_pricing_rules_single_target_check + CHECK ((target_type = ''ACCOUNT'' AND tier IS NULL) + OR (target_type = ''TIER'' AND customer_id IS NULL))'; + END IF; +END $$; diff --git a/core/migrations/down/107_category_rules_single_target_down.sql b/core/migrations/down/107_category_rules_single_target_down.sql new file mode 100644 index 00000000..95e18e11 --- /dev/null +++ b/core/migrations/down/107_category_rules_single_target_down.sql @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Rollback for 107_category_rules_single_target.sql. +-- +-- Lives in migrations/down/ so cmd/migrate's `migrations/*.sql` glob cannot +-- pick it up and apply it as a forward migration. Apply by hand. +-- +-- What does not come back: nothing. The constraint goes entirely; no row was +-- rewritten by the up migration, so there is no data to restore. + +ALTER TABLE category_pricing_rules + DROP CONSTRAINT IF EXISTS category_pricing_rules_single_target_check; diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 23a8fcf2..4ca048d3 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -94,8 +94,8 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/quotes/{id}/exposure": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, "GET /api/v1/tax/exemptions/{customerID}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/users/{sub}/branches": {KeyBranchClassConfined, "the handler answers only the pin branch; TestKeyBranchPin_LocationReads"}, - "POST /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBodyBranch, tier targets refused; TestKeyBranchPin_CategoryTierRefusals"}, - "POST /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBodyBranch per element; TestKeyBranchPin_CustomerConfinedWrites"}, + "POST /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch; an account rule element only, tier targets refused; TestKeyBranchPin_CategoryTierRefusals"}, + "POST /api/v1/pricing/category-rules/bulk": {KeyBranchClassConfined, "CustomerBranch; an account rule element only, tier elements refused; TestKeyBranchPin_CategoryTierRefusals"}, "POST /api/v1/pricing/rules": {KeyBranchClassConfined, "CustomerBranch(customer_id and job_id to projects.customer_id); TestKeyBranchPin_PricingRuleJobConfinement"}, "POST /api/v1/quotes/{id}/exposure/acknowledge": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, "POST /api/v1/quotes/{id}/exposure/escalate-now": {KeyBranchClassConfined, "RowBranch(quote branch); TestKeyBranchPin_ExposureRoutes"}, From 38948f2b3a392db18506da6dd6998f5b590bb3de Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:28:00 -0700 Subject: [PATCH 21/74] security: the pos and account row routes hold a bound key to its branch Round 5 P1 and round 6 P1-2: the till, Z report, return and account routes loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), read B's till report, Z reports, returns, account summary, AR statement and reconciliation, and a line insert on B's transaction left the line written behind its 500. The till, Z report and return reads now carry the branch predicate the transaction reads already carried (till_sessions.branch_id, till_z_reports.branch_id, pos_returns.branch_id); the line delete joins its transaction's branch; AddItem reads the transaction (through the same predicate) before anything is written, so the insert never happens on a transaction the caller may not target; the account summary, subledger and statement mount the customer branch wall on the path customer; and the reconciliation, whose ledger half carries no branch fact, is refused to a bound key by the route allowlist. The pos handler file is untouched (PR 72 rewrites it): the confinement lives in the repository queries and the branch wall layer. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 15 +- .../app/serve/wire_customer_branch.go | 18 ++ .../app/serve/wire_key_branch_pin_test.go | 233 ++++++++++++++++++ core/internal/pos/repository.go | 13 +- core/internal/pos/returns_repository.go | 12 +- core/internal/pos/service.go | 19 +- core/internal/pos/till_repository.go | 24 +- core/pkg/middleware/keybranch_allow.go | 46 ++-- 8 files changed, 335 insertions(+), 45 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 247d1883..29c56088 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -158,9 +158,20 @@ func (w *branchWall) invoices(mux *http.ServeMux, svc *invoice.Service) { // accounts mounts the account and AR routes: the reads (summary, subledger, // aging, statement) behind the role guard and the branch middleware, the -// reconciliation and the application reversal behind the finance guard. +// reconciliation and the application reversal behind the finance guard. The +// summary, the subledger and the statement name a customer in the path, so +// the read guard carries the customer branch wall beside the branch +// middleware: a bound key reaches them only while that customer holds its +// pin (round 6 P1-2). The aging reads carry the repository's branch wall and +// name no customer, so the wall passes them; the application reversal walls +// through the invoice its application settles (the repository's wall), and +// the reconciliation reads every branch's books (its ledger half carries no +// branch fact), so a bound key is refused it by the route allowlist gate. func (w *branchWall) accounts(mux *http.ServeMux, svc *account.Service) { - account.NewHandler(svc).RegisterRoutes(mux, w.scoped("admin", "owner", "sales", "finance"), w.scoped("admin", "owner", "finance")) + customer := w.keyWall.CustomerBranch(accountCustomerPathOf()) + account.NewHandler(svc).RegisterRoutes(mux, + middleware.Compose(w.scoped("admin", "owner", "sales", "finance"), customer), + w.scoped("admin", "owner", "finance")) } // payments mounts the payment routes: the create takes branch_id from its body diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 5a9a5374..ca0ac35a 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -72,6 +72,24 @@ func exemptionCustomerPathOf() func(ctx context.Context, r *http.Request) ([]uui } } +// accountCustomerPathOf resolves the customer the account routes name in +// their path ({id} is the customer): the summary, the subledger and the +// statement read that customer's data, so they are confined exactly like a +// write that names one. +func accountCustomerPathOf() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + raw := r.PathValue("id") + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, nil // the handler answers the malformed id + } + return []uuid.UUID{id}, nil + } +} + // previewCustomerOf resolves the customer the tax preview's body names. A // preview that names no customer is a plain rate calculation and passes; one // that names a customer reads that customer's exemptions and is confined. diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index c566eab1..efbfb46a 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/ap" "github.com/gablelbm/gable/internal/app/orderwire" "github.com/gablelbm/gable/internal/bankrecon" @@ -30,7 +31,11 @@ import ( "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/gl" glint "github.com/gablelbm/gable/internal/integrations/gl" + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/location" + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/quote" @@ -74,6 +79,19 @@ type keyPinFixture struct { taxExempt uuid.UUID // a tax exemption row of the foreign customer jobA uuid.UUID // a project of the pin's customer jobB uuid.UUID // a project of the foreign customer + regA string // a POS register per branch, on the branch's yard + regB string + tillA uuid.UUID // an OPEN till session per register + tillB uuid.UUID + txnA uuid.UUID // an OPEN POS transaction per branch + txnB uuid.UUID + lineA uuid.UUID // one line item on each transaction + lineB uuid.UUID + retA uuid.UUID // a completed POS return per branch + retB uuid.UUID + zrA uuid.UUID // a frozen Z report per branch's session + zrB uuid.UUID + posProd uuid.UUID // the product the POS lines carry bound string // the key pinned to branch A unbound string // the same scopes, no pin } @@ -87,6 +105,7 @@ var pinKeyScopes = []string{ "admin:staff", "reports:read", "reporting:read", "reporting:write", "events:read", "gl:read", "gl:write", "ap:read", "ap:write", "bankrecon:read", "bankrecon:write", "sales-team:read", "market-indices:read", "market-indices:write", "tax:read", "tax:write", "pricing:read", "pricing:write", + "pos:read", "pos:write", "accounts:read", "ar:read", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -109,6 +128,9 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { yardA: uuid.New(), yardB: uuid.New(), quoteA: uuid.New(), quoteB: uuid.New(), custID: uuid.New(), custB: uuid.New(), category: uuid.New(), catRuleA: uuid.New(), catRuleB: uuid.New(), taxExempt: uuid.New(), yardNull: uuid.New(), catRuleT: uuid.New(), jobA: uuid.New(), jobB: uuid.New(), + tillA: uuid.New(), tillB: uuid.New(), txnA: uuid.New(), txnB: uuid.New(), lineA: uuid.New(), lineB: uuid.New(), + retA: uuid.New(), retB: uuid.New(), zrA: uuid.New(), zrB: uuid.New(), posProd: uuid.New(), + regA: "PINREG-A-" + uuid.NewString()[:8], regB: "PINREG-B-" + uuid.NewString()[:8], } for _, r := range []struct { id uuid.UUID @@ -192,6 +214,50 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { if _, err := db.Pool.Exec(ctx, `UPDATE locations SET branch_id = NULL WHERE id = $1`, f.yardNull); err != nil { t.Fatalf("null the branch: %v", err) } + // The POS twins: a register on each branch's yard, an OPEN till session, + // an OPEN transaction with one line, a completed return and a frozen + // Z report, so the by id pos routes act on real records of each branch. + cashier := uuid.New() + if _, err := db.Pool.Exec(ctx, + `INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'pin pos item', 'EA', 1)`, + f.posProd, "PINP-"+f.posProd.String()[:8]); err != nil { + t.Fatalf("seed pos product: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_registers (id, location_id, branch_id, name) VALUES ($1, $2, $3, 'pin reg A'), ($4, $5, $6, 'pin reg B')`, + f.regA, f.yardA, f.branchA, f.regB, f.yardB, f.branchB); err != nil { + t.Fatalf("seed registers: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO till_sessions (id, register_id, branch_id, cashier_id, status, opening_float) + VALUES ($1, $2, $3, $5, 'OPEN', 50), ($6, $4, $7, $5, 'OPEN', 50)`, + f.tillA, f.regA, f.branchA, f.regB, cashier, f.tillB, f.branchB); err != nil { + t.Fatalf("seed till sessions: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id) + VALUES ($1, $2, $4, 'OPEN', $5, $6), ($3, $7, $4, 'OPEN', $8, $9)`, + f.txnA, f.regA, f.txnB, cashier, f.branchA, f.tillA, f.regB, f.branchB, f.tillB); err != nil { + t.Fatalf("seed pos transactions: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_line_items (id, transaction_id, product_id, description, quantity, uom, unit_price, line_total) + VALUES ($1, $2, $4, 'pin', 1, 'EA', 1, 1), ($3, $5, $4, 'pin', 1, 'EA', 1, 1)`, + f.lineA, f.txnA, f.lineB, f.posProd, f.txnB); err != nil { + t.Fatalf("seed pos lines: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, refund_method, status) + VALUES ($1, $2, $3, $5, 'CASH', 'COMPLETED'), ($6, $4, $7, $5, 'CASH', 'COMPLETED')`, + f.retA, f.regA, f.branchA, f.regB, cashier, f.retB, f.branchB); err != nil { + t.Fatalf("seed pos returns: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload) + VALUES ($1, $2, $3, $4, 0, '{}'), ($5, $6, $7, $8, 0, '{}')`, + f.zrA, f.tillA, f.regA, f.branchA, f.zrB, f.tillB, f.regB, f.branchB); err != nil { + t.Fatalf("seed z reports: %v", err) + } t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' AND entity_id IN (SELECT id::text FROM quote_exposure_events WHERE quote_id IN ($1, $2))`, f.quoteA, f.quoteB) @@ -213,6 +279,16 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id IN ($1, $2)`, f.custID, f.custB) _, _ = db.Pool.Exec(ctx, `DELETE FROM user_locations WHERE user_sub LIKE 'pin-%' OR user_sub = 'pin-both'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE code LIKE 'pin-new-%'`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM till_z_reports WHERE id IN ($1, $2)`, f.zrA, f.zrB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_return_lines WHERE return_id IN ($1, $2)`, f.retA, f.retB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_returns WHERE id IN ($1, $2)`, f.retA, f.retB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_tenders WHERE transaction_id IN ($1, $2)`, f.txnA, f.txnB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id IN ($1, $2)`, f.txnA, f.txnB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id IN ($1, $2)`, f.txnA, f.txnB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM till_sessions WHERE id IN ($1, $2)`, f.tillA, f.tillB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_registers WHERE id IN ($1, $2)`, f.regA, f.regB) + _, _ = db.Pool.Exec(ctx, `DELETE FROM inventory WHERE product_id = $1`, f.posProd) + _, _ = db.Pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, f.posProd) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE parent_id IN ($1, $2, $3, $4)`, f.branchA, f.branchB, f.branchC, f.branchC2) _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2, $3, $4, $5, $6, $7)`, f.branchA, f.branchB, f.branchC, f.branchC2, f.yardA, f.yardB, f.yardNull) @@ -280,6 +356,24 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithAudit(pricingAuditAdapter{l: audit.NewLogger(db)}), customerSvc).RegisterCategoryRoutes(mux, categoryRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + // The account module exactly as serve registers it: the summary, the + // subledger and the statement name a customer in the path, and the aging + // and reconciliation reads come with the same mount. + accountSvc := account.NewService(db, glSvc, slog.Default()) + wall.accounts(mux, accountSvc) + + // The POS module exactly as serve registers it: the scoped role guard + // plus the branch middleware, and the repositories hold the pin on the + // till, Z report, return and line item rows. + invoiceSvc := invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db). + WithAuditLog(audit.NewLogger(db)) + paymentSvc := payment.NewService(db, payment.NewRepository(db), accountSvc). + WithAuditLog(audit.NewLogger(db)) + posSvc := pos.NewService(db, pos.NewRepository(db), productSvc, + inventory.NewService(inventory.NewRepository(db)), invoiceSvc, paymentSvc, slog.Default()). + WithAuditLog(audit.NewLogger(db)) + pos.NewHandler(posSvc).RegisterRoutes(mux, wall.scoped("admin", "owner", "cashier")) + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { t.Helper() @@ -1090,3 +1184,142 @@ func TestKeyBranchPin_RowWithoutBranch(t *testing.T) { f.putLocation(t, "unbound", f.yardNull, "pin free null edit", http.StatusOK) f.putLocation(t, "user", f.yardNull, "pin user null edit", http.StatusOK) } + +// TestKeyBranchPin_PosAndAccountRows holds the pin on the POS and account by +// id routes (round 5 P1, round 6 P1-2): a branch bound key reads or writes +// branch B's till session, Z report, return, transaction line or account only +// through its own branch. A foreign row answers exactly what a missing row +// answers, never B data; nothing is written (row counts before and after); +// the pin's own rows pass to the module; and the unbound key and the user +// keep their reach. The reconciliation reads every branch's books and is +// refused outright (the allowlist gate), like the GL surface. +func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { + f := newKeyPinFixture(t) + custA, custB := f.custID.String(), f.custB.String() + missing := uuid.NewString() + linesOn := func(tx uuid.UUID) (n int) { + t.Helper() + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM pos_line_items WHERE transaction_id = $1`, tx).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + tillOpen := func(id uuid.UUID) bool { + t.Helper() + var status string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT status FROM till_sessions WHERE id = $1`, id).Scan(&status); err != nil { + t.Fatal(err) + } + return status == "OPEN" + } + zReportsOn := func(session uuid.UUID) (n int) { + t.Helper() + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM till_z_reports WHERE till_session_id = $1`, session).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + // The open till of the foreign register: no session answers, while the + // pin's own register answers its own. + _, buf := f.key(t, f.bound, "GET", "/api/v1/pos/till/current?register_id="+f.regB, "", nil) + if strings.Contains(string(buf), f.tillB.String()) { + t.Errorf("bound key till current of the foreign register answered the foreign session: %s", buf) + } + buf = f.allow(t, "bound", "GET", "/api/v1/pos/till/current?register_id="+f.regA, "", nil, http.StatusOK) + if !strings.Contains(string(buf), f.tillA.String()) { + t.Errorf("bound key till current of the own register did not answer the own session: %s", buf) + } + + // The till report of the foreign session answers exactly what a missing + // session answers (never B data), and the unbound key reads the foreign + // one; the pin's own session answers its report. + sMiss, _ := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+missing+"/report", "", nil) + sB, bB := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+f.tillB.String()+"/report", "", nil) + if sB != sMiss || strings.Contains(string(bB), f.regB) { + t.Errorf("bound key foreign till report: %d %s, want the missing row answer (%d, no B data)", sB, bB, sMiss) + } + f.allow(t, "unbound", "GET", "/api/v1/pos/till/"+f.tillB.String()+"/report", "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/pos/till/"+f.tillA.String()+"/report", "", nil, http.StatusOK) + + // The foreign till cannot be closed: no session write, no Z report. + f.key(t, f.bound, "POST", "/api/v1/pos/till/"+f.tillB.String()+"/close", + `{"counted_by_method":{"CASH":50}}`, nil) + if !tillOpen(f.tillB) { + t.Errorf("bound key closed the foreign till session") + } + if n := zReportsOn(f.tillB); n != 1 { + t.Errorf("bound key foreign till close wrote Z reports: %d rows for the session", n) + } + + // The Z report of the foreign session answers what a missing one answers; + // the list carries the pin's own Z report and not the foreign one. + sMiss, _ = f.key(t, f.bound, "GET", "/api/v1/pos/till/"+missing+"/zreport", "", nil) + if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+f.tillB.String()+"/zreport", "", nil); sB != sMiss { + t.Errorf("bound key foreign Z report: %d, want the missing row answer %d", sB, sMiss) + } + _, buf = f.key(t, f.bound, "GET", "/api/v1/pos/zreports", "", nil) + if strings.Contains(string(buf), f.zrB.String()) { + t.Errorf("bound key Z report list carries the foreign Z report: %s", buf) + } + if !strings.Contains(string(buf), f.zrA.String()) { + t.Errorf("bound key Z report list does not carry the pin's own Z report: %s", buf) + } + + // The returns list carries the pin's own return and not the foreign one; + // the foreign return reads as missing, the own one answers. + _, buf = f.key(t, f.bound, "GET", "/api/v1/pos/returns", "", nil) + if strings.Contains(string(buf), f.retB.String()) { + t.Errorf("bound key returns list carries the foreign return: %s", buf) + } + if !strings.Contains(string(buf), f.retA.String()) { + t.Errorf("bound key returns list does not carry the pin's own return: %s", buf) + } + if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/returns/"+f.retB.String(), "", nil); sB != http.StatusNotFound { + t.Errorf("bound key foreign return read: %d, want 404", sB) + } + f.allow(t, "unbound", "GET", "/api/v1/pos/returns/"+f.retB.String(), "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/pos/returns/"+f.retA.String(), "", nil, http.StatusOK) + + // The transaction reads hold the pin already (branch_id predicate); the + // line writes now hold it too: no line is written on or removed from the + // foreign transaction, and the pin's own transaction takes the write. + if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/transactions/"+f.txnB.String(), "", nil); sB != http.StatusNotFound { + t.Errorf("bound key foreign transaction read: %d, want 404", sB) + } + before := linesOn(f.txnB) + f.key(t, f.bound, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items", + fmt.Sprintf(`{"product_id":%q,"quantity":1,"uom":"EA"}`, f.posProd.String()), nil) + if after := linesOn(f.txnB); after != before { + t.Errorf("bound key line insert on the foreign transaction wrote: %d rows then %d", before, after) + } + f.key(t, f.bound, "DELETE", "/api/v1/pos/transactions/"+f.txnB.String()+"/items/"+f.lineB.String(), "", nil) + if after := linesOn(f.txnB); after != before { + t.Errorf("bound key line delete on the foreign transaction wrote: %d rows then %d", before, after) + } + before = linesOn(f.txnA) + f.allow(t, "bound", "POST", "/api/v1/pos/transactions/"+f.txnA.String()+"/items", + fmt.Sprintf(`{"product_id":%q,"quantity":1,"uom":"EA"}`, f.posProd.String()), nil, http.StatusOK) + if after := linesOn(f.txnA); after != before+1 { + t.Errorf("bound key line insert on the own transaction: %d rows then %d, want one new line", before, after) + } + + // The account routes name a customer in the path: the foreign customer is + // refused with the audit row, the pin's own answers, and the + // reconciliation (every branch's books) is refused outright. + f.refuseForeign(t, "GET", "/api/v1/accounts/"+custB, "", nil) + f.refuseForeign(t, "GET", "/api/v1/accounts/"+custB+"/transactions", "", nil) + f.refuseForeign(t, "GET", "/api/v1/ar/customers/"+custB+"/statement", "", nil) + f.refuseForeign(t, "GET", "/api/v1/ar/reconciliation", "", nil) + f.allow(t, "bound", "GET", "/api/v1/accounts/"+custA, "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/accounts/"+custA+"/transactions", "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/ar/customers/"+custA+"/statement", "", nil, http.StatusOK) + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "GET", "/api/v1/accounts/"+custB, "", nil, http.StatusOK) + f.allow(t, who, "GET", "/api/v1/ar/customers/"+custB+"/statement", "", nil, http.StatusOK) + f.allow(t, who, "GET", "/api/v1/ar/reconciliation", "", nil, http.StatusOK) + } +} diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 4299e3cf..48775837 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -268,9 +268,18 @@ func (r *PostgresRepository) AddLineItem(ctx context.Context, item *POSLineItem) return nil } +// RemoveLineItem deletes a line through its transaction's branch: a bound +// key's branch context (or a user's grants) hides a transaction of another +// branch, so its line deletes as missing and nothing is written. func (r *PostgresRepository) RemoveLineItem(ctx context.Context, itemID uuid.UUID) error { - query := `DELETE FROM pos_line_items WHERE id = $1` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, itemID) + query := ` + DELETE FROM pos_line_items li + USING pos_transactions t + WHERE li.id = $1 + AND t.id = li.transaction_id + AND ($2::uuid IS NULL OR t.branch_id = $2) + ` + _, err := r.db.GetExecutor(ctx).Exec(ctx, query, itemID, branchctx.IDForQuery(ctx)) if err != nil { return fmt.Errorf("failed to remove POS line item: %w", err) } diff --git a/core/internal/pos/returns_repository.go b/core/internal/pos/returns_repository.go index b1f3189e..f94359af 100644 --- a/core/internal/pos/returns_repository.go +++ b/core/internal/pos/returns_repository.go @@ -9,6 +9,7 @@ import ( "fmt" "time" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -104,7 +105,8 @@ func (r *PostgresRepository) scanReturn(row pgx.Row) (*POSReturn, error) { func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*POSReturn, error) { row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+returnColumns+` FROM pos_returns WHERE id = $1`, id) + `SELECT `+returnColumns+` FROM pos_returns WHERE id = $1 AND ($2::uuid IS NULL OR branch_id = $2)`, + id, branchctx.IDForQuery(ctx)) ret, err := r.scanReturn(row) if err != nil { return nil, fmt.Errorf("failed to get return: %w", err) @@ -140,11 +142,13 @@ func (r *PostgresRepository) getReturnLines(ctx context.Context, returnID uuid.U } func (r *PostgresRepository) ListReturns(ctx context.Context, registerID string, date time.Time) ([]POSReturn, error) { - q := `SELECT ` + returnColumns + ` FROM pos_returns WHERE ($1 = '' OR register_id = $1)` - args := []any{registerID} + branch := branchctx.IDForQuery(ctx) + q := `SELECT ` + returnColumns + ` FROM pos_returns + WHERE ($1 = '' OR register_id = $1) AND ($2::uuid IS NULL OR branch_id = $2)` + args := []any{registerID, branch} if !date.IsZero() { start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND created_at >= $2 AND created_at < $3` + q += ` AND created_at >= $3 AND created_at < $4` args = append(args, start, start.Add(24*time.Hour)) } q += ` ORDER BY created_at DESC` diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index d1f1d057..f7bb4c81 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -143,6 +143,15 @@ func (s *Service) StartTransaction(ctx context.Context, registerID string, cashi // AddItem adds a product to the transaction cart. func (s *Service) AddItem(ctx context.Context, txID uuid.UUID, req AddLineItemRequest) (*POSTransaction, error) { + // The transaction must be visible before anything is written: the read + // carries the branch predicate, so a transaction of a branch the caller's + // branch context may not target (or a missing one) names nothing the + // caller may add to, and the line insert never happens (round 6 P1-2: + // the insert used to land and the recalculation fail after it). + tx, err := s.repo.GetTransaction(ctx, txID) + if err != nil { + return nil, err + } // Get the product to populate description and pricing prod, err := s.productSvc.GetProduct(ctx, req.ProductID) if err != nil { @@ -151,13 +160,9 @@ func (s *Service) AddItem(ctx context.Context, txID uuid.UUID, req AddLineItemRe // Resolve effective price (customer-specific if available) effectivePrice := prod.BasePrice - if s.priceCalc != nil { - // Get the transaction to check for customer association - tx, txErr := s.repo.GetTransaction(ctx, txID) - if txErr == nil && tx.CustomerID != nil { - if resolved, pErr := s.priceCalc.CalculateItemPrice(ctx, *tx.CustomerID, req.ProductID, prod.BasePrice, req.Quantity); pErr == nil { - effectivePrice = resolved - } + if s.priceCalc != nil && tx.CustomerID != nil { + if resolved, pErr := s.priceCalc.CalculateItemPrice(ctx, *tx.CustomerID, req.ProductID, prod.BasePrice, req.Quantity); pErr == nil { + effectivePrice = resolved } } diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index 42215df2..be6fee5d 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -10,6 +10,7 @@ import ( "fmt" "time" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -93,7 +94,8 @@ const tillSessionColumns = `id, register_id, branch_id, cashier_id, status, open func (r *PostgresRepository) GetTillSession(ctx context.Context, id uuid.UUID) (*TillSession, error) { row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+tillSessionColumns+` FROM till_sessions WHERE id = $1`, id) + `SELECT `+tillSessionColumns+` FROM till_sessions WHERE id = $1 AND ($2::uuid IS NULL OR branch_id = $2)`, + id, branchctx.IDForQuery(ctx)) s, err := r.scanTillSession(row) if err != nil { return nil, fmt.Errorf("failed to get till session: %w", err) @@ -101,10 +103,14 @@ func (r *PostgresRepository) GetTillSession(ctx context.Context, id uuid.UUID) ( return s, nil } -// GetOpenTillSession returns the register's OPEN session, or (nil, nil). +// GetOpenTillSession returns the register's OPEN session, or (nil, nil). A +// session of a branch the caller's branch context may not target reads as +// none (the branch predicate the transaction reads carry). func (r *PostgresRepository) GetOpenTillSession(ctx context.Context, registerID string) (*TillSession, error) { row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+tillSessionColumns+` FROM till_sessions WHERE register_id = $1 AND status = 'OPEN'`, registerID) + `SELECT `+tillSessionColumns+` FROM till_sessions + WHERE register_id = $1 AND status = 'OPEN' AND ($2::uuid IS NULL OR branch_id = $2)`, + registerID, branchctx.IDForQuery(ctx)) s, err := r.scanTillSession(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -230,7 +236,9 @@ const zReportColumns = `id, till_session_id, register_id, branch_id, over_short, func (r *PostgresRepository) GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) { row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+zReportColumns+` FROM till_z_reports WHERE till_session_id = $1`, sessionID) + `SELECT `+zReportColumns+` FROM till_z_reports + WHERE till_session_id = $1 AND ($2::uuid IS NULL OR branch_id = $2)`, + sessionID, branchctx.IDForQuery(ctx)) z, err := r.scanZReport(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -242,11 +250,13 @@ func (r *PostgresRepository) GetZReportBySession(ctx context.Context, sessionID } func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) { - q := `SELECT ` + zReportColumns + ` FROM till_z_reports WHERE ($1 = '' OR register_id = $1)` - args := []any{registerID} + branch := branchctx.IDForQuery(ctx) + q := `SELECT ` + zReportColumns + ` FROM till_z_reports + WHERE ($1 = '' OR register_id = $1) AND ($2::uuid IS NULL OR branch_id = $2)` + args := []any{registerID, branch} if !date.IsZero() { start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND generated_at >= $2 AND generated_at < $3` + q += ` AND generated_at >= $3 AND generated_at < $4` args = append(args, start, start.Add(24*time.Hour)) } q += ` ORDER BY generated_at DESC` diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 4ca048d3..ec355593 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -87,7 +87,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "DELETE /api/v1/tax/exemptions/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, "DELETE /api/v1/users/{sub}/branches/{branch_id}": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, - "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id); TestKeyBranchPin_CustomerConfinedReads"}, + "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id, and job_id to projects.customer_id); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules/{id}/audit": {KeyBranchClassConfined, "CustomerRowBranch, tier rows refused; TestKeyBranchPin_CategoryTierRefusals"}, "GET /api/v1/pricing/resolve": {KeyBranchClassConfined, "CustomerBranch(query customer_id, when named); TestKeyBranchPin_CustomerConfinedReads"}, @@ -244,16 +244,16 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "pos_transactions.branch_id (the delete joins it); TestKeyBranchPin_PosAndAccountRows"}, "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassWalled, "the branch middleware"}, "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/accounts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/accounts/{id}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/aging": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/reconciliation": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/ar/aging": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, + "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, + "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/ar/reconciliation": {KeyBranchClassRefused, "the reconciliation reads every branch's books; its ledger half carries no branch fact"}, "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, @@ -309,14 +309,14 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/pos/catalog": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/pos/products/search": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/pos/returns": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "till_sessions.branch_id (the register's open session); TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "till_sessions.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_transactions.branch_id"}, + "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "pos_transactions.branch_id"}, + "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/products/{id}/detail": {KeyBranchClassWalled, "the branch middleware"}, @@ -386,14 +386,14 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "pos_registers.branch_id on create; no machine key cashier"}, "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the register's branch (pos_registers.branch_id, refused on a mismatch) and no machine key cashier"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_registers.branch_id on create, pos_transactions.branch_id after; no machine key cashier"}, + "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, + "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, + "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassWalled, "the branch middleware"}, From a89170250b9d4067386827eae241683fb38c3417 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:28:51 -0700 Subject: [PATCH 22/74] security: the price calculation resolves job_id through the job's customer Round 5 F-3: GET /pricing/calculate confined only its customer_id, so the pin's customer plus the foreign customer's job answered with the job override rule that prices the foreign customer, byte for byte the unbound key's answer. The calculation's resolver now resolves job_id to projects.customer_id, the same resolution the rule write carries, and holds both customers to the pin; a job that names no customer changes nothing. Signed-off-by: colton --- .../app/serve/wire_customer_branch.go | 42 +++++++++++++++++-- .../app/serve/wire_key_branch_allow_test.go | 20 +++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index ca0ac35a..1edbe4eb 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -353,15 +353,51 @@ func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles . } } +// calculateCustomersOf resolves the customers the price calculation names: +// its query customer_id, and the customer of the job its job_id names (a job +// rule prices that job's customer, the same resolution the rule write +// carries, round 5 F-3). A job that does not exist names nothing the wall can +// hold and the handler's own answer covers it; a query that names neither +// passes (a retail calculation with no customer scope). +func calculateCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("price calculation customer lookup: no database") + } + q := r.URL.Query() + var out []uuid.UUID + if vals := q["customer_id"]; len(vals) > 0 { + if id, err := uuid.Parse(vals[0]); err == nil { + out = append(out, id) + } + } + if vals := q["job_id"]; len(vals) > 0 { + if job, err := uuid.Parse(vals[0]); err == nil { + var customer *uuid.UUID + err := db.GetExecutor(ctx).QueryRow(ctx, + `SELECT customer_id FROM projects WHERE id = $1`, job).Scan(&customer) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if customer != nil { + out = append(out, *customer) + } + } + } + return out, nil + } +} + // pricingRulesCustomerWall composes the pricing role guard with the customer // branch wall on the rule create (its body names the customer, its job names // one, and a body naming neither is dealer wide and refused) and on the -// calculation (its query names the customer). The unfiltered rule list is -// REFUSED by the route allowlist gate and never reaches these arms. +// calculation (its query names the customer, and its job names one through +// that job's customer). The unfiltered rule list is REFUSED by the route +// allowlist gate and never reaches these arms. func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) create := middleware.Compose(role, keyWall.CustomerBranch(pricingRuleCustomersOf(db))) - calculate := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", false))) + calculate := middleware.Compose(role, keyWall.CustomerBranch(calculateCustomersOf(db))) return func(next http.Handler) http.Handler { createHandler, calculateHandler, roleHandler := create(next), calculate(next), role(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index adae2e17..ce47c580 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -78,6 +78,26 @@ func TestKeyBranchPin_CustomerConfinedReads(t *testing.T) { f.refuseForeign(t, "GET", calc+B+"&quantity=1", "", nil) f.allow(t, "bound", "GET", calc+A+"&quantity=1", "", nil, http.StatusNotFound) f.parity(t, "GET", calc+B+"&quantity=1", "") + + // The calculation also resolves job_id through its job's customer, the + // same resolution the write carries (round 5 F-3): the pin's customer + // with the foreign customer's job is refused, not answered with the job + // rule that prices the foreign customer. + jobRule := func(job uuid.UUID) string { + return fmt.Sprintf(`{"name":"pin calc job %s","rule_type":"job_override","job_id":%q,"fixed_price_ten_thousandths":10000}`, + job.String()[:8], job.String()) + } + f.allow(t, "unbound", "POST", "/api/v1/pricing/rules", jobRule(f.jobB), nil, http.StatusCreated) + calcJob := "/api/v1/pricing/calculate?product_id=" + f.posProd.String() + "&quantity=1&customer_id=" + f.refuseForeign(t, "GET", calcJob+A+"&job_id="+f.jobB.String(), "", nil) + f.parity(t, "GET", calcJob+A+"&job_id="+f.jobB.String(), "") + status, buf := f.key(t, f.bound, "GET", calcJob+A+"&job_id="+f.jobA.String(), "", nil) + if status == http.StatusForbidden { + t.Errorf("calculate with the pin's own job was refused: %s", buf) + } + if status == http.StatusOK && !strings.Contains(string(buf), `"price_basis":"retail"`) { + t.Errorf("calculate with the pin's own job answered %s, want the module's own answer", buf) + } // The resolve preview answers a bare product with no match rather than a // 404; what matters is that the pin's customer reaches that answer and // the foreign one never reaches the module. From 1cf0c93a1f1da38e337b067418b947da9a90cb3f Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:29:42 -0700 Subject: [PATCH 23/74] security: a tax preview naming a ship to is confined to that ship to's customer Round 6 P3: POST /tax/preview with the foreign branch's ship_to_id and no customer passed the wall (the resolver read customer_id only) and answered a rate for a delivery the key's branch does not serve. The preview's resolver now resolves ship_to_id through customer_ship_tos.customer_id and holds that customer to the pin beside the body's customer_id; a preview naming neither stays a plain rate calculation. Signed-off-by: colton --- .../app/serve/wire_customer_branch.go | 35 +++++++++++++++---- .../app/serve/wire_key_branch_allow_test.go | 21 +++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 1edbe4eb..5b617610 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -90,21 +90,42 @@ func accountCustomerPathOf() func(ctx context.Context, r *http.Request) ([]uuid. } } -// previewCustomerOf resolves the customer the tax preview's body names. A -// preview that names no customer is a plain rate calculation and passes; one -// that names a customer reads that customer's exemptions and is confined. -func previewCustomerOf() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { +// previewCustomerOf resolves the customers the tax preview's body names: its +// customer_id, and the customer of the ship to its ship_to_id names (round 6 +// P3: a preview quoting the foreign branch's ship to prices that customer's +// delivery). A preview that names no customer and no ship to is a plain rate +// calculation and passes; a ship to that does not exist names nothing and the +// handler ignores the field. +func previewCustomerOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { var body struct { CustomerID string `json:"customer_id"` + ShipToID string `json:"ship_to_id"` } if err := middleware.DecodeOneJSONBody(r, &body); err != nil { return nil, err } + var out []uuid.UUID if id, err := uuid.Parse(body.CustomerID); err == nil { - return []uuid.UUID{id}, nil + out = append(out, id) } - return nil, nil + if raw := body.ShipToID; raw != "" { + if shipTo, err := uuid.Parse(raw); err == nil { + if db == nil { + return nil, errors.New("tax preview ship to lookup: no database") + } + var customer uuid.UUID + err := db.GetExecutor(ctx).QueryRow(ctx, + `SELECT customer_id FROM customer_ship_tos WHERE id = $1`, shipTo).Scan(&customer) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err == nil { + out = append(out, customer) + } + } + } + return out, nil } } @@ -332,7 +353,7 @@ func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles . create := middleware.Compose(role, keyWall.CustomerBodyBranch()) remove := middleware.Compose(role, keyWall.CustomerRowBranch(exemptionCustomerOf(db))) read := middleware.Compose(role, keyWall.CustomerBranch(exemptionCustomerPathOf())) - preview := middleware.Compose(role, keyWall.CustomerBranch(previewCustomerOf())) + preview := middleware.Compose(role, keyWall.CustomerBranch(previewCustomerOf(db))) return func(next http.Handler) http.Handler { createHandler, removeHandler, readHandler, previewHandler, roleHandler := create(next), remove(next), read(next), preview(next), role(next) diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index ce47c580..4a35ccbe 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -56,6 +56,27 @@ func TestKeyBranchPin_CustomerConfinedReads(t *testing.T) { } f.parity(t, "POST", "/api/v1/tax/preview", preview(B)) + // A preview that names only a ship to is confined through that ship to's + // customer (round 6 P3): the foreign branch's ship to is refused, the + // pin's own customer's ship to passes, and a preview naming neither + // stays a plain rate calculation. + shipTo := func(id uuid.UUID) string { + return fmt.Sprintf(`{"ship_to_id":%q,"document_type":"SalesInvoice","lines":[{"amount":100}]}`, id.String()) + } + shipA, shipB := uuid.New(), uuid.New() + for _, s := range []struct{ id, customer uuid.UUID }{{shipA, f.custID}, {shipB, f.custB}} { + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO customer_ship_tos (id, customer_id, code, name, line1) VALUES ($1, $2, 'pin', 'pin ship to', 'pin')`, + s.id, s.customer); err != nil { + t.Fatalf("seed ship to: %v", err) + } + } + f.refuseForeign(t, "POST", "/api/v1/tax/preview", shipTo(shipB), nil) + if status, buf := f.key(t, f.bound, "POST", "/api/v1/tax/preview", shipTo(shipA), nil); status == http.StatusForbidden { + t.Errorf("tax preview of the pin's customer's ship to was refused: %s", buf) + } + f.parity(t, "POST", "/api/v1/tax/preview", shipTo(shipB)) + // The category rule list is confined by its customer_id query; the // unfiltered list is refused (it carries every customer's rules). q := "/api/v1/pricing/category-rules?customer_id=" From c8621976ee5933f58f9da5ad22546be1c9dbc5fb Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:31:50 -0700 Subject: [PATCH 24/74] test: the gate's nil resolver arm, the unnamed refusals and the location path probe Round 5 P3: the gate fails closed when its mux is not a pattern resolver (mutant G5 survived every test; the pin drives a plain handler mux and expects the refusal, and goes red under the mutant); the twenty REFUSED routes no independent test named (staff id and modules, reporting saved and schedules, AP invoice read and approve, bankrecon session read, account create, complete and unmatch, GL reopen, post, reverse, void and account write) join DealerWideReads by name with their parity legs; and PUT /locations/{id} writing path is probed and pinned: the path is a materialized breadcrumb with no enforcement, the branch comes from the parent at insert (the denorm trigger fires on parent_id and type, neither writable by the route), and no branch scoped read resolves a branch from a path, so a path naming the foreign branch's breadcrumb cannot move a row under it. Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 40 +++++++++++++++++ core/pkg/middleware/keybranch_test.go | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index efbfb46a..cdbc9f9c 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -621,6 +621,26 @@ func TestKeyBranchPin_LocationWrites(t *testing.T) { f.putLocation(t, "unbound", f.yardB, "pin free edit", http.StatusOK) f.putLocation(t, "user", f.yardB, "pin user edit", http.StatusOK) + // PUT /locations/{id} writes `path`, a materialized breadcrumb with no + // enforcement (round 5 P3-4 probed whether it can move a row under + // another branch's tree). It cannot: the row's branch comes from its + // parent at insert (the denorm trigger fires on parent_id and type, + // neither writable here) and no branch scoped read resolves a branch from + // path, so a path naming the foreign branch's breadcrumb leaves the row + // exactly where it was. This leg pins that. + revA, codeA, _ := f.locState(t, f.yardA) + f.allow(t, "bound", "PUT", "/api/v1/locations/"+f.yardA.String(), + fmt.Sprintf(`{"code":%q,"path":%q,"name":"pin path probe"}`, codeA, B+"/yards/under-B"), + map[string]string{"If-Match": fmt.Sprintf(`"%d"`, revA)}, http.StatusOK) + var pathBranch uuid.UUID + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT branch_id FROM locations WHERE id = $1`, f.yardA).Scan(&pathBranch); err != nil { + t.Fatal(err) + } + if pathBranch != f.branchA { + t.Errorf("a path write moved the own yard to branch %s, want it to stay its own", pathBranch) + } + // DELETE /locations/{id}: the foreign yard is refused and stays active. f.refuseForeign(t, "DELETE", "/api/v1/locations/"+f.yardB.String(), "", map[string]string{"If-Match": `"2"`}) if _, _, active := f.locState(t, f.yardB); !active { @@ -889,25 +909,45 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { {"POST", "/api/v1/gl/accounts", `{"name":"pin wall probe"}`}, {"POST", "/api/v1/gl/journal-entries", `{"lines":[]}`}, {"POST", "/api/v1/gl/fiscal-periods/" + uuid.NewString() + "/close", ""}, + {"POST", "/api/v1/gl/fiscal-periods/" + uuid.NewString() + "/reopen", ""}, + {"POST", "/api/v1/gl/journal-entries/" + uuid.NewString() + "/post", ""}, + {"POST", "/api/v1/gl/journal-entries/" + uuid.NewString() + "/reverse", ""}, + {"POST", "/api/v1/gl/journal-entries/" + uuid.NewString() + "/void", ""}, + {"PUT", "/api/v1/gl/accounts/" + uuid.NewString(), `{"name":"pin wall probe"}`}, {"GET", "/api/v1/ap/invoices", ""}, + {"GET", "/api/v1/ap/invoices/" + uuid.NewString(), ""}, {"GET", "/api/v1/ap/payments", ""}, {"GET", "/api/v1/ap/aging", ""}, {"POST", "/api/v1/ap/invoices", `{"vendor_id":"` + uuid.NewString() + `"}`}, {"POST", "/api/v1/ap/payments", `{"invoice_id":"` + uuid.NewString() + `"}`}, + {"POST", "/api/v1/ap/invoices/" + uuid.NewString() + "/approve", ""}, {"GET", "/api/v1/bankrecon/accounts", ""}, {"GET", "/api/v1/bankrecon/sessions", ""}, {"POST", "/api/v1/bankrecon/import", ""}, {"POST", "/api/v1/bankrecon/match", `{"bank_tx_id":"` + uuid.NewString() + `"}`}, {"POST", "/api/v1/bankrecon/sessions", `{"bank_account_id":"` + uuid.NewString() + `"}`}, + {"GET", "/api/v1/bankrecon/sessions/" + uuid.NewString(), ""}, + {"POST", "/api/v1/bankrecon/accounts", `{"name":"pin"}`}, + {"POST", "/api/v1/bankrecon/sessions/" + uuid.NewString() + "/complete", ""}, + {"POST", "/api/v1/bankrecon/unmatch", `{"match_id":"` + uuid.NewString() + `"}`}, {"GET", "/api/v1/sales-team", ""}, {"GET", "/api/v1/sales-team/" + uuid.NewString(), ""}, {"GET", "/api/v1/users", ""}, {"GET", "/api/v1/admin/staff", ""}, + {"GET", "/api/v1/admin/staff/" + uuid.NewString(), ""}, {"POST", "/api/v1/admin/staff", `{"name":"pin"}`}, + {"POST", "/api/v1/admin/staff/" + uuid.NewString() + "/modules", `{"modules":["pos"]}`}, + {"PUT", "/api/v1/admin/staff/" + uuid.NewString(), `{"name":"pin"}`}, + {"DELETE", "/api/v1/admin/staff/" + uuid.NewString() + "/modules/m", ""}, {"POST", "/api/v1/market-indices/" + idx + "/refresh", ""}, {"POST", "/api/v1/market-indices/" + idx + "/refresh/preview", `{"new_value":120}`}, {"GET", "/api/v1/pricing/rules", ""}, {"GET", "/api/v1/pricing/matrix", ""}, + {"GET", "/api/v1/reporting/saved/" + uuid.NewString(), ""}, + {"POST", "/api/v1/reporting/save", `{"name":"pin"}`}, + {"PUT", "/api/v1/reporting/saved/" + uuid.NewString(), `{"name":"pin"}`}, + {"DELETE", "/api/v1/reporting/saved/" + uuid.NewString(), ""}, + {"DELETE", "/api/v1/reporting/schedules/" + uuid.NewString(), ""}, } { f.refuseForeign(t, c.method, c.path, c.body, nil) f.parity(t, c.method, c.path, c.body) diff --git a/core/pkg/middleware/keybranch_test.go b/core/pkg/middleware/keybranch_test.go index 1b21b2f2..388014ae 100644 --- a/core/pkg/middleware/keybranch_test.go +++ b/core/pkg/middleware/keybranch_test.go @@ -305,3 +305,48 @@ func TestKeyBranchWall_CustomerBranch(t *testing.T) { t.Errorf("missing row: %d ran %v, want 200 and run", code, ran) } } + +// TestKeyBranchRouteGateFailsClosedWithoutResolver pins the gate's nil +// resolver arm (round 5 P3-2, mutant G5): a mux the gate cannot resolve a +// registered pattern through (any http.Handler that is not the pattern +// resolver) is a surface the allowlist cannot speak about, so a branch bound +// key is refused everything on it, the audit row is written and the handler +// never runs, while a request with no pin (a user, an unbound key) keeps the +// handler's own answer. +func TestKeyBranchRouteGateFailsClosedWithoutResolver(t *testing.T) { + ran := false + inner := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ran = true + rw.WriteHeader(http.StatusOK) + }) + mux := http.NewServeMux() + mux.Handle("GET /api/v1/branches", inner) + // The opaque wrapper hides the mux's patternResolver surface: the gate + // cannot resolve, which must refuse, never pass. + opaque := http.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + mux.ServeHTTP(rw, r) + })) + auditor := &pinAuditor{} + pin := uuid.New() + send := func(pin *uuid.UUID) int { + t.Helper() + ran = false + req := httptest.NewRequest(http.MethodGet, "/api/v1/branches", nil) + ctx := WithKeyID(req.Context(), "key-test") + if pin != nil { + ctx = branchctx.WithKeyBranch(ctx, *pin) + } + rec := httptest.NewRecorder() + KeyBranchRouteGate(opaque, auditor)(inner).ServeHTTP(rec, req.WithContext(ctx)) + return rec.Code + } + if code := send(&pin); code != http.StatusForbidden || ran { + t.Errorf("bound key through an unresolvable mux: %d ran %v, want 403 and not run", code, ran) + } + if len(auditor.rows) != 1 { + t.Errorf("refusal audit rows: %v, want one", auditor.rows) + } + if code := send(nil); code != http.StatusOK || !ran { + t.Errorf("no pin through an unresolvable mux: %d ran %v, want 200 and run", code, ran) + } +} From c0264fe4ed3405e0860cf21fe42a8ec4302d92f5 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:39:03 -0700 Subject: [PATCH 25/74] security: a branch bound key reads dealer wide reference data and never writes it The principal's ruling (FROM-PRINCIPAL 37 and 38): every STATED LIMIT write is refused to a branch bound key, the reads stay stated limits; the dealer wide catalogue writes the branch middleware cannot confine (products, PIM content, margins, dimensions, lead time, kit components, unit sets, payment terms, charge codes, the reorder target refresh) are dealer wide writes and are refused with their reads stated; and the driver routes are refused outright, reads included (PD-3: driver rows carry personal data and no branch column), while the vehicle reads stay stated limits. The class split in the table: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118 of the 345 machine key reachable routes. One A/B leg per write family drives every refused family with its audit row beside the unbound key's own module answer, and the margin write proves the refusal is the only thing that kept the dealer wide target margin unwritten. Signed-off-by: colton --- .../app/serve/wire_key_branch_allow_test.go | 140 ++++++++++++++-- .../app/serve/wire_key_branch_pin_test.go | 61 +++++++ core/pkg/middleware/keybranch_allow.go | 152 +++++++++--------- 3 files changed, 268 insertions(+), 85 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index 4a35ccbe..254caa7b 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -281,19 +281,20 @@ func TestKeyBranchPin_StatedLimits(t *testing.T) { f.parity(t, "GET", "/api/v1/branches", "") f.parity(t, "GET", "/api/v1/branches/"+B, "") - // The index metadata write and the history read are dealer wide reference - // data: the bound key meets the module's own answer, never a refusal. - for _, c := range []struct{ method, path string }{ - {"PUT", "/api/v1/market-indices/" + idx}, - {"GET", "/api/v1/market-indices/" + idx + "/history"}, + // The index history read stays dealer wide reference data; the index + // metadata write is refused with every other stated limit write (the + // stated limit split: it feeds every branch's escalators). + f.refuseForeign(t, "PUT", "/api/v1/market-indices/"+idx, `{"name":"pin"}`, nil) + for _, path := range []string{ + "/api/v1/market-indices/" + idx + "/history", } { - sb, bb := f.key(t, f.bound, c.method, c.path, "", nil) - sk, _ := f.key(t, f.unbound, c.method, c.path, "", nil) + sb, bb := f.key(t, f.bound, "GET", path, "", nil) + sk, _ := f.key(t, f.unbound, "GET", path, "", nil) if sb == http.StatusForbidden { - t.Errorf("bound key %s %s: refused %s, want the module's own answer", c.method, c.path, bb) + t.Errorf("bound key GET %s: refused %s, want the module's own answer", path, bb) } if sb != sk { - t.Errorf("%s %s: bound %d, unbound %d, want the same answer", c.method, c.path, sb, sk) + t.Errorf("GET %s: bound %d, unbound %d, want the same answer", path, sb, sk) } } } @@ -464,3 +465,124 @@ func allowlistWant(refused bool) string { } return "200 (the stub answers)" } + +// TestKeyBranchPin_StatedLimitWritesRefused holds the stated limit split (the +// lead's ruling on item 37, with item 38 and PD-3): a branch bound key reads +// the dealer wide reference data and is refused every write on it, each +// family named, with its audit row, while the unbound key keeps the write +// (never the gate's 403; the module's own answer). The driver routes are +// refused reads included (PD-3: personal data, no branch column); the vehicle +// reads stay stated limits. +func TestKeyBranchPin_StatedLimitWritesRefused(t *testing.T) { + f := newKeyPinFixture(t) + idx, prod := uuid.NewString(), f.posProd.String() + drv, veh, partner, rfc, cat := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + for _, c := range []struct{ method, path, body string }{ + {"PUT", "/api/v1/admin/settings/ai", `{"provider":"avalara"}`}, + {"DELETE", "/api/v1/admin/settings/ai", ""}, + {"PUT", "/api/v1/admin/settings/routing", `{"provider":"avalara"}`}, + {"DELETE", "/api/v1/admin/settings/routing", ""}, + {"PUT", "/api/v1/admin/modules/" + uuid.NewString(), `{"is_enabled":false}`}, + {"POST", "/api/v1/apps/millwork/disable", ""}, + {"POST", "/api/v1/apps/millwork/enable", ""}, + {"PUT", "/api/v1/matching/config", `{"price_tolerance_pct":"1"}`}, + {"POST", "/api/v1/edi/partners", `{"partner_id":"PIN-EDI","name":"pin"}`}, + {"PUT", "/api/v1/edi/partners/" + partner, `{"name":"pin"}`}, + {"DELETE", "/api/v1/edi/partners/" + partner, ""}, + {"POST", "/api/v1/edi/partners/" + partner + "/import-catalog", `{}`}, + {"POST", "/api/v1/vendors", `{"name":"pin vendor","email":"pin@example.com"}`}, + {"POST", "/api/v1/units", `{"code":"PIN","name":"pin unit"}`}, + {"PUT", "/api/v1/units/PIN", `{"name":"pin unit"}`}, + {"POST", "/api/v1/pricing/categories", `{"name":"pin cat","slug":"pin-cat"}`}, + {"PUT", "/api/v1/pricing/categories/" + cat, `{"name":"pin cat"}`}, + {"POST", "/api/v1/pricing/rebates/programs", `{"name":"pin rebate"}`}, + {"POST", "/api/v1/millwork/options", `{"name":"pin option"}`}, + {"POST", "/api/v1/governance/rfcs", `{"title":"pin rfc"}`}, + {"PUT", "/api/v1/governance/rfcs/" + rfc, `{"title":"pin rfc"}`}, + {"POST", "/api/v1/governance/rfcs/" + rfc + "/transitions", `{"transition":"review"}`}, + {"PUT", "/api/v1/market-indices/" + idx, `{"name":"pin index"}`}, + {"POST", "/api/v1/delivery/drivers", `{"name":"pin driver","license_number":"PIN-1"}`}, + {"GET", "/api/v1/delivery/drivers", ""}, + {"GET", "/api/v1/delivery/drivers/" + drv, ""}, + {"PUT", "/api/v1/delivery/drivers/" + drv, `{"name":"pin driver"}`}, + {"DELETE", "/api/v1/delivery/drivers/" + drv, ""}, + {"POST", "/api/v1/delivery/drivers/" + drv + "/photo", ``}, + {"POST", "/api/v1/delivery/vehicles", `{"name":"pin truck","license_plate":"PIN-1"}`}, + {"PUT", "/api/v1/delivery/vehicles/" + veh, `{"name":"pin truck"}`}, + {"POST", "/api/v1/delivery/vehicles/" + veh + "/photo", ``}, + {"DELETE", "/api/v1/delivery/vehicles/" + veh, ""}, + {"POST", "/api/v1/products", `{"sku":"PIN-PROD-1","description":"pin"}`}, + {"PATCH", "/api/v1/products/" + prod + "/dimensions", `{}`}, + {"PATCH", "/api/v1/products/" + prod + "/lead-time", `{"lead_time_days":2,"revision":1}`}, + {"PUT", "/api/v1/products/" + prod + "/kit-components", `[]`}, + {"PUT", "/api/v1/products/" + prod + "/units", `[]`}, + {"PUT", "/api/v1/products/" + prod + "/pim/content", `{"description":"pin"}`}, + {"DELETE", "/api/v1/products/" + prod + "/pim/collateral/" + uuid.NewString(), ""}, + {"DELETE", "/api/v1/products/" + prod + "/pim/media/" + uuid.NewString(), ""}, + {"PATCH", "/api/v1/products/" + prod + "/pim/media/" + uuid.NewString() + "/primary", ``}, + {"POST", "/api/v1/products/" + prod + "/pim/generate/collateral", `{}`}, + {"POST", "/api/v1/products/" + prod + "/pim/generate/descriptions", `{}`}, + {"POST", "/api/v1/products/" + prod + "/pim/generate/image", `{}`}, + {"POST", "/api/v1/products/" + prod + "/pim/generate/seo", `{}`}, + {"POST", "/api/v1/charge-codes", `{"code":"PIN-CC","description":"pin"}`}, + {"PUT", "/api/v1/charge-codes/" + uuid.NewString(), `{"code":"PIN-CC"}`}, + {"POST", "/api/v1/payment-terms", `{"kind":"net_days","days":30}`}, + {"PUT", "/api/v1/payment-terms/" + uuid.NewString(), `{"kind":"net_days","days":30}`}, + {"POST", "/api/v1/purchase-orders/refresh-reorder-targets", `{"dry_run":false}`}, + } { + f.refuseForeign(t, c.method, c.path, c.body, nil) + if sk, _ := f.key(t, f.unbound, c.method, c.path, c.body, nil); sk == http.StatusForbidden { + t.Errorf("unbound key %s %s: the gate's 403, want the module's own answer", c.method, c.path) + } + } + + // The written families prove nothing landed from the refused leg: the + // bound key's margin attempt leaves the product's target margin where it + // was, and the unbound key's same write sets it (the round 5 probe, both + // ways, with the revision the write demands). + var margin float64 + var revision int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COALESCE(target_margin, 0), revision FROM products WHERE id = $1`, f.posProd). + Scan(&margin, &revision); err != nil { + t.Fatal(err) + } + f.refuseForeign(t, "PATCH", "/api/v1/products/"+prod+"/margins", + fmt.Sprintf(`{"target_margin":0.5,"revision":%d}`, revision), nil) + var after float64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COALESCE(target_margin, 0) FROM products WHERE id = $1`, f.posProd).Scan(&after); err != nil { + t.Fatal(err) + } + if after != margin { + t.Errorf("refused margins write moved the dealer wide target margin %v to %v", margin, after) + } + if sk, buf := f.key(t, f.unbound, "PATCH", "/api/v1/products/"+prod+"/margins", + fmt.Sprintf(`{"target_margin":0.5,"revision":%d}`, revision), nil); sk != http.StatusOK { + t.Errorf("unbound margins write: %d %s, want 200", sk, buf) + } + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COALESCE(target_margin, 0) FROM products WHERE id = $1`, f.posProd).Scan(&after); err != nil { + t.Fatal(err) + } + if after != 0.5 { + t.Errorf("unbound margins write left target margin %v, want 0.5", after) + } + + // The reads that stay stated limits answer the bound key as everyone + // else: the catalogue, the charge codes, the payment terms and the + // vehicle list. + for _, path := range []string{ + "/api/v1/products", "/api/v1/products/" + prod, + "/api/v1/charge-codes", "/api/v1/payment-terms", "/api/v1/delivery/vehicles", + } { + sb, bb := f.key(t, f.bound, "GET", path, "", nil) + sk, _ := f.key(t, f.unbound, "GET", path, "", nil) + if sb == http.StatusForbidden { + t.Errorf("bound key GET %s: refused %s, want the stated limit read", path, bb) + } + if sb != sk { + t.Errorf("GET %s: bound %d, unbound %d, want the same answer", path, sb, sk) + } + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index cdbc9f9c..adc249f4 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -26,24 +26,35 @@ import ( "github.com/gablelbm/gable/internal/ap" "github.com/gablelbm/gable/internal/app/orderwire" "github.com/gablelbm/gable/internal/bankrecon" + "github.com/gablelbm/gable/internal/chargecode" "github.com/gablelbm/gable/internal/config" "github.com/gablelbm/gable/internal/customer" + "github.com/gablelbm/gable/internal/delivery" + "github.com/gablelbm/gable/internal/edi" "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/governance" glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/internal/inventory" "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/location" + "github.com/gablelbm/gable/internal/matching" + "github.com/gablelbm/gable/internal/millwork" "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/pim" "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/pricing" "github.com/gablelbm/gable/internal/product" + "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/internal/quote" "github.com/gablelbm/gable/internal/reporting" "github.com/gablelbm/gable/internal/salesteam" "github.com/gablelbm/gable/internal/tax" "github.com/gablelbm/gable/internal/techadmin" "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/internal/unit" + "github.com/gablelbm/gable/internal/vendor" + "github.com/gablelbm/gable/pkg/apps" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" @@ -106,6 +117,12 @@ var pinKeyScopes = []string{ "gl:read", "gl:write", "ap:read", "ap:write", "bankrecon:read", "bankrecon:write", "sales-team:read", "market-indices:read", "market-indices:write", "tax:read", "tax:write", "pricing:read", "pricing:write", "pos:read", "pos:write", "accounts:read", "ar:read", + "products:read", "products:write", "charge-codes:read", "charge-codes:write", + "payment-terms:read", "payment-terms:write", "purchase-orders:read", "purchase-orders:write", + "edi:read", "edi:write", "vendors:read", "vendors:write", "units:read", "units:write", + "millwork:read", "millwork:write", "governance:read", "governance:write", + "matching:read", "matching:write", "delivery:read", "delivery:write", + "apps:read", "apps:write", "admin:settings", "admin:modules", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -374,7 +391,51 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithAuditLog(audit.NewLogger(db)) pos.NewHandler(posSvc).RegisterRoutes(mux, wall.scoped("admin", "owner", "cashier")) + // The dealer wide catalogue and configuration modules exactly as serve + // registers them: products, PIM, the unit catalogue, the payment terms on + // the customer handler, vendors, EDI partners, charge codes, rebate + // programs, market indices, millwork, governance, the delivery fleet, the + // matching config, the techadmin settings and app switches, and the + // purchase orders (the reorder refresh among them). + wall.products(mux, product.NewHandler(product.NewService(product.NewRepository(db)))) + pim.NewHandler(pim.NewService(pim.NewRepository(db), productSvc)).RegisterRoutes(mux, wall.scoped("admin", "owner")) + unit.NewHandler(unit.NewService(unit.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db)). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales", "warehouse", "finance", "cashier", "purchasing"), + middleware.RequireRole("admin", "owner")) + wall.customers(mux, customerSvc.WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db)) + vendor.NewHandler(vendor.NewService(vendor.NewRepository(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "purchasing")) + edi.NewEDIHandler(edi.NewEDIRepository(db), edi.NewBuyingGroupService(slog.Default()), edi.NewService("", slog.Default())). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + wall.chargeCodes(mux, chargecode.NewService(chargecode.NewRepository(db))) + pricing.NewRebateHandler(pricing.NewRebateService(pricing.NewRebateRepository(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + pricing.NewEscalatorHandler(pricing.NewEscalatorService(pricing.NewEscalatorRepository(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + millwork.NewHandler(millwork.NewService(millwork.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + governance.NewHandler(governance.NewService(governance.NewRepository(db), governance.NewTemplateAIProvider()). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAuditLog(audit.NewLogger(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + wall.delivery(mux, delivery.NewHandler(delivery.NewService(delivery.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithAudit(audit.NewLogger(db))), + "admin", "owner", "warehouse", "driver") + wall.matching(mux, matching.NewService(db, matching.NewRepository(db), nil, nil, slog.Default())) + apps.NewHandler(apps.NewRegistry(db, slog.Default()).WithAudit(audit.NewLogger(db))). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + poProductSvc := product.NewService(product.NewRepository(db)) + poRecSvc := purchase_order.NewRecommendationService(purchase_order.NewRepository(db), + inventory.NewService(inventory.NewRepository(db)), poProductSvc, + vendor.NewService(vendor.NewRepository(db))). + WithVelocityRepo(purchase_order.NewVelocityRepository(db)) + poSvc := purchase_order.NewService(purchase_order.NewRepository(db), db, nil, nil, poProductSvc, nil). + WithVelocityRepo(purchase_order.NewVelocityRepository(db)) + wall.purchaseOrders(mux, purchase_order.NewHandler(poSvc, poRecSvc)) + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) + techadmin.NewHandler(keys).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) mint := func(name string, branch *uuid.UUID) string { t.Helper() raw, _, err := keys.GenerateKey(ctx, name, pinKeyScopes, branch) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index ec355593..03f2ca9c 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -86,6 +86,9 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "DELETE /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, "DELETE /api/v1/tax/exemptions/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, "DELETE /api/v1/users/{sub}/branches/{branch_id}": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "GET /api/v1/accounts/{id}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/branches/{id}/users": {KeyBranchClassConfined, "NamedBranch(id); TestKeyBranchPin_LocationReads"}, "GET /api/v1/pricing/calculate": {KeyBranchClassConfined, "CustomerBranch(query customer_id, and job_id to projects.customer_id); TestKeyBranchPin_CustomerConfinedReads"}, "GET /api/v1/pricing/category-rules": {KeyBranchClassConfined, "CustomerBranch(query customer_id, required); TestKeyBranchPin_CustomerConfinedReads"}, @@ -108,7 +111,14 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/locations/{id}": {KeyBranchClassConfined, "LocationRowBranch; TestKeyBranchPin_LocationWrites"}, "PUT /api/v1/pricing/category-rules/{id}": {KeyBranchClassConfined, "CustomerRowBranch; TestKeyBranchPin_CustomerConfinedWrites"}, "PUT /api/v1/users/{sub}/home-branch": {KeyBranchClassConfined, "BodyBranch or NamedBranch(branch_id); TestKeyBranchPin_UserGrants"}, + "DELETE /api/v1/admin/settings/ai": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, + "DELETE /api/v1/admin/settings/routing": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, "DELETE /api/v1/admin/staff/{id}/modules/{module_id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "DELETE /api/v1/delivery/drivers/{id}": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, + "DELETE /api/v1/delivery/vehicles/{id}": {KeyBranchClassRefused, "the fleet is dealer wide, no branch column; the reads stay stated limits"}, + "DELETE /api/v1/edi/partners/{id}": {KeyBranchClassRefused, "a dealer wide trading partner write (raw transport configuration); the reads stay stated limits"}, + "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassRefused, "product content is dealer wide, no branch column; the reads stay stated limits"}, + "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassRefused, "product content is dealer wide, no branch column; the reads stay stated limits"}, "DELETE /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, "DELETE /api/v1/reporting/schedules/{id}": {KeyBranchClassRefused, "reports span every branch"}, "GET /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, @@ -117,9 +127,12 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables span every branch"}, "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ar/reconciliation": {KeyBranchClassRefused, "the reconciliation reads every branch's books; its ledger half carries no branch fact"}, "GET /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "GET /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "GET /api/v1/bankrecon/sessions/{id}": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, + "GET /api/v1/delivery/drivers": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, + "GET /api/v1/delivery/drivers/{id}": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, "GET /api/v1/events": {KeyBranchClassRefused, "the feed carries every branch events"}, "GET /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, "GET /api/v1/gl/balance-sheet": {KeyBranchClassRefused, "the books span every branch"}, @@ -143,12 +156,18 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/sales-team": {KeyBranchClassRefused, "the sales team roster spans every branch"}, "GET /api/v1/sales-team/{id}": {KeyBranchClassRefused, "the sales team roster spans every branch"}, "GET /api/v1/users": {KeyBranchClassRefused, "the known users list spans every branch"}, + "PATCH /api/v1/products/{id}/dimensions": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, + "PATCH /api/v1/products/{id}/lead-time": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, + "PATCH /api/v1/products/{id}/margins": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, + "PATCH /api/v1/products/{id}/pim/media/{mediaId}/primary": {KeyBranchClassRefused, "product content is dealer wide, no branch column; the reads stay stated limits"}, "POST /api/v1/admin/exposure-scan": {KeyBranchClassRefused, "the scan re-checks every branch quotes"}, "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, "POST /api/v1/ap/invoices/{id}/approve": {KeyBranchClassRefused, "payables span every branch"}, "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/apps/{key}/disable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, + "POST /api/v1/apps/{key}/enable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, "POST /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "POST /api/v1/bankrecon/import": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "POST /api/v1/bankrecon/match": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, @@ -156,6 +175,13 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/bankrecon/sessions/{id}/complete": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "POST /api/v1/bankrecon/unmatch": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "POST /api/v1/branches": {KeyBranchClassRefused, "the branch directory create is outside every pin"}, + "POST /api/v1/charge-codes": {KeyBranchClassRefused, "charge codes carry a revenue account and no branch column; the reads stay stated limits"}, + "POST /api/v1/delivery/drivers": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, + "POST /api/v1/delivery/drivers/{id}/photo": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, + "POST /api/v1/delivery/vehicles": {KeyBranchClassRefused, "the fleet is dealer wide, no branch column; the reads stay stated limits"}, + "POST /api/v1/delivery/vehicles/{id}/photo": {KeyBranchClassRefused, "the fleet is dealer wide, no branch column; the reads stay stated limits"}, + "POST /api/v1/edi/partners": {KeyBranchClassRefused, "a dealer wide trading partner write (raw transport configuration); the reads stay stated limits"}, + "POST /api/v1/edi/partners/{id}/import-catalog": {KeyBranchClassRefused, "the catalog import rewrites the vendor map for every branch"}, "POST /api/v1/gl/accounts": {KeyBranchClassRefused, "the books span every branch"}, "POST /api/v1/gl/fiscal-periods/{id}/close": {KeyBranchClassRefused, "the books span every branch"}, "POST /api/v1/gl/fiscal-periods/{id}/reopen": {KeyBranchClassRefused, "the books span every branch"}, @@ -163,32 +189,57 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/gl/journal-entries/{id}/post": {KeyBranchClassRefused, "the books span every branch"}, "POST /api/v1/gl/journal-entries/{id}/reverse": {KeyBranchClassRefused, "the books span every branch"}, "POST /api/v1/gl/journal-entries/{id}/void": {KeyBranchClassRefused, "the books span every branch"}, + "POST /api/v1/governance/rfcs": {KeyBranchClassRefused, "the rfc workflow carries no branch fact; the reads stay stated limits"}, + "POST /api/v1/governance/rfcs/{id}/transitions": {KeyBranchClassRefused, "the rfc workflow carries no branch fact; the reads stay stated limits"}, "POST /api/v1/market-indices/{id}/refresh": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, "POST /api/v1/market-indices/{id}/refresh/preview": {KeyBranchClassRefused, "the index refresh re-checks every branch quotes"}, + "POST /api/v1/millwork/options": {KeyBranchClassRefused, "the option catalogue is dealer wide; the reads stay stated limits"}, + "POST /api/v1/payment-terms": {KeyBranchClassRefused, "payment terms are dealer wide, no branch column; the reads stay stated limits"}, + "POST /api/v1/pricing/categories": {KeyBranchClassRefused, "the product category tree is dealer wide; the reads stay stated limits"}, + "POST /api/v1/pricing/rebates/programs": {KeyBranchClassRefused, "a vendor rebate program is a dealer wide financial definition; the reads stay stated limits"}, + "POST /api/v1/products": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, + "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassRefused, "product content is dealer wide and the generate routes spend the dealer AI credit"}, + "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassRefused, "product content is dealer wide and the generate routes spend the dealer AI credit"}, + "POST /api/v1/products/{id}/pim/generate/image": {KeyBranchClassRefused, "product content is dealer wide and the generate routes spend the dealer AI credit"}, + "POST /api/v1/products/{id}/pim/generate/seo": {KeyBranchClassRefused, "product content is dealer wide and the generate routes spend the dealer AI credit"}, + "POST /api/v1/purchase-orders/refresh-reorder-targets": {KeyBranchClassRefused, "the reorder refresh rewrites reorder targets for every branch (dry_run or not)"}, "POST /api/v1/reporting/builder/export": {KeyBranchClassRefused, "reports span every branch"}, "POST /api/v1/reporting/builder/preview": {KeyBranchClassRefused, "reports span every branch"}, "POST /api/v1/reporting/save": {KeyBranchClassRefused, "reports span every branch"}, "POST /api/v1/reporting/saved/{id}/run": {KeyBranchClassRefused, "reports span every branch"}, "POST /api/v1/reporting/schedules": {KeyBranchClassRefused, "reports span every branch"}, + "POST /api/v1/units": {KeyBranchClassRefused, "the unit catalogue is dealer wide; the reads stay stated limits"}, + "POST /api/v1/vendors": {KeyBranchClassRefused, "the vendor master is dealer wide; the read stays a stated limit"}, + "PUT /api/v1/admin/modules/{id}": {KeyBranchClassRefused, "a module kill switch is a dealer wide write; the read stays a stated limit"}, + "PUT /api/v1/admin/settings/ai": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, + "PUT /api/v1/admin/settings/routing": {KeyBranchClassRefused, "a dealer wide configuration write (the provider key and base URL); the read stays a stated limit"}, "PUT /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, + "PUT /api/v1/charge-codes/{id}": {KeyBranchClassRefused, "charge codes carry a revenue account and no branch column; the reads stay stated limits"}, + "PUT /api/v1/delivery/drivers/{id}": {KeyBranchClassRefused, "driver rows carry personal data and no branch column"}, + "PUT /api/v1/delivery/vehicles/{id}": {KeyBranchClassRefused, "the fleet is dealer wide, no branch column; the reads stay stated limits"}, + "PUT /api/v1/edi/partners/{id}": {KeyBranchClassRefused, "a dealer wide trading partner write (raw transport configuration); the reads stay stated limits"}, "PUT /api/v1/gl/accounts/{id}": {KeyBranchClassRefused, "the books span every branch"}, + "PUT /api/v1/governance/rfcs/{id}": {KeyBranchClassRefused, "the rfc workflow carries no branch fact; the reads stay stated limits"}, + "PUT /api/v1/market-indices/{id}": {KeyBranchClassRefused, "index metadata feeds every branch escalators; the reads stay stated limits"}, + "PUT /api/v1/matching/config": {KeyBranchClassRefused, "match tolerances are a dealer wide financial control; the read stays a stated limit"}, + "PUT /api/v1/payment-terms/{id}": {KeyBranchClassRefused, "payment terms are dealer wide, no branch column; the reads stay stated limits"}, + "PUT /api/v1/pricing/categories/{id}": {KeyBranchClassRefused, "the product category tree is dealer wide; the reads stay stated limits"}, + "PUT /api/v1/products/{id}/kit-components": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, + "PUT /api/v1/products/{id}/pim/content": {KeyBranchClassRefused, "product content is dealer wide, no branch column; the reads stay stated limits"}, + "PUT /api/v1/products/{id}/units": {KeyBranchClassRefused, "the product catalogue is dealer wide, no branch column; the reads stay stated limits"}, "PUT /api/v1/reporting/saved/{id}": {KeyBranchClassRefused, "reports span every branch"}, - "DELETE /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "DELETE /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "DELETE /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "DELETE /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "DELETE /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, + "PUT /api/v1/units/{code}": {KeyBranchClassRefused, "the unit catalogue is dealer wide; the reads stay stated limits"}, "GET /api/v1/admin/modules": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, "GET /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, "GET /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, "GET /api/v1/apps": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, "GET /api/v1/branches": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, "GET /api/v1/branches/{id}": {KeyBranchClassStatedLimit, "branch directory reference data (the PR 39 decision)"}, + "GET /api/v1/charge-codes": {KeyBranchClassStatedLimit, "the dealer wide charge code vocabulary"}, + "GET /api/v1/charge-codes/{id}": {KeyBranchClassStatedLimit, "the dealer wide charge code vocabulary"}, "GET /api/v1/configurator/options": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, "GET /api/v1/configurator/presets": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, "GET /api/v1/configurator/rules": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "GET /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "GET /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, "GET /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, "GET /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, "GET /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, @@ -196,67 +247,46 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/edi/partners/{id}/catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, "GET /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, "GET /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, + "GET /api/v1/links/products/{id}": {KeyBranchClassStatedLimit, "the dealer wide product link"}, "GET /api/v1/market-indices": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, "GET /api/v1/market-indices/{id}/history": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, "GET /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, "GET /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, "GET /api/v1/millwork/options/{id}": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, + "GET /api/v1/payment-terms": {KeyBranchClassStatedLimit, "the dealer wide payment terms vocabulary"}, + "GET /api/v1/payment-terms/{id}": {KeyBranchClassStatedLimit, "the dealer wide payment terms vocabulary"}, + "GET /api/v1/pos/catalog": {KeyBranchClassStatedLimit, "the dealer wide product catalogue"}, + "GET /api/v1/pos/products/search": {KeyBranchClassStatedLimit, "the dealer wide product catalogue"}, "GET /api/v1/price_levels": {KeyBranchClassStatedLimit, "the dealer wide price level vocabulary"}, "GET /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, "GET /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, "GET /api/v1/pricing/rebates/programs/{id}": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, "GET /api/v1/pricing/rebates/programs/{id}/claims": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, + "GET /api/v1/products": {KeyBranchClassStatedLimit, "the dealer wide product catalogue (stock totals narrow by branch)"}, + "GET /api/v1/products/{id}": {KeyBranchClassStatedLimit, "the dealer wide product catalogue (stock totals narrow by branch)"}, + "GET /api/v1/products/{id}/detail": {KeyBranchClassStatedLimit, "the dealer wide product catalogue (stock totals narrow by branch)"}, + "GET /api/v1/products/{id}/kit-components": {KeyBranchClassStatedLimit, "the dealer wide product catalogue"}, + "GET /api/v1/products/{id}/pim/collateral": {KeyBranchClassStatedLimit, "the dealer wide product content"}, + "GET /api/v1/products/{id}/pim/content": {KeyBranchClassStatedLimit, "the dealer wide product content"}, + "GET /api/v1/products/{id}/pim/media": {KeyBranchClassStatedLimit, "the dealer wide product content"}, + "GET /api/v1/products/{id}/units": {KeyBranchClassStatedLimit, "the dealer wide product unit sets"}, "GET /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "GET /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "GET /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, "GET /api/v1/vendors/{id}": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, - "POST /api/v1/apps/{key}/disable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, - "POST /api/v1/apps/{key}/enable": {KeyBranchClassStatedLimit, "app enablement switches change dealer wide behaviour"}, "POST /api/v1/configurator/build-sku": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, "POST /api/v1/configurator/validate": {KeyBranchClassStatedLimit, "option catalog configuration, no branch fact"}, - "POST /api/v1/delivery/drivers": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/drivers/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/vehicles": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/delivery/vehicles/{id}/photo": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "POST /api/v1/edi/partners": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "POST /api/v1/edi/partners/{id}/import-catalog": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "POST /api/v1/governance/rfcs": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "POST /api/v1/governance/rfcs/{id}/transitions": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "POST /api/v1/millwork/options": {KeyBranchClassStatedLimit, "option catalog data, no branch fact"}, "POST /api/v1/parsing/upload": {KeyBranchClassStatedLimit, "the upload parses dealer wide product data"}, "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, - "POST /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, - "POST /api/v1/pricing/rebates/programs": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, - "POST /api/v1/units": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, - "POST /api/v1/vendors": {KeyBranchClassStatedLimit, "the dealer wide vendor master"}, "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, - "PUT /api/v1/admin/modules/{id}": {KeyBranchClassStatedLimit, "module kill switches change dealer wide behaviour"}, - "PUT /api/v1/admin/settings/ai": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "PUT /api/v1/admin/settings/routing": {KeyBranchClassStatedLimit, "dealer wide configuration"}, - "PUT /api/v1/delivery/drivers/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "PUT /api/v1/delivery/vehicles/{id}": {KeyBranchClassStatedLimit, "the fleet is dealer wide, no branch column"}, - "PUT /api/v1/edi/partners/{id}": {KeyBranchClassStatedLimit, "trading partners and catalogs, no branch fact"}, - "PUT /api/v1/governance/rfcs/{id}": {KeyBranchClassStatedLimit, "the rfc workflow carries no branch fact"}, - "PUT /api/v1/market-indices/{id}": {KeyBranchClassStatedLimit, "dealer wide index reference data"}, - "PUT /api/v1/matching/config": {KeyBranchClassStatedLimit, "match tolerances are dealer wide configuration"}, - "PUT /api/v1/pricing/categories/{id}": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, - "PUT /api/v1/units/{code}": {KeyBranchClassStatedLimit, "the dealer wide unit catalogue"}, "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "pos_transactions.branch_id (the delete joins it); TestKeyBranchPin_PosAndAccountRows"}, - "DELETE /api/v1/products/{id}/pim/collateral/{collateralId}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/products/{id}/pim/media/{mediaId}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/accounts/{id}": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/accounts/{id}/transactions": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/ar/aging": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, - "GET /api/v1/ar/customers/{id}/statement": {KeyBranchClassConfined, "CustomerBranch(path customer); TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/ar/reconciliation": {KeyBranchClassRefused, "the reconciliation reads every branch's books; its ledger half carries no branch fact"}, "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, @@ -293,7 +323,6 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "the branch middleware"}, @@ -305,10 +334,6 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/catalog": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/pos/products/search": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/pos/returns": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "till_sessions.branch_id (the register's open session); TestKeyBranchPin_PosAndAccountRows"}, @@ -317,33 +342,20 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_transactions.branch_id"}, "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "pos_transactions.branch_id"}, "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/detail": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/collateral": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/pim/media": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/dimensions": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/lead-time": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/margins": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/products/{id}/pim/media/{mediaId}/primary": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/charge-codes": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, @@ -381,36 +393,28 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments/card": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payment-terms": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "pos_registers.branch_id on create; no machine key cashier"}, "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the register's branch (pos_registers.branch_id, refused on a mismatch) and no machine key cashier"}, + "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_registers.branch_id on create, pos_transactions.branch_id after; no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, - "POST /api/v1/products": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/collateral": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/descriptions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/image": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/products/{id}/pim/generate/seo": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/refresh-reorder-targets": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "the branch middleware"}, "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/charge-codes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, @@ -418,10 +422,6 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/payment-terms/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/kit-components": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/pim/content": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/products/{id}/units": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, From e6a4dffef7f5449aef67da23264ebb6d4faa960f Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:51:21 -0700 Subject: [PATCH 26/74] security: the WALLED class names its filter column and holds it under a twin sweep The lead's ruling on item 37: WALLED becomes a tested claim. Every WALLED entry's note now names the column or join its module filters on (quotes, orders, invoices, payments, credit memos, purchase orders, drafts and till rows on their branch_id; customers, contacts, ship tos and activities through customer_branches; deliveries and routes through their orders' branch; the dashboard and inventory through locations.branch_id; the links through each module's walled read), replacing the bare the branch middleware label that rounds 5 and 6 showed can overstate. A new sweep drives every WALLED route that takes an id or a list with branch B's twin row as a bound A key's target: the foreign row answers exactly what a missing row answers (or a 403), never names the row, nothing is written (family tables counted before and after), a user reads the twin where the missing row is a 404 so the twins are real, and the lists carry the pin's branch only. The fixture grows the document family mounts exactly as serve registers them and a second mint pair for the document scopes (the mint caps a key at 64 scopes). Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 169 ++++++- .../app/serve/wire_key_branch_walled_test.go | 437 ++++++++++++++++++ core/pkg/middleware/keybranch_allow.go | 258 +++++------ 3 files changed, 733 insertions(+), 131 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_walled_test.go diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index adc249f4..825e41c1 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -14,6 +14,7 @@ package serve import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -28,8 +29,12 @@ import ( "github.com/gablelbm/gable/internal/bankrecon" "github.com/gablelbm/gable/internal/chargecode" "github.com/gablelbm/gable/internal/config" + "github.com/gablelbm/gable/internal/crm" "github.com/gablelbm/gable/internal/customer" + "github.com/gablelbm/gable/internal/dashboard" "github.com/gablelbm/gable/internal/delivery" + "github.com/gablelbm/gable/internal/document" + "github.com/gablelbm/gable/internal/drafts" "github.com/gablelbm/gable/internal/edi" "github.com/gablelbm/gable/internal/events" "github.com/gablelbm/gable/internal/gl" @@ -37,9 +42,12 @@ import ( glint "github.com/gablelbm/gable/internal/integrations/gl" "github.com/gablelbm/gable/internal/inventory" "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/links" "github.com/gablelbm/gable/internal/location" "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/millwork" + "github.com/gablelbm/gable/internal/notification" + "github.com/gablelbm/gable/internal/order" "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/internal/pim" "github.com/gablelbm/gable/internal/pos" @@ -61,6 +69,7 @@ import ( "github.com/gablelbm/gable/pkg/outbox" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) // keyPinFixture is two branches, one yard per branch for the location rows, @@ -105,11 +114,29 @@ type keyPinFixture struct { posProd uuid.UUID // the product the POS lines carry bound string // the key pinned to branch A unbound string // the same scopes, no pin + bound2 string // a second bound key holding the document family scopes the mint cap keeps off the first + unbound2 string // the same scopes, no pin } // pinKeyScopes opens every route under test: the location writes, the branch // directory, the user grants, the exposure surface, the dealer wide routes, // the staff roster and the customer scoped pricing and tax surface. +// pinDocumentScopes is the second mint's scope list: the document families +// (customers, contacts, ship-tos, invoices, payments, credit memos, orders, +// drafts, dashboard, inventory, locations), kept off the first mint only +// because the mint caps a key at 64 scopes and the two lists together exceed +// it. +var pinDocumentScopes = []string{ + "customers:read", "customers:write", "contacts:read", "contacts:write", + "ship-tos:read", "ship-tos:write", "invoices:read", "invoices:write", + "payments:read", "payments:write", "credit-memos:read", "credit-memos:write", + "orders:read", "orders:write", "orders:propose", "quotes:propose", "quotes:commit", + "dashboard:read", "inventory:read", "inventory:write", "locations:read", "locations:write", + "products:read", "branches:read", + "matching:read", "matching:write", "delivery:read", "delivery:write", + "purchase-orders:read", "purchase-orders:write", "quotes:read", "quotes:write", +} + var pinKeyScopes = []string{ "locations:write", "branches:read", "branches:write", "users:read", "users:grants", "quotes:read", "quotes:write", "admin:write", @@ -422,7 +449,7 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { wall.delivery(mux, delivery.NewHandler(delivery.NewService(delivery.NewRepository(db)). WithOutbox(outbox.NewWriter(db, "")).WithAudit(audit.NewLogger(db))), "admin", "owner", "warehouse", "driver") - wall.matching(mux, matching.NewService(db, matching.NewRepository(db), nil, nil, slog.Default())) + wall.matching(mux, matching.NewService(db, matching.NewRepository(db), pinPOSource{db: db}, pinAPSource{}, slog.Default())) apps.NewHandler(apps.NewRegistry(db, slog.Default()).WithAudit(audit.NewLogger(db))). RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) poProductSvc := product.NewService(product.NewRepository(db)) @@ -434,11 +461,105 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithVelocityRepo(purchase_order.NewVelocityRepository(db)) wall.purchaseOrders(mux, purchase_order.NewHandler(poSvc, poRecSvc)) + // The branch walled document families exactly as serve registers them: + // quotes, orders, invoices, payments, crm, documents, drafts, dashboard, + // inventory and the link resolver, each through its module's wall. + quoteSvc := quote.NewService(quote.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db) + wall.quotes(mux, quoteSvc) + orderSvc := order.NewService(order.NewRepository(db)).WithTxRunner(db) + wall.orders(mux, orderSvc) + wall.invoices(mux, invoiceSvc.WithOrders(orderSvc).WithStock(inventory.NewService(inventory.NewRepository(db)))) + wall.payments(mux, paymentSvc.WithBranchGuard(wall.guard)) + wall.crm(mux, crm.NewService(crm.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db))) + wall.documents(mux, document.NewHandler(document.NewService(product.NewRepository(db)), + orderSvc, invoiceSvc, customerSvc, notification.NewLogEmailService(slog.Default()))) + quoteKind := quote.NewDraftKind(quoteSvc) + orderKind := order.NewDraftKind(orderSvc) + draftsRegistry, derr := drafts.NewRegistry(quoteKind, orderKind) + if derr != nil { + t.Fatalf("draft kind registration: %v", derr) + } + draftsSvc := drafts.NewService(drafts.NewRepository(db), draftsRegistry). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db)). + WithBranchGuard(wall.guard) + draftsHandler := drafts.NewHandler(draftsSvc) + quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, wall.scoped(quoteKind.Roles()...)) + order.RegisterDraftRoutes(mux, draftsHandler, orderKind, wall.scoped(orderKind.Roles()...)) + linksHandler := links.NewHandler(links.Settings{}, + links.Entity{Row: rowOrNone(links.RowFor("quotes", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + q, err := quoteSvc.GetQuoteByIDOrNumber(ctx, raw) + if err != nil { + return uuid.Nil, "", err + } + return q.ID, q.Number, nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("orders", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + o, err := orderSvc.GetOrderByIDOrNumber(ctx, raw) + if err != nil { + return uuid.Nil, "", err + } + return o.ID, o.Number, nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("invoices", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + inv, err := invoiceSvc.GetInvoiceByIDOrNumber(ctx, raw) + if err != nil { + return uuid.Nil, "", err + } + return inv.ID, inv.Number, nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("customers", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + c, err := customerSvc.Get(ctx, uuid.MustParse(raw)) + if err != nil { + return uuid.Nil, "", err + } + return c.ID, "", nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("products", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + pc, err := productSvc.GetProduct(ctx, uuid.MustParse(raw)) + if err != nil { + return uuid.Nil, "", err + } + return pc.ID, "", nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("quotes", true)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + d, err := draftsSvc.Get(ctx, "quotes", uuid.MustParse(raw)) + if err != nil { + return uuid.Nil, "", err + } + return d.ID, "", nil + }}, + links.Entity{Row: rowOrNone(links.RowFor("orders", true)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { + d, err := draftsSvc.Get(ctx, "orders", uuid.MustParse(raw)) + if err != nil { + return uuid.Nil, "", err + } + return d.ID, "", nil + }}, + ) + links.RegisterAll(mux, linksHandler, links.Guards{ + Quotes: wall.scoped("admin", "owner", "sales"), + Orders: wall.scoped("admin", "owner", "sales", "finance"), + Invoices: wall.scoped("admin", "owner", "sales", "finance"), + Customers: wall.scoped("admin", "owner", "sales"), + Products: wall.scoped("admin", "owner", "sales", "warehouse"), + DraftQuotes: wall.scoped(quoteKind.Roles()...), + DraftOrders: wall.scoped(orderKind.Roles()...), + }) + dashboard.NewHandler(dashboard.NewService(dashboard.NewRepository(db))). + RegisterRoutes(mux, wall.scoped("admin", "owner", "finance")) + wall.inventory(mux, inventory.NewService(inventory.NewRepository(db))) + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) techadmin.NewHandler(keys).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) mint := func(name string, branch *uuid.UUID) string { t.Helper() - raw, _, err := keys.GenerateKey(ctx, name, pinKeyScopes, branch) + scopes := pinKeyScopes + if strings.Contains(name, "documents") { + scopes = pinDocumentScopes + } + raw, _, err := keys.GenerateKey(ctx, name, scopes, branch) if err != nil { t.Fatalf("mint %s: %v", name, err) } @@ -446,6 +567,8 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { } f.bound = mint("pin bound to A", &f.branchA) f.unbound = mint("pin unbound", nil) + f.bound2 = mint("pin bound to A, documents", &f.branchA) + f.unbound2 = mint("pin unbound, documents", nil) mkAuth := middleware.NewMachineKeyAuth(pinKeyValidator{svc: keys}, audit.NewLogger(db), []string{"/api/integration/"}, nil) // The production chain, built by the same builder Run uses, so every test // here drives the wiring serve serves: the machine key core, the request @@ -481,6 +604,48 @@ func pinClaims(next http.Handler) http.Handler { }) } +// pinPOSource answers the matching module's purchase order seam from the +// table: the branch wall reads the branch, the service reads the record. +type pinPOSource struct { + db *database.DB +} + +func (s pinPOSource) GetPO(ctx context.Context, id uuid.UUID) (*purchase_order.PurchaseOrder, error) { + var exists bool + if err := s.db.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM purchase_orders WHERE id = $1)`, id).Scan(&exists); err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("no such purchase order") + } + return &purchase_order.PurchaseOrder{ID: id}, nil +} + +func (s pinPOSource) GetPOBranch(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + var branch *uuid.UUID + err := s.db.Pool.QueryRow(ctx, `SELECT branch_id FROM purchase_orders WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return branch, err +} + +// pinAPSource is an empty accounts payable: the cases that pass the wall +// answer before any invoice is needed. +type pinAPSource struct{} + +func (pinAPSource) ListVendorInvoices(context.Context, *uuid.UUID, string) ([]ap.VendorInvoice, error) { + return nil, nil +} + +func (pinAPSource) GetVendorInvoice(context.Context, uuid.UUID) (*ap.VendorInvoice, error) { + return nil, nil +} + +func (pinAPSource) ApproveInvoice(context.Context, uuid.UUID, uuid.UUID) (*ap.VendorInvoice, error) { + return nil, nil +} + // pinKeyValidator adapts the techadmin service to the machine key core, the // same adapter serve uses. type pinKeyValidator struct { diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go new file mode 100644 index 00000000..70120c48 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The WALLED class as a tested claim (the lead's ruling on item 37): every +// WALLED entry names the column or join its module filters on, and this sweep +// drives every WALLED route that takes an id (or a list) with branch B's row +// as the target of a key bound to branch A. The foreign row must answer +// exactly what a missing row answers (or a plain 403), the answer must never +// name the foreign row, nothing may be written (the family tables are counted +// before and after), and the unbound key must answer differently for the twin +// and for a missing row, so the twin is a real row the wall holds back. The +// lists carry the pin's branch's rows only. The pos family is driven the same +// way by TestKeyBranchPin_PosAndAccountRows. + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" +) + +// walledTwins is one row per branch for every walled family the sweep drives. +type walledTwins struct { + ordA, ordB uuid.UUID + invA, invB uuid.UUID + memoA, memoB uuid.UUID + payA, payB uuid.UUID + actA, actB uuid.UUID + conA, conB uuid.UUID + delA, delB uuid.UUID + routeA, routeB uuid.UUID + draftQA, draftQB, draftOA, draftOB uuid.UUID + poA, poB, poLineA, poLineB, freightA, freightB uuid.UUID + appA, appB uuid.UUID + shipA, shipB uuid.UUID +} + +// seedWalledTwins seeds the twin rows with their cleanup. +func (f *keyPinFixture) seedWalledTwins(t *testing.T) *walledTwins { + t.Helper() + ctx := context.Background() + w := &walledTwins{ + ordA: uuid.New(), ordB: uuid.New(), invA: uuid.New(), invB: uuid.New(), + memoA: uuid.New(), memoB: uuid.New(), payA: uuid.New(), payB: uuid.New(), + actA: uuid.New(), actB: uuid.New(), conA: uuid.New(), conB: uuid.New(), + delA: uuid.New(), delB: uuid.New(), routeA: uuid.New(), routeB: uuid.New(), + draftQA: uuid.New(), draftQB: uuid.New(), draftOA: uuid.New(), draftOB: uuid.New(), + poA: uuid.New(), poB: uuid.New(), poLineA: uuid.New(), poLineB: uuid.New(), + freightA: uuid.New(), freightB: uuid.New(), appA: uuid.New(), appB: uuid.New(), + shipA: uuid.New(), shipB: uuid.New(), + } + vendor := uuid.New() + vendorName := "pin twin vendor " + vendor.String()[:8] + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO vendors (id, name) VALUES ($1, $2)`, vendor, vendorName); err != nil { + t.Fatalf("seed vendor: %v", err) + } + // The seeds below this point can fail before the cleanup is registered, + // so the vendor carries its own cleanup from the moment it exists. + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM vendors WHERE id = $1`, vendor) + }) + for _, o := range []struct { + id, cust, branch, inv, pay, act, con, ship uuid.UUID + }{ + {w.ordA, f.custID, f.branchA, w.invA, w.payA, w.actA, w.conA, w.shipA}, + {w.ordB, f.custB, f.branchB, w.invB, w.payB, w.actB, w.conB, w.shipB}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO orders (id, customer_id, branch_id, status, total_amount, delivery_type, currency) + VALUES ($1, $2, $3, 'CONFIRMED', 10, 'PICKUP', 'USD')`, o.id, o.cust, o.branch); err != nil { + t.Fatalf("seed order: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO invoices (id, order_id, customer_id, status, total_amount, branch_id) + VALUES ($1, $2, $3, 'UNPAID', 10, $4)`, o.inv, o.id, o.cust, o.branch); err != nil { + t.Fatalf("seed invoice: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO payments (id, customer_id, branch_id, currency, amount, method, status, received_on, amount_unapplied) + VALUES ($1, $2, $3, 'USD', 5, 'CASH', 'POSTED', CURRENT_DATE, 5)`, o.pay, o.cust, o.branch); err != nil { + t.Fatalf("seed payment: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO crm_activities (id, customer_id, activity_type, description, activity_date) + VALUES ($1, $2, 'CALL', 'pin twin', now())`, o.act, o.cust); err != nil { + t.Fatalf("seed activity: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO customer_contacts (id, customer_id, first_name, last_name) + VALUES ($1, $2, 'pin', 'twin')`, o.con, o.cust); err != nil { + t.Fatalf("seed contact: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO customer_ship_tos (id, customer_id, code, name, line1) VALUES ($1, $2, 'twin', 'pin twin', 'pin')`, + o.ship, o.cust); err != nil { + t.Fatalf("seed ship to: %v", err) + } + } + for _, m := range []struct{ id, inv, cust, branch uuid.UUID }{ + {w.memoA, w.invA, f.custID, f.branchA}, {w.memoB, w.invB, f.custB, f.branchB}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO credit_memos (id, invoice_id, customer_id, branch_id, currency, reason_code, reason, amount, status, number, + memo_date, subtotal, tax_amount, total_amount, tax_rate) + VALUES ($1, $2, $3, $4, 'USD', 'OTHER', 'pin twin', 1, 'OPEN', credit_memo_next_number(), CURRENT_DATE, -1, 0, -1, 0)`, + m.id, m.inv, m.cust, m.branch); err != nil { + t.Fatalf("seed credit memo: %v", err) + } + } + for _, r := range []struct{ id uuid.UUID }{{w.routeA}, {w.routeB}} { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO delivery_routes (id, status, scheduled_date) VALUES ($1, 'DRAFT', CURRENT_DATE)`, r.id); err != nil { + t.Fatalf("seed route: %v", err) + } + } + for _, d := range []struct{ id, route, ord uuid.UUID }{ + {w.delA, w.routeA, w.ordA}, {w.delB, w.routeB, w.ordB}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO deliveries (id, route_id, order_id, status) VALUES ($1, $2, $3, 'PENDING')`, + d.id, d.route, d.ord); err != nil { + t.Fatalf("seed delivery: %v", err) + } + } + for _, d := range []struct { + id, branch uuid.UUID + module string + }{ + {w.draftQA, f.branchA, "quotes"}, {w.draftQB, f.branchB, "quotes"}, + {w.draftOA, f.branchA, "orders"}, {w.draftOB, f.branchB, "orders"}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO drafts (id, module, branch_id, status, payload, created_by_kind, updated_by_kind) + VALUES ($1, $2, $3, 'OPEN', '{}', 'user', 'user')`, d.id, d.module, d.branch); err != nil { + t.Fatalf("seed draft: %v", err) + } + } + for _, po := range []struct { + id, line, branch uuid.UUID + }{ + {w.poA, w.poLineA, f.branchA}, {w.poB, w.poLineB, f.branchB}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO purchase_orders (id, vendor_id, status, source, branch_id) VALUES ($1, $2, 'SENT', 'MANUAL', $3)`, + po.id, vendor, po.branch); err != nil { + t.Fatalf("seed purchase order: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO purchase_order_lines (id, po_id, product_id, description, quantity, cost) VALUES ($1, $2, $3, 'pin twin', 5, 1)`, + po.line, po.id, f.posProd); err != nil { + t.Fatalf("seed purchase order line: %v", err) + } + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO inventory (product_id, location_id, location, quantity) VALUES ($1, $2, 'pin', 7), ($1, $3, 'pin', 9)`, + f.posProd, f.yardA, f.yardB); err != nil { + t.Fatalf("seed inventory: %v", err) + } + for _, a := range []struct { + id, cust, inv, pay uuid.UUID + }{ + {w.appA, f.custID, w.invA, w.payA}, {w.appB, f.custB, w.invB, w.payB}, + } { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO ar_applications (id, customer_id, currency, kind, payment_id, invoice_id, amount, applied_on, act_id) + VALUES ($1, $2, 'USD', 'PAYMENT', $3, $4, 1, CURRENT_DATE, $1)`, a.id, a.cust, a.pay, a.inv); err != nil { + t.Fatalf("seed application: %v", err) + } + } + t.Cleanup(func() { + exec := func(stmt string, args ...any) { + _, _ = f.db.Pool.Exec(ctx, stmt, args...) + } + exec(`DELETE FROM inventory WHERE product_id = $1 AND location_id IN ($2, $3)`, f.posProd, f.yardA, f.yardB) + exec(`DELETE FROM ar_applications WHERE id IN ($1, $2)`, w.appA, w.appB) + exec(`DELETE FROM purchase_order_lines WHERE po_id IN ($1, $2)`, w.poA, w.poB) + exec(`DELETE FROM purchase_orders WHERE id IN ($1, $2)`, w.poA, w.poB) + exec(`DELETE FROM drafts WHERE id IN ($1, $2, $3, $4)`, w.draftQA, w.draftQB, w.draftOA, w.draftOB) + exec(`DELETE FROM deliveries WHERE id IN ($1, $2) OR route_id IN ($3, $4)`, w.delA, w.delB, w.routeA, w.routeB) + exec(`DELETE FROM delivery_routes WHERE id IN ($1, $2)`, w.routeA, w.routeB) + exec(`DELETE FROM credit_memos WHERE id IN ($1, $2)`, w.memoA, w.memoB) + exec(`DELETE FROM customer_ship_tos WHERE id IN ($1, $2)`, w.shipA, w.shipB) + exec(`DELETE FROM customer_contacts WHERE id IN ($1, $2)`, w.conA, w.conB) + exec(`DELETE FROM crm_activities WHERE id IN ($1, $2)`, w.actA, w.actB) + exec(`DELETE FROM payments WHERE id IN ($1, $2)`, w.payA, w.payB) + exec(`DELETE FROM invoices WHERE id IN ($1, $2)`, w.invA, w.invB) + exec(`DELETE FROM orders WHERE id IN ($1, $2)`, w.ordA, w.ordB) + exec(`DELETE FROM vendors WHERE id = $1`, vendor) + }) + return w +} + +// familyCounts reads the row counts of every table the sweep's write legs +// could touch, so a confined write that still wrote shows as a diff. +func (f *keyPinFixture) familyCounts(t *testing.T) map[string]int { + t.Helper() + tables := []string{"orders", "invoices", "credit_memos", "payments", "crm_activities", "customer_contacts", + "deliveries", "delivery_routes", "drafts", "purchase_orders", "purchase_order_lines", + "po_freight_charges", "inventory", "quotes", "customer_ship_tos", "ar_applications"} + out := map[string]int{} + for _, tb := range tables { + var n int + if err := f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM `+tb).Scan(&n); err != nil { + t.Fatal(err) + } + out[tb] = n + } + return out +} + +// TestKeyBranchPin_WalledRowsHoldThePin drives every WALLED route that takes +// an id (or a list) with branch B's twin row: the bound key's answer for the +// foreign row equals its answer for a missing row (or a 403 or 404) and never +// names the row, nothing is written anywhere the families write, and the +// unbound key tells the twin from a missing row, so the twin is real. The +// lists carry the pin's branch only. +func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { + f := newKeyPinFixture(t) + // The sweep's key is the document family mint: the families it drives + // live in the second scope list. + f.bound, f.unbound = f.bound2, f.unbound2 + w := f.seedWalledTwins(t) + missing := uuid.NewString() + A, B := f.branchA.String(), f.branchB.String() + + // Every by id route with the foreign twin it drives. + // The routes whose user answer for the twin is not a plain 200: a quote + // with no stored file answers its own 404, and the exposure gate answers + // a missing order rather than echoing it. + noRealUserAnswer := map[string]bool{ + "/api/v1/quotes/%s/file": true, + "/api/v1/orders/%s/exposure-gate": true, + "/api/v1/matching/results/%s": true, // no match results are seeded, both rows answer 404 + "/api/v1/purchase-orders/%s/freight": true, // an empty freight list answers 200 for any order + } + idRoutes := []struct { + method, path, body string + twin uuid.UUID + }{ + {"GET", "/api/v1/quotes/%s", "", f.quoteB}, + {"PUT", "/api/v1/quotes/%s", `{}`, f.quoteB}, + {"POST", "/api/v1/quotes/%s/convert", `{}`, f.quoteB}, + {"POST", "/api/v1/quotes/%s/transitions", `{"action":"send"}`, f.quoteB}, + {"GET", "/api/v1/quotes/%s/file", "", f.quoteB}, + {"PUT", "/api/v1/quotes/%s/file", `{}`, f.quoteB}, + {"GET", "/api/v1/orders/%s", "", w.ordB}, + {"PUT", "/api/v1/orders/%s", `{}`, w.ordB}, + {"GET", "/api/v1/orders/%s/exposure-gate", "", w.ordB}, + {"POST", "/api/v1/orders/%s/allocate", `{}`, w.ordB}, + {"POST", "/api/v1/orders/%s/exposure-override", `{"notes":"pin"}`, w.ordB}, + {"POST", "/api/v1/orders/%s/fulfillments", `{}`, w.ordB}, + {"POST", "/api/v1/orders/%s/transitions", `{"action":"confirm"}`, w.ordB}, + {"POST", "/api/v1/orders/fulfillment-requests/%s/retry", "", w.delB}, + {"GET", "/api/v1/invoices/%s", "", w.invB}, + {"GET", "/api/v1/invoices/%s/payments", "", w.invB}, + {"POST", "/api/v1/invoices/%s/email", `{}`, w.invB}, + {"POST", "/api/v1/invoices/%s/transitions", `{"action":"send"}`, w.invB}, + {"POST", "/api/v1/invoices/%s/write-offs", `{"amount_cents":1,"reason":"pin"}`, w.invB}, + {"GET", "/api/v1/documents/print/invoice/%s", "", w.invB}, + {"GET", "/api/v1/documents/print/pickticket/%s", "", w.ordB}, + {"GET", "/api/v1/payments/%s", "", w.payB}, + {"POST", "/api/v1/payments/%s/applications", `{}`, w.payB}, + {"POST", "/api/v1/payments/%s/refunds", `{}`, w.payB}, + {"POST", "/api/v1/payments/%s/transitions", `{}`, w.payB}, + {"POST", "/api/v1/ar/applications/%s/reverse", `{}`, w.appB}, + {"GET", "/api/v1/credit-memos/%s", "", w.memoB}, + {"PUT", "/api/v1/credit-memos/%s", `{}`, w.memoB}, + {"POST", "/api/v1/credit-memos/%s/applications", `{}`, w.memoB}, + {"POST", "/api/v1/credit-memos/%s/refunds", `{}`, w.memoB}, + {"POST", "/api/v1/credit-memos/%s/transitions", `{}`, w.memoB}, + {"GET", "/api/v1/activities/%s", "", w.actB}, + {"PUT", "/api/v1/activities/%s", `{}`, w.actB}, + {"DELETE", "/api/v1/activities/%s", "", w.actB}, + {"GET", "/api/v1/contacts/%s", "", w.conB}, + {"PUT", "/api/v1/contacts/%s", `{}`, w.conB}, + {"DELETE", "/api/v1/contacts/%s", "", w.conB}, + {"GET", "/api/v1/ship-tos/%s", "", w.shipB}, + {"PUT", "/api/v1/ship-tos/%s", `{}`, w.shipB}, + {"GET", "/api/v1/drafts/quotes/%s", "", w.draftQB}, + {"PUT", "/api/v1/drafts/quotes/%s", `{}`, w.draftQB}, + {"POST", "/api/v1/drafts/quotes/%s/promote", `{}`, w.draftQB}, + {"POST", "/api/v1/drafts/quotes/%s/transitions", `{}`, w.draftQB}, + {"GET", "/api/v1/drafts/orders/%s", "", w.draftOB}, + {"PUT", "/api/v1/drafts/orders/%s", `{}`, w.draftOB}, + {"POST", "/api/v1/drafts/orders/%s/promote", `{}`, w.draftOB}, + {"POST", "/api/v1/drafts/orders/%s/transitions", `{}`, w.draftOB}, + {"GET", "/api/v1/links/quotes/%s", "", f.quoteB}, + {"GET", "/api/v1/links/orders/%s", "", w.ordB}, + {"GET", "/api/v1/links/invoices/%s", "", w.invB}, + {"GET", "/api/v1/links/customers/%s", "", f.custB}, + {"GET", "/api/v1/links/drafts/quotes/%s", "", w.draftQB}, + {"GET", "/api/v1/links/drafts/orders/%s", "", w.draftOB}, + {"GET", "/api/v1/delivery/deliveries/%s", "", w.delB}, + {"GET", "/api/v1/delivery/deliveries/%s/pod-photos", "", w.delB}, + {"POST", "/api/v1/delivery/deliveries/%s/adjust-qty", `{}`, w.delB}, + {"POST", "/api/v1/delivery/deliveries/%s/pod-photo", `{}`, w.delB}, + {"POST", "/api/v1/delivery/deliveries/%s/transitions", `{"status":"COMPLETED"}`, w.delB}, + {"GET", "/api/v1/delivery/routes/%s", "", w.routeB}, + {"GET", "/api/v1/delivery/routes/%s/deliveries", "", w.routeB}, + {"POST", "/api/v1/delivery/routes/%s/optimize", "", w.routeB}, + {"POST", "/api/v1/delivery/routes/%s/reorder", "", w.routeB}, + {"POST", "/api/v1/delivery/routes/%s/transitions", `{"status":"PLANNED"}`, w.routeB}, + {"GET", "/api/v1/matching/results/%s", "", w.poB}, + {"POST", "/api/v1/matching/run/%s", "", w.poB}, + {"GET", "/api/v1/purchase-orders/%s", "", w.poB}, + {"GET", "/api/v1/purchase-orders/%s/freight", "", w.poB}, + {"POST", "/api/v1/purchase-orders/%s/freight", `{"carrier":"pin","cost_cents":1}`, w.poB}, + {"POST", "/api/v1/purchase-orders/%s/freight/%s/apply", "", w.freightB}, + {"POST", "/api/v1/purchase-orders/%s/receive", `{}`, w.poB}, + {"POST", "/api/v1/purchase-orders/%s/submit", "", w.poB}, + } + before := f.familyCounts(t) + for _, c := range idRoutes { + var bPath, mPath string + if strings.Count(c.path, "%s") == 2 { + // The freight apply route names the order and the freight quote. + bPath = fmt.Sprintf(c.path, w.poB, w.freightB) + mPath = fmt.Sprintf(c.path, uuid.MustParse(missing), uuid.MustParse(missing)) + } else { + bPath = fmt.Sprintf(c.path, c.twin) + mPath = fmt.Sprintf(c.path, uuid.MustParse(missing)) + } + sb, bb := f.key(t, f.bound, c.method, bPath, c.body, nil) + sm, _ := f.key(t, f.bound, c.method, mPath, c.body, nil) + if sb != sm && sb != http.StatusForbidden && sb != http.StatusNotFound { + t.Errorf("bound key %s %s: %d %s for the foreign row, want the missing row answer (%d), a 403 or a 404", + c.method, bPath, sb, bb, sm) + } + if strings.Contains(string(bb), c.twin.String()) { + t.Errorf("bound key %s %s: the answer names the foreign row: %s", c.method, bPath, bb) + } + // The twin is real: a user reads it where a missing row is a 404 (the + // write legs prove the same rows exist through their family tables). + if c.method == "GET" && !noRealUserAnswer[c.path] { + ub, ubuf := f.user(t, c.method, bPath, c.body, nil) + um, _ := f.user(t, c.method, mPath, c.body, nil) + if ub != http.StatusOK || um != http.StatusNotFound { + t.Errorf("user %s %s: %d for the twin and %d for a missing row, want 200 and 404 (%s)", + c.method, bPath, ub, um, ubuf) + } + } + } + after := f.familyCounts(t) + for tb, n := range before { + if after[tb] != n { + t.Errorf("table %s: %d rows before the sweep, %d after, want no writes", tb, n, after[tb]) + } + } + + // The lists carry the pin's branch only: the foreign twin ids never + // appear, the pin's own do, and the unbound key sees the list too. + lists := []struct { + path string + absent, present []string + }{ + {"/api/v1/customers", []string{f.custB.String()}, []string{f.custID.String()}}, + {"/api/v1/credit-memos", []string{w.memoB.String()}, []string{w.memoA.String()}}, + {"/api/v1/invoices", []string{w.invB.String()}, []string{w.invA.String()}}, + {"/api/v1/payments", []string{w.payB.String()}, []string{w.payA.String()}}, + {"/api/v1/quotes", []string{f.quoteB.String()}, []string{f.quoteA.String()}}, + {"/api/v1/orders", []string{w.ordB.String()}, []string{w.ordA.String()}}, + {"/api/v1/orders/fulfillment-requests", []string{w.ordB.String()}, nil}, + {"/api/v1/drafts/quotes", []string{w.draftQB.String()}, []string{w.draftQA.String()}}, + {"/api/v1/drafts/orders", []string{w.draftOB.String()}, []string{w.draftOA.String()}}, + {"/api/v1/purchase-orders", []string{w.poB.String()}, []string{w.poA.String()}}, + {"/api/v1/purchase-orders/recommendations", []string{}, nil}, + {"/api/v1/purchase-orders/reorder-runs", []string{}, nil}, + {"/api/v1/purchase-orders/source-summary", []string{}, nil}, + {"/api/v1/delivery/routes", []string{w.routeB.String()}, []string{w.routeA.String()}}, + {"/api/v1/matching/exceptions", []string{w.poB.String()}, nil}, + {"/api/v1/customers/" + f.custID.String() + "/activities", []string{w.actB.String()}, []string{w.actA.String()}}, + {"/api/v1/customers/" + f.custID.String() + "/contacts", []string{w.conB.String()}, []string{w.conA.String()}}, + {"/api/v1/customers/" + f.custID.String() + "/ship-tos", []string{w.shipB.String()}, []string{w.shipA.String()}}, + {"/api/v1/dashboard/summary", []string{f.custB.String()}, nil}, + {"/api/v1/dashboard/top-customers", []string{f.custB.String()}, nil}, + {"/api/v1/dashboard/order-activity", []string{w.ordB.String()}, nil}, + {"/api/v1/dashboard/revenue-trend", []string{w.invB.String()}, nil}, + {"/api/v1/dashboard/inventory-alerts", []string{f.yardB.String()}, nil}, + {"/api/v1/inventory", []string{f.yardB.String()}, nil}, + {"/api/v1/locations", []string{f.yardB.String()}, nil}, + {"/api/v1/products/reorder-alerts", []string{f.yardB.String()}, nil}, + } + for _, l := range lists { + sb, bb := f.key(t, f.bound, "GET", l.path, "", nil) + if sb != http.StatusOK { + t.Errorf("bound key GET %s: %d %s, want 200", l.path, sb, bb) + continue + } + for _, id := range l.absent { + if strings.Contains(string(bb), id) { + t.Errorf("bound key GET %s: the list carries the foreign row %s", l.path, id) + } + } + for _, id := range l.present { + if !strings.Contains(string(bb), id) { + t.Errorf("bound key GET %s: the list does not carry the pin's own row %s", l.path, id) + } + } + if sk, _ := f.key(t, f.unbound, "GET", l.path, "", nil); sk != http.StatusOK { + t.Errorf("unbound key GET %s: %d, want 200", l.path, sk) + } + } + + // The branch tree and the location row read hold the same way: the + // foreign branch's tree and yard are refused or missing, the pin's own + // answer. The tree's refusal is the branch middleware's own (the wall is + // not what refuses a path branch on these mounts). + if sb, _ := f.key(t, f.bound, "GET", "/api/v1/branches/"+B+"/tree", "", nil); sb != http.StatusForbidden { + t.Errorf("bound key foreign branch tree: %d, want 403", sb) + } + f.allow(t, "bound", "GET", "/api/v1/branches/"+A+"/tree", "", nil, http.StatusOK) + if sb, bb := f.key(t, f.bound, "GET", "/api/v1/locations/"+f.yardB.String(), "", nil); sb != http.StatusForbidden && sb != http.StatusNotFound { + t.Errorf("bound key foreign yard read: %d %s, want 403 or 404", sb, bb) + } + f.allow(t, "bound", "GET", "/api/v1/locations/"+f.yardA.String(), "", nil, http.StatusOK) + + // The inventory adjust and transfer bodies name locations: the foreign + // yard is refused (the branch guard's own refusal) with nothing written. + beforeInv := f.familyCounts(t)["inventory"] + if sb, bb := f.key(t, f.bound, "POST", "/api/v1/inventory/adjust", + fmt.Sprintf(`{"product_id":%q,"location_id":%q,"quantity":5,"reason":"pin"}`, f.posProd.String(), f.yardB.String()), nil); sb != http.StatusForbidden { + t.Errorf("bound key inventory adjust naming the foreign yard: %d %s, want 403", sb, bb) + } + if sb, bb := f.key(t, f.bound, "POST", "/api/v1/inventory/transfer", + fmt.Sprintf(`{"product_id":%q,"from_location_id":%q,"to_location_id":%q,"quantity":1,"reason":"pin"}`, + f.posProd.String(), f.yardA.String(), f.yardB.String()), nil); sb != http.StatusForbidden { + t.Errorf("bound key inventory transfer into the foreign yard: %d %s, want 403", sb, bb) + } + if n := f.familyCounts(t)["inventory"]; n != beforeInv { + t.Errorf("refused inventory writes moved rows: %d then %d", beforeInv, n) + } +} diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 03f2ca9c..0a314e5c 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -280,60 +280,60 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, - "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "pos_transactions.branch_id (the delete joins it); TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ar/aging": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, - "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository's wall)"}, - "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/inventory-alerts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/order-activity": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/revenue-trend": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/dashboard/top-customers": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/deliveries/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/deliveries/{id}/pod-photos": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/delivery/routes/{id}/deliveries": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/documents/print/invoice/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/documents/print/pickticket/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders/feed": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes/feed": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/inventory": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/invoices/{id}/payments": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/matching/exceptions": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/matching/results/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/fulfillment-requests": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "GET /api/v1/ar/aging": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository wall)"}, + "GET /api/v1/ar/aging/summary": {KeyBranchClassWalled, "invoices and credit memos branch_id (the account repository wall)"}, + "GET /api/v1/branches/{id}/tree": {KeyBranchClassWalled, "locations.branch_id (the tree walks the branch middleware context)"}, + "GET /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, + "GET /api/v1/credit-memos": {KeyBranchClassWalled, "credit_memos.branch_id"}, + "GET /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "credit_memos.branch_id"}, + "GET /api/v1/customers": {KeyBranchClassWalled, "customer_branches (the customer repository predicate)"}, + "GET /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "GET /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, + "GET /api/v1/customers/{id}": {KeyBranchClassWalled, "customer_branches (a customer of another branch reads as missing)"}, + "GET /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "customer_branches"}, + "GET /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "customer_ship_tos.customer_id to customer_branches"}, + "GET /api/v1/dashboard/inventory-alerts": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id (the dashboard predicate)"}, + "GET /api/v1/dashboard/order-activity": {KeyBranchClassWalled, "orders.branch_id (the dashboard predicate)"}, + "GET /api/v1/dashboard/revenue-trend": {KeyBranchClassWalled, "invoices.branch_id (the dashboard predicate)"}, + "GET /api/v1/dashboard/summary": {KeyBranchClassWalled, "orders and invoices branch_id (the dashboard predicate)"}, + "GET /api/v1/dashboard/top-customers": {KeyBranchClassWalled, "invoices.branch_id (the dashboard predicate)"}, + "GET /api/v1/delivery/deliveries/{id}": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id (stopVisible)"}, + "GET /api/v1/delivery/deliveries/{id}/pod-photos": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id (stopVisible)"}, + "GET /api/v1/delivery/routes": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible; a route with no stops is visible by design)"}, + "GET /api/v1/delivery/routes/{id}": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible)"}, + "GET /api/v1/delivery/routes/{id}/deliveries": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible)"}, + "GET /api/v1/documents/print/invoice/{id}": {KeyBranchClassWalled, "invoices.branch_id"}, + "GET /api/v1/documents/print/pickticket/{id}": {KeyBranchClassWalled, "orders.branch_id"}, + "GET /api/v1/drafts/orders": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/drafts/orders/feed": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/drafts/quotes": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/drafts/quotes/feed": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "drafts.branch_id"}, + "GET /api/v1/inventory": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id"}, + "GET /api/v1/invoices": {KeyBranchClassWalled, "invoices.branch_id"}, + "GET /api/v1/invoices/{id}": {KeyBranchClassWalled, "invoices.branch_id"}, + "GET /api/v1/invoices/{id}/payments": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, + "GET /api/v1/links/customers/{id}": {KeyBranchClassWalled, "customer_branches (the link resolves through the walled customer Get)"}, + "GET /api/v1/links/drafts/orders/{id}": {KeyBranchClassWalled, "drafts.branch_id (the link resolves through the walled draft Get)"}, + "GET /api/v1/links/drafts/quotes/{id}": {KeyBranchClassWalled, "drafts.branch_id (the link resolves through the walled draft Get)"}, + "GET /api/v1/links/invoices/{id}": {KeyBranchClassWalled, "invoices.branch_id (the link resolves through the walled invoice Get)"}, + "GET /api/v1/links/orders/{id}": {KeyBranchClassWalled, "orders.branch_id (the link resolves through the walled order Get)"}, + "GET /api/v1/links/quotes/{id}": {KeyBranchClassWalled, "quotes.branch_id (the link resolves through the walled quote Get)"}, + "GET /api/v1/locations": {KeyBranchClassWalled, "locations.branch_id (the branch middleware context)"}, + "GET /api/v1/locations/{id}": {KeyBranchClassWalled, "locations.branch_id (the payload guard)"}, + "GET /api/v1/matching/exceptions": {KeyBranchClassWalled, "purchase_orders.branch_id (the exceptions join their order)"}, + "GET /api/v1/matching/results/{po_id}": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/orders": {KeyBranchClassWalled, "orders.branch_id"}, + "GET /api/v1/orders/fulfillment-requests": {KeyBranchClassWalled, "orders.branch_id (the requests join their order)"}, + "GET /api/v1/orders/{id}": {KeyBranchClassWalled, "orders.branch_id"}, + "GET /api/v1/orders/{id}/exposure-gate": {KeyBranchClassWalled, "orders.branch_id"}, + "GET /api/v1/payments": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, + "GET /api/v1/payments/{id}": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "GET /api/v1/pos/returns": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "till_sessions.branch_id (the register's open session); TestKeyBranchPin_PosAndAccountRows"}, @@ -342,89 +342,89 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_transactions.branch_id"}, "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "pos_transactions.branch_id"}, "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, - "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/credit-memos/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/adjust-qty": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/pod-photo": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/deliveries/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/optimize": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/reorder": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/delivery/routes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes/{id}/promote": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/drafts/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/inventory/adjust": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/inventory/transfer": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/email": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/locations": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/fulfillment-requests/{delivery_id}/retry": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/fulfillments": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/card": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/intent": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, + "GET /api/v1/products/reorder-alerts": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id (stock narrows by branch)"}, + "GET /api/v1/purchase-orders": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/purchase-orders/recommendations": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id and products"}, + "GET /api/v1/purchase-orders/reorder-runs": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/purchase-orders/source-summary": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/purchase-orders/{id}": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "GET /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id"}, + "GET /api/v1/quotes/analytics": {KeyBranchClassWalled, "quotes.branch_id"}, + "GET /api/v1/quotes/{id}": {KeyBranchClassWalled, "quotes.branch_id"}, + "GET /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "quotes.branch_id"}, + "GET /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "customer_ship_tos.customer_id to customer_branches"}, + "PATCH /api/v1/customers/{id}/salesperson": {KeyBranchClassWalled, "customer_branches"}, + "POST /api/v1/ar/applications/{id}/reverse": {KeyBranchClassWalled, "ar_applications to invoices.branch_id (GetApplication wall)"}, + "POST /api/v1/credit-memos": {KeyBranchClassWalled, "credit_memos.branch_id (the create takes branch_id from the caller context)"}, + "POST /api/v1/credit-memos/{id}/applications": {KeyBranchClassWalled, "credit_memos.branch_id (MemoFactsFor and LockMemo wall)"}, + "POST /api/v1/credit-memos/{id}/refunds": {KeyBranchClassWalled, "credit_memos.branch_id (MemoFactsFor and LockMemo wall)"}, + "POST /api/v1/credit-memos/{id}/transitions": {KeyBranchClassWalled, "credit_memos.branch_id"}, + "POST /api/v1/customers": {KeyBranchClassWalled, "customers.primary_branch_id (the branch middleware holds it to the pin)"}, + "POST /api/v1/customers/{customerId}/activities": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "POST /api/v1/customers/{customerId}/contacts": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, + "POST /api/v1/customers/{id}/ship-tos": {KeyBranchClassWalled, "customer_ship_tos.customer_id to customer_branches"}, + "POST /api/v1/delivery/deliveries": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id"}, + "POST /api/v1/delivery/deliveries/{id}/adjust-qty": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id (stopVisible)"}, + "POST /api/v1/delivery/deliveries/{id}/pod-photo": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id (stopVisible)"}, + "POST /api/v1/delivery/deliveries/{id}/transitions": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id (stopVisible)"}, + "POST /api/v1/delivery/routes": {KeyBranchClassWalled, "the route create carries the caller context branch into its stops orders"}, + "POST /api/v1/delivery/routes/{id}/optimize": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible)"}, + "POST /api/v1/delivery/routes/{id}/reorder": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible)"}, + "POST /api/v1/delivery/routes/{id}/transitions": {KeyBranchClassWalled, "delivery_routes stops to their orders branch (routeVisible)"}, + "POST /api/v1/drafts/orders": {KeyBranchClassWalled, "drafts.branch_id (the draft carries the caller context branch)"}, + "POST /api/v1/drafts/orders/{id}/promote": {KeyBranchClassWalled, "drafts.branch_id"}, + "POST /api/v1/drafts/orders/{id}/transitions": {KeyBranchClassWalled, "drafts.branch_id"}, + "POST /api/v1/drafts/quotes": {KeyBranchClassWalled, "drafts.branch_id (the draft carries the caller context branch)"}, + "POST /api/v1/drafts/quotes/{id}/promote": {KeyBranchClassWalled, "drafts.branch_id"}, + "POST /api/v1/drafts/quotes/{id}/transitions": {KeyBranchClassWalled, "drafts.branch_id"}, + "POST /api/v1/inventory/adjust": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id (the guard checks both ends)"}, + "POST /api/v1/inventory/transfer": {KeyBranchClassWalled, "inventory.location_id to locations.branch_id (the guard checks both ends)"}, + "POST /api/v1/invoices/{id}/email": {KeyBranchClassWalled, "invoices.branch_id"}, + "POST /api/v1/invoices/{id}/transitions": {KeyBranchClassWalled, "invoices.branch_id"}, + "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "invoices.branch_id"}, + "POST /api/v1/locations": {KeyBranchClassWalled, "locations.branch_id (the guard on the parent the row hangs under)"}, + "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "purchase_orders.branch_id (the run reads the order under the wall)"}, + "POST /api/v1/orders": {KeyBranchClassWalled, "orders.branch_id (the payload branch rule)"}, + "POST /api/v1/orders/fulfillment-requests/{delivery_id}/retry": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id"}, + "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "orders.branch_id"}, + "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "orders.branch_id"}, + "POST /api/v1/orders/{id}/fulfillments": {KeyBranchClassWalled, "orders.branch_id"}, + "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "orders.branch_id"}, + "POST /api/v1/payments": {KeyBranchClassWalled, "payments.branch_id (the create takes branch_id from the caller context)"}, + "POST /api/v1/payments/card": {KeyBranchClassWalled, "payments.branch_id (the card charge writes its payment under the caller context)"}, + "POST /api/v1/payments/intent": {KeyBranchClassWalled, "payments.branch_id (the intent writes under the caller context)"}, + "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, + "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, + "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "pos_registers.branch_id on create; no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "pos_registers.branch_id on every replayed create (CreateTransaction)"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the register's branch (pos_registers.branch_id, refused on a mismatch) and no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_registers.branch_id on create, pos_transactions.branch_id after; no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, - "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "the branch middleware"}, - "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "the branch middleware"}, - "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "the branch middleware"}, + "POST /api/v1/purchase-orders": {KeyBranchClassWalled, "purchase_orders.branch_id (the create takes the caller context branch)"}, + "POST /api/v1/purchase-orders/reorder-check": {KeyBranchClassWalled, "purchase_orders.branch_id (the check reads stock and velocity under the caller context)"}, + "POST /api/v1/purchase-orders/{id}/freight": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "purchase_orders.branch_id"}, + "POST /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id (the payload branch rule)"}, + "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "quotes.branch_id (the convert reads the quote under the wall)"}, + "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "quotes.branch_id"}, + "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, + "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "credit_memos.branch_id"}, + "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "customer_branches (a customer of another branch reads as missing)"}, + "PUT /api/v1/customers/{id}/escalation-policy": {KeyBranchClassWalled, "customer_branches"}, + "PUT /api/v1/drafts/orders/{id}": {KeyBranchClassWalled, "drafts.branch_id"}, + "PUT /api/v1/drafts/quotes/{id}": {KeyBranchClassWalled, "drafts.branch_id"}, + "PUT /api/v1/orders/{id}": {KeyBranchClassWalled, "orders.branch_id"}, + "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "quotes.branch_id"}, + "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "quotes.branch_id"}, + "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "customer_ship_tos.customer_id to customer_branches"}, } // KeyBranchRouteClassFor returns the class the table gives a method and From 2ab31115deee423aa9eaa8db8a498f4b796ff861 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:56:46 -0700 Subject: [PATCH 27/74] security: the principal's PD-4, PD-5 and PD-6 rulings on shared customers, audited grants and AI spend PD-4, the stricter line: a branch bound key may read a customer shared with another branch, but may not add or change a customer scoped pricing rule, category rule or tax exemption for it, because the write prices the other branch's sales to that customer too. A new exclusive customer wall holds those writes to the customers the pin holds alone and refuses the rest with a message naming the other branch; the reads keep the shared customer rule. PD-5: the grant, revoke and home branch writes for the pin branch record their audit rows (user.branch.granted, user.branch.revoked, user.home_branch.set) with the machine key named as the actor. PD-6: the parsing upload and the vision scan stay stated limits and each call records its ai.parsing.upload or ai.vision.scan audit row with the key that spent the dealer's AI credit (both handlers gain the audit logger serve wires). The drivers ruling (PD-3) landed with the stated limit split. Signed-off-by: colton --- core/internal/app/serve/serve.go | 6 +- .../app/serve/wire_customer_branch.go | 54 ++++- .../app/serve/wire_key_branch_allow_test.go | 184 ++++++++++++++++++ .../app/serve/wire_key_branch_pin_test.go | 13 +- core/internal/location/handler.go | 32 +++ core/internal/parsing/handler.go | 32 ++- core/internal/vision/handler.go | 31 ++- core/pkg/middleware/keybranch.go | 100 ++++++++++ 8 files changed, 440 insertions(+), 12 deletions(-) diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 8972ddc7..60c31206 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -278,7 +278,7 @@ func Run() { // AI Parsing Module (Material List Intake) parsingSvc := parsing.NewService(productRepo, aiClient) - parsingHandler := parsing.NewHandler(parsingSvc) + parsingHandler := parsing.NewHandler(parsingSvc).WithAuditLog(auditLog) parsingHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) // PIM Module (AI-Powered Product Information Management) — text generation and @@ -299,7 +299,7 @@ func Run() { locationSvc, locationUserRepo, middleware.RequireRole("admin", "owner"), - ) + ).WithAuditLog(auditLog) // Location routes run behind the branch middleware through // branchWall.locations: POST /locations writes into a branch's tree, the // by-id reads and the list are held to the caller's branches (the branch @@ -807,7 +807,7 @@ func Run() { // AI Vision Module (Sprint 19: Blueprint Verification Prototype) visionSvc := vision.NewService() - visionHandler := vision.NewHandler(visionSvc) + visionHandler := vision.NewHandler(visionSvc).WithAuditLog(auditLog) visionHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) // Governance App (converted — reference conversion #2) diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 5b617610..f0a6d6fd 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -344,14 +344,56 @@ func categoryRuleBodyCustomers(ctx context.Context, r *http.Request) ([]uuid.UUI return element(one) } +// exemptionBodyCustomers resolves the customer the exemption create's body +// names (the exclusive write resolver; the read wall shares it through its +// own body arm). +func exemptionBodyCustomers() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + var body struct { + CustomerID string `json:"customer_id"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + if id, err := uuid.Parse(body.CustomerID); err == nil { + return []uuid.UUID{id}, nil + } + return nil, nil + } +} + +// exemptionRowCustomers resolves the customer the exemption delete's row +// belongs to (the exclusive write resolver). +func exemptionRowCustomers(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + customerOf := exemptionCustomerOf(db) + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + raw := r.PathValue("id") + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, nil // the handler answers the malformed id + } + customer, err := customerOf(ctx, id) + if err != nil { + return nil, err + } + if customer == nil { + return nil, nil + } + return []uuid.UUID{*customer}, nil + } +} + // taxCustomerWall composes the tax role guard with the customer branch wall: // the exemption create's body names the customer, the delete's row belongs // to one, the read's path names one, and the preview's body names one when // it names any. func taxCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - create := middleware.Compose(role, keyWall.CustomerBodyBranch()) - remove := middleware.Compose(role, keyWall.CustomerRowBranch(exemptionCustomerOf(db))) + create := middleware.Compose(role, keyWall.CustomerBranchExclusive(exemptionBodyCustomers())) + remove := middleware.Compose(role, keyWall.CustomerBranchExclusive(exemptionRowCustomers(db))) read := middleware.Compose(role, keyWall.CustomerBranch(exemptionCustomerPathOf())) preview := middleware.Compose(role, keyWall.CustomerBranch(previewCustomerOf(db))) return func(next http.Handler) http.Handler { @@ -417,7 +459,7 @@ func calculateCustomersOf(db *database.DB) func(ctx context.Context, r *http.Req // allowlist gate and never reaches these arms. func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - create := middleware.Compose(role, keyWall.CustomerBranch(pricingRuleCustomersOf(db))) + create := middleware.Compose(role, keyWall.CustomerBranchExclusive(pricingRuleCustomersOf(db))) calculate := middleware.Compose(role, keyWall.CustomerBranch(calculateCustomersOf(db))) return func(next http.Handler) http.Handler { createHandler, calculateHandler, roleHandler := create(next), calculate(next), role(next) @@ -444,9 +486,9 @@ func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB // route allowlist gate; the categories routes are stated limits. func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) - body := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleBodyCustomers)) - byRow := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleRowCustomerOf(db))) - byBulkIDs := middleware.Compose(role, keyWall.CustomerBranch(bulkRuleCustomersOf(db))) + body := middleware.Compose(role, keyWall.CustomerBranchExclusive(categoryRuleBodyCustomers)) + byRow := middleware.Compose(role, keyWall.CustomerBranchExclusive(categoryRuleRowCustomerOf(db))) + byBulkIDs := middleware.Compose(role, keyWall.CustomerBranchExclusive(bulkRuleCustomersOf(db))) list := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", true))) resolve := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", false))) return func(next http.Handler) http.Handler { diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index 254caa7b..dcc1c406 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -13,8 +13,11 @@ package serve // everyone else. All through the production chain the fixture builds. import ( + "bytes" "context" "fmt" + "io" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -586,3 +589,184 @@ func TestKeyBranchPin_StatedLimitWritesRefused(t *testing.T) { } } } + +// TestKeyBranchPin_SharedCustomerWrites holds the stricter line of the +// principal's PD-4 ruling: a branch bound key may READ a customer shared +// with another branch, but may not add or change a customer scoped pricing +// rule or tax exemption for it, because the write prices the other branch's +// sales to that customer too. The refusal names the other branch; a customer +// the pin holds alone passes; the unbound key and the user keep both. +func TestKeyBranchPin_SharedCustomerWrites(t *testing.T) { + f := newKeyPinFixture(t) + // A customer the pin shares with branch B, and the pin's own alone. + shared := uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'pin shared', $2, $3)`, + shared, "PINSH-"+shared.String()[:8], f.branchA); err != nil { + t.Fatalf("seed shared customer: %v", err) + } + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2), ($1, $3)`, + shared, f.branchA, f.branchB); err != nil { + t.Fatalf("seed shared branches: %v", err) + } + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_branches WHERE customer_id = $1`, shared) + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customers WHERE id = $1`, shared) + }) + sh := shared.String() + + // The read passes: the exemption read of the shared customer answers. + f.allow(t, "bound", "GET", "/api/v1/tax/exemptions/"+sh, "", nil, http.StatusOK) + + // Every customer scoped write on the shared customer is refused with its + // audit row, the message naming branch B, and nothing written. + before := struct{ rules, catRules, exemptions int }{} + counts := func() (int, int, int) { + var r, c, e int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT (SELECT COUNT(*) FROM pricing_rules WHERE customer_id = $1), + (SELECT COUNT(*) FROM category_pricing_rules WHERE customer_id = $1), + (SELECT COUNT(*) FROM tax_exemptions WHERE customer_id = $1)`, shared).Scan(&r, &c, &e); err != nil { + t.Fatal(err) + } + return r, c, e + } + refusedNaming := func(method, path, body string) { + t.Helper() + b := f.auditRows(t) + status, buf := f.key(t, f.bound, method, path, body, nil) + if status != http.StatusForbidden { + t.Errorf("bound key %s %s: %d %s, want 403", method, path, status, buf) + return + } + if !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + t.Errorf("bound key %s %s: the refusal does not name the other branch: %s", method, path, buf) + } + if after := f.auditRows(t); after != b+1 { + t.Errorf("bound key %s %s: key.branch_refused rows %d then %d, want one new row", method, path, b, after) + } + } + refusedNaming("POST", "/api/v1/pricing/rules", pricingRuleBody("shared", shared)) + refusedNaming("POST", "/api/v1/pricing/category-rules", categoryRuleBody(shared, f.freshCategory(t))) + refusedNaming("POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, sh)) + exemptShared := uuid.New() + f.seedTaxExempt(t, exemptShared, shared) + refusedNaming("DELETE", "/api/v1/tax/exemptions/"+exemptShared.String(), "") + catShared := uuid.New() + f.seedCatRule(t, catShared, shared, f.freshCategory(t)) + before.rules, before.catRules, before.exemptions = counts() + refusedNaming("PUT", "/api/v1/pricing/category-rules/"+catShared.String(), + `{"rule_type":"markdown","value_pct":"2"}`) + refusedNaming("DELETE", "/api/v1/pricing/category-rules/"+catShared.String(), "") + r, c, e := counts() + if r != before.rules || c != before.catRules || e != before.exemptions { + t.Errorf("refused shared customer writes changed rows: rules %d->%d, category %d->%d, exemptions %d->%d", + before.rules, r, before.catRules, c, before.exemptions, e) + } + + // A customer the pin holds alone passes every one of the same writes. + f.allow(t, "bound", "POST", "/api/v1/pricing/rules", pricingRuleBody("own", f.custID), nil, http.StatusCreated) + f.allow(t, "bound", "POST", "/api/v1/pricing/category-rules", + categoryRuleBody(f.custID, f.freshCategory(t)), nil, http.StatusCreated) + f.allow(t, "bound", "POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, f.custID.String()), nil, http.StatusCreated) + + // The unbound key and the user keep the shared customer's writes (each on + // its own fresh target). + for _, who := range []string{"unbound", "user"} { + f.allow(t, who, "POST", "/api/v1/pricing/rules", pricingRuleBody(who, shared), nil, http.StatusCreated) + f.allow(t, who, "POST", "/api/v1/tax/exemptions", + fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin"}`, sh), nil, http.StatusCreated) + } +} + +// TestKeyBranchPin_GrantWritesAreAudited holds the PD-5 ruling's leg: a +// shared user's grant, revoke and home branch for the pin branch is accepted +// as confined and audited, the audit row naming the machine key that acted. +func TestKeyBranchPin_GrantWritesAreAudited(t *testing.T) { + f := newKeyPinFixture(t) + A := f.branchA.String() + grantRows := func(action string) (n int) { + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = $1 AND actor_kind = 'key'`, action).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + before := grantRows("user.branch.granted") + f.allow(t, "bound", "POST", "/api/v1/users/pin-audit-grant/branches", fmt.Sprintf(`{"branch_id":%q}`, A), nil, http.StatusNoContent) + if after := grantRows("user.branch.granted"); after != before+1 { + t.Errorf("grant audit rows: %d then %d, want one new row naming the key", before, after) + } + before = grantRows("user.home_branch.set") + f.allow(t, "bound", "PUT", "/api/v1/users/pin-audit-grant/home-branch", fmt.Sprintf(`{"branch_id":%q}`, A), nil, http.StatusNoContent) + if after := grantRows("user.home_branch.set"); after != before+1 { + t.Errorf("home branch audit rows: %d then %d, want one new row naming the key", before, after) + } + before = grantRows("user.branch.revoked") + f.allow(t, "bound", "DELETE", "/api/v1/users/pin-audit-grant/branches/"+A, "", nil, http.StatusNoContent) + if after := grantRows("user.branch.revoked"); after != before+1 { + t.Errorf("revoke audit rows: %d then %d, want one new row naming the key", before, after) + } +} + +// TestKeyBranchPin_AISpendsWithItsKey holds the PD-6 ruling: the parsing +// upload and the vision scan stay stated limits, and each call is audited +// with the key that spent the dealer's AI credit. +func TestKeyBranchPin_AISpendsWithItsKey(t *testing.T) { + f := newKeyPinFixture(t) + spendRows := func() (n int) { + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action IN ('ai.parsing.upload', 'ai.vision.scan') AND actor_kind = 'key'`).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + // The upload: a small multipart body; the AI client is unconfigured, so + // the answer is the module's own (synthetic), never the gate's refusal. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "pin-material.csv") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("sku,qty\nPIN-1,2\n")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("POST", f.srv.URL+"/api/v1/parsing/upload", body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+f.bound) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + uploadBody, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode == http.StatusForbidden { + t.Errorf("bound key parsing upload refused: %s", uploadBody) + } + if n := spendRows(); n < 1 { + t.Errorf("parsing upload audit rows naming the key: %d, want at least one", n) + } + + before := spendRows() + status, buf := f.key(t, f.bound, "POST", "/api/v1/vision/scan", `{"blueprint_text":"pin wall probe"}`, nil) + if status == http.StatusForbidden { + t.Errorf("bound key vision scan refused: %s", buf) + } + if after := spendRows(); after != before+1 { + t.Errorf("vision scan audit rows naming the key: %d then %d, want one new row", before, after) + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 825e41c1..8123b020 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -48,6 +48,7 @@ import ( "github.com/gablelbm/gable/internal/millwork" "github.com/gablelbm/gable/internal/notification" "github.com/gablelbm/gable/internal/order" + "github.com/gablelbm/gable/internal/parsing" "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/internal/pim" "github.com/gablelbm/gable/internal/pos" @@ -62,6 +63,7 @@ import ( "github.com/gablelbm/gable/internal/testutil" "github.com/gablelbm/gable/internal/unit" "github.com/gablelbm/gable/internal/vendor" + "github.com/gablelbm/gable/internal/vision" "github.com/gablelbm/gable/pkg/apps" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" @@ -150,6 +152,7 @@ var pinKeyScopes = []string{ "millwork:read", "millwork:write", "governance:read", "governance:write", "matching:read", "matching:write", "delivery:read", "delivery:write", "apps:read", "apps:write", "admin:settings", "admin:modules", + "parsing:write", "vision:write", } func newKeyPinFixture(t *testing.T) *keyPinFixture { @@ -343,7 +346,8 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { wall := newBranchWall(db) mux := http.NewServeMux() wall.locations(mux, location.NewHandler(location.NewService(location.NewRepository(db)), - location.NewUserRepository(db), middleware.RequireRole("admin", "owner"))) + location.NewUserRepository(db), middleware.RequireRole("admin", "owner")). + WithAuditLog(audit.NewLogger(db))) // The exposure surface exactly as wireExposure registers it (the index // admin surface with the market index refresh comes with it). @@ -418,6 +422,13 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithAuditLog(audit.NewLogger(db)) pos.NewHandler(posSvc).RegisterRoutes(mux, wall.scoped("admin", "owner", "cashier")) + // The AI spending routes exactly as serve registers them, each call + // audited with whoever spent the credit (the PD-6 ruling). + parsing.NewHandler(parsing.NewService(product.NewRepository(db), nil)). + WithAuditLog(audit.NewLogger(db)).RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "sales")) + vision.NewHandler(vision.NewService()).WithAuditLog(audit.NewLogger(db)). + RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + // The dealer wide catalogue and configuration modules exactly as serve // registers them: products, PIM, the unit catalogue, the payment terms on // the customer handler, vendors, EDI partners, charge codes, rebate diff --git a/core/internal/location/handler.go b/core/internal/location/handler.go index b3a73292..80c485d1 100644 --- a/core/internal/location/handler.go +++ b/core/internal/location/handler.go @@ -9,10 +9,12 @@ import ( "encoding/json" "errors" "io" + "log/slog" "net/http" "time" "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/httputil" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" @@ -25,6 +27,33 @@ type Handler struct { guard LocationGuard // optional; see WithBranchWall branchMw func(http.Handler) http.Handler // optional; see WithBranchWall keyWall *middleware.KeyBranchWall // optional; see WithKeyBranchWall + auditLog *audit.Logger // optional; see WithAuditLog +} + +// WithAuditLog sets the audit logger the grant, revoke and home branch +// writes record their row through (ADR 0007 section 5.5, the PD-5 ruling: a +// shared user's grant, revoke and home branch for the pin branch is accepted +// as confined and audited, the audit row naming whoever acted, a user or the +// machine key). +func (h *Handler) WithAuditLog(l *audit.Logger) *Handler { + h.auditLog = l + return h +} + +// recordGrant audits one grant surface write; a missing logger or a failed +// row never blocks the write (the mutation is already committed). +func (h *Handler) recordGrant(ctx context.Context, action string, branch uuid.UUID, changes map[string]interface{}) { + if h.auditLog == nil { + return + } + if err := h.auditLog.Log(ctx, audit.Entry{ + Action: action, + EntityType: "user_branch", + EntityID: branch, + Changes: changes, + }); err != nil { + slog.Error("failed to audit the grant write", "action", action, "error", err) + } } // NewHandler constructs the location handler. userRepo and adminGuards may be @@ -839,6 +868,7 @@ func (h *Handler) GrantUserBranch(w http.ResponseWriter, r *http.Request) { httputil.RespondError(w, r, "failed to grant", http.StatusInternalServerError, err) return } + h.recordGrant(r.Context(), "user.branch.granted", req.BranchID, map[string]interface{}{"sub": sub}) w.WriteHeader(http.StatusNoContent) } @@ -857,6 +887,7 @@ func (h *Handler) RevokeUserBranch(w http.ResponseWriter, r *http.Request) { httputil.RespondError(w, r, "failed to revoke", http.StatusInternalServerError, err) return } + h.recordGrant(r.Context(), "user.branch.revoked", branchID, map[string]interface{}{"sub": sub}) w.WriteHeader(http.StatusNoContent) } @@ -883,6 +914,7 @@ func (h *Handler) SetHomeBranch(w http.ResponseWriter, r *http.Request) { httputil.RespondError(w, r, "failed to set home", http.StatusInternalServerError, err) return } + h.recordGrant(r.Context(), "user.home_branch.set", req.BranchID, map[string]interface{}{"sub": sub}) w.WriteHeader(http.StatusNoContent) } diff --git a/core/internal/parsing/handler.go b/core/internal/parsing/handler.go index b3cb7451..b1f3310d 100644 --- a/core/internal/parsing/handler.go +++ b/core/internal/parsing/handler.go @@ -5,6 +5,7 @@ package parsing import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -14,13 +15,16 @@ import ( "strings" "time" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/httputil" + "github.com/google/uuid" "github.com/xuri/excelize/v2" ) // Handler manages HTTP requests for material list parsing. type Handler struct { - service *Service + service *Service + auditLog *audit.Logger } // NewHandler creates a new parsing Handler. @@ -28,6 +32,31 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +// WithAuditLog sets the audit logger every upload records its row through +// (ADR 0007 section 5.5, the PD-6 ruling): the parse spends the dealer's AI +// credit, so each call is audited with whoever spent it, a user or the +// machine key. +func (h *Handler) WithAuditLog(l *audit.Logger) *Handler { + h.auditLog = l + return h +} + +// recordUpload audits one upload; a missing logger or a failed row never +// blocks the answer (the spend already happened when the service answered). +func (h *Handler) recordUpload(ctx context.Context, filename string) { + if h.auditLog == nil { + return + } + if err := h.auditLog.Log(ctx, audit.Entry{ + Action: "ai.parsing.upload", + EntityType: "ai_spend", + EntityID: uuid.Nil, + Changes: map[string]interface{}{"filename": filename}, + }); err != nil { + slog.Error("failed to audit the parsing upload", "error", err) + } +} + // RegisterRoutes adds parsing handlers to the mux. func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guard := func(handler http.HandlerFunc) http.HandlerFunc { @@ -138,6 +167,7 @@ func (h *Handler) HandleUpload(w http.ResponseWriter, r *http.Request) { "filename", header.Filename, "item_count", len(items)) } + h.recordUpload(r.Context(), header.Filename) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) diff --git a/core/internal/vision/handler.go b/core/internal/vision/handler.go index b7eef27d..a4c9b4a4 100644 --- a/core/internal/vision/handler.go +++ b/core/internal/vision/handler.go @@ -4,21 +4,49 @@ package vision import ( + "context" "encoding/json" "log/slog" "net/http" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/httputil" + "github.com/google/uuid" ) type Handler struct { - service *Service + service *Service + auditLog *audit.Logger } func NewHandler(service *Service) *Handler { return &Handler{service: service} } +// WithAuditLog sets the audit logger every scan records its row through +// (ADR 0007 section 5.5, the PD-6 ruling): the scan spends the dealer's AI +// credit, so each call is audited with whoever spent it, a user or the +// machine key. +func (h *Handler) WithAuditLog(l *audit.Logger) *Handler { + h.auditLog = l + return h +} + +// recordScan audits one scan; a missing logger or a failed row never blocks +// the answer (the spend already happened when the service answered). +func (h *Handler) recordScan(ctx context.Context) { + if h.auditLog == nil { + return + } + if err := h.auditLog.Log(ctx, audit.Entry{ + Action: "ai.vision.scan", + EntityType: "ai_spend", + EntityID: uuid.Nil, + }); err != nil { + slog.Error("failed to audit the vision scan", "error", err) + } +} + func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guard := func(handler http.HandlerFunc) http.HandlerFunc { if len(roleGuard) > 0 && roleGuard[0] != nil { @@ -47,6 +75,7 @@ func (h *Handler) handleScan(w http.ResponseWriter, r *http.Request) { } resp := h.service.ScanBlueprint(req) + h.recordScan(r.Context()) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index bdff24dd..e92b4d94 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -265,6 +265,106 @@ func (w *KeyBranchWall) CustomerRowBranch(customerOf func(ctx context.Context, i }) } +// CustomerBranchExclusive wraps a handler whose request adds or changes a +// customer scoped pricing or tax definition (the stricter line of the +// principal's PD-4 ruling): unlike CustomerBranch, the read rule, the write +// is held to the customers the pin holds ALONE, because a rule or exemption +// written on a customer shared with another branch prices that branch's +// sales to the customer too. A customer held by any branch outside the pin +// is refused with a message naming that branch; a customer with no +// customer_branches row holds no branch at all and is refused the same way +// as by the read wall. A resolver failure answers 500 rather than failing +// open, and a resolver may hand back ErrBodyRefused. +func (w *KeyBranchWall) CustomerBranchExclusive(customersOf func(ctx context.Context, r *http.Request) ([]uuid.UUID, error)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + pin, ok := KeyBranchPin(r.Context()) + if !ok { + next.ServeHTTP(rw, r) + return + } + customers, err := customersOf(r.Context(), r) + if err != nil { + if errors.Is(err, errRefused) { + w.refuse(r.Context(), rw, r, pin, "customer_id", "") + return + } + slog.Error("key branch wall: customer branch lookup failed", "error", err, "method", r.Method, "path", r.URL.Path) + respondAuthError(rw, r, http.StatusInternalServerError, "internal_error", "branch lookup failed") + return + } + if len(customers) > 0 { + other, held, err := w.customerHeldOutsidePin(r.Context(), customers, pin) + if err != nil { + slog.Error("key branch wall: customer branch lookup failed", "error", err, "method", r.Method, "path", r.URL.Path) + respondAuthError(rw, r, http.StatusInternalServerError, "internal_error", "branch lookup failed") + return + } + if !held || other != uuid.Nil { + if keyID, ok := KeyIDFromContext(r.Context()); ok && w.auditor != nil { + w.auditor.AuditKeyBranchRefusal(r.Context(), keyID, pin, r.Method, r.URL.Path) + } + message := "a branch bound key may act only on its own branch" + if other != uuid.Nil { + message = message + ": the customer is also held by branch " + other.String() + } + writeAuthError(rw, r, http.StatusForbidden, "forbidden", message, []string{"customer_id"}) + return + } + } + next.ServeHTTP(rw, r) + }) + } +} + +// customerHeldOutsidePin answers one branch that holds any of the customers +// besides the pin, and whether the pin holds every one of them at all. A +// customer the pin does not hold (no row, or only other branches) sets held +// false; the message names the other branch only when one exists. +func (w *KeyBranchWall) customerHeldOutsidePin(ctx context.Context, customers []uuid.UUID, pin uuid.UUID) (uuid.UUID, bool, error) { + if w.db == nil { + return uuid.Nil, false, errors.New("key branch wall: no database for the customer branch lookup") + } + distinct := make(map[uuid.UUID]struct{}, len(customers)) + for _, c := range customers { + distinct[c] = struct{}{} + } + ids := make([]uuid.UUID, 0, len(distinct)) + for c := range distinct { + ids = append(ids, c) + } + var holdingPin int + if err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT COUNT(DISTINCT customer_id) FROM customer_branches WHERE branch_id = $1 AND customer_id = ANY($2)`, + pin, ids).Scan(&holdingPin); err != nil { + return uuid.Nil, false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) + } + if holdingPin != len(ids) { + // Some named customer is not held by the pin at all; name another of + // its branches when one exists. + var other *uuid.UUID + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) LIMIT 1`, ids).Scan(&other) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) + } + if other != nil { + return *other, false, nil + } + return uuid.Nil, false, nil + } + var outside *uuid.UUID + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) AND branch_id <> $2 LIMIT 1`, ids, pin).Scan(&outside) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) + } + if outside != nil { + return *outside, true, nil + } + return uuid.Nil, true, nil +} + // bodyCustomers reads the customer_ids a body names, one object or an array // of them. An id that does not parse names nothing the wall can hold, and // the handler's own validation answers it. When required is set, an object From e7c04370129866601218fbe213c164fefd061fce Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:59:06 -0700 Subject: [PATCH 28/74] docs: ADR 0007 5.5, CONTRACT-CHANGES and the module pages say what the code now does The ADR's section 5.5 records the principal's rulings: WALLED is a tested claim whose entries name their filter column; the category rule invariant and its migration; the stricter shared customer line (read yes, priced writes no, the refusal naming the other branch); the audited grant, revoke and home branch writes; the stated limit as a read, never a write (the catalogue and configuration writes, the drivers refused reads included, the AI spend audited); and the account and calculation confinements. CONTRACT-CHANGES gains the fix round 3 row naming every changed answer, and the delivery, charge codes, products and locations module pages state the same: the fleet split, the refused catalogue writes, the audited grants. Signed-off-by: colton --- ...7-drafts-links-and-confirm-gated-scopes.md | 96 +++++++++++++------ docs/modules/charge-codes.md | 5 +- docs/modules/delivery.md | 8 +- docs/modules/locations.md | 5 +- docs/modules/products.md | 9 ++ docs/refactor/CONTRACT-CHANGES.md | 1 + 6 files changed, 90 insertions(+), 34 deletions(-) diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 9d181584..05669641 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -704,6 +704,14 @@ machine key reachable census route is unclassified or a table entry is stale, so a new route cannot appear without a class decision. Users and unbound keys keep exactly their reach on every route. +A WALLED entry is a tested claim, not a label: its note names the column or +join its module filters on (`quotes.branch_id`, a customer through +`customer_branches`, a delivery through its order's branch, the dashboard +and inventory through `locations.branch_id`), and a sweep drives every WALLED +route that takes an id or a list with the other branch's twin row as a bound +key's target: the foreign row answers exactly what a missing row answers (or +a 403), never names the row, and nothing is written. + The confinement contract the CONFINED routes hold: a body is read whole (a body that is not exactly one complete JSON value is refused, so the wall and the handler can never disagree about what a body names); a location row that @@ -715,36 +723,64 @@ exemption read by its path customer, the category rule list by its `customer_id`, the rule audit by its row's customer, and resolve and calculate by their `customer_id`). A pricing rule names a customer through its `customer_id` or its `job_id` (a projects row belongs to exactly one -customer, and a rule on the job prices that customer), and both are checked. -A pricing rule that names neither a customer nor a job, and a category rule -or bulk element that targets a tier, price every branch's customers and are -refused. The unfiltered lists (`GET /pricing/rules`, the category rule list -with no `customer_id`, `GET /pricing/matrix`) cannot be confined to one -branch and are REFUSED. - -The stated limits, each with its one line reason in the table: the branch -directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`, -reference data by the PR 39 decision); the dealer wide reference data that -carries no branch fact (the unit catalogue, vendors, EDI partners and their -catalogs, millwork and configurator options, governance rfcs, the vision -scan, the parsing upload, the product pricing categories, the rebate -programs and their claims, the price level vocabulary, the market index -metadata list edit and history, the price escalation calculator, the match -tolerances); the delivery fleet (drivers and vehicles carry no branch -column); and the dealer wide switches (admin settings, the admin module kill -switches, app enablement). An operator should not mint on a bound key the -scopes that buy only these limits: `admin:settings`, `admin:modules`, -`apps:write`, `units:write`, `vendors:write`, `configurator:write`, -`millwork:write`, `vision:write`, `edi:write`, `governance:write` and -`parsing:write`; `market-indices:write` and `pricing:write` reach stated -limit writes (the index metadata; the categories and rebates) beside their -confined uses, and `delivery:write` reaches the fleet, so mint them on a -bound key only for those confined uses. REFUSED outright, whatever scope a -key holds: the GL, AP and bank reconciliation surfaces (reads and writes -alike), every report and builder route, the events feed, the sales team -roster, the staff roster and its module grants, the known users list, the -exposure lists and the scan, the market index refresh and its preview, the -branch directory create, and the unfiltered pricing lists above. +customer, and a rule on the job prices that customer), and both are checked, +on the write and on the calculation alike (the calculation resolves its +`job_id` the same way). A tax preview that names a `ship_to_id` is confined +through that ship to's customer. The account summary, the customer subledger +and the AR statement name a customer in the path and are confined the same +way; the AR reconciliation reads every branch's books and is REFUSED. A +pricing rule that names neither a customer nor a job, and a category rule or +bulk element whose target is not an account rule or that carries a tier, +price every branch's customers and are refused; the handler keeps the same +invariant on its own side, and migration 107 puts it on the table (an account +rule carries no tier, a tier rule no customer, existing rows checked first +and reported by a NOTICE, never rewritten, the constraint validated on add +when none breaks it and NOT VALID when one does). A category rule that also +names a customer while targeting a tier is refused however many branches of +that customer hold the pin: it prices every branch's customers of the tier. + +The stricter line on shared customers: a bound key may READ a customer its +branch shares with another, but may not add or change a customer scoped +pricing rule or tax exemption for it, because the write prices the other +branch's sales to that customer too. Those writes are held to the customers +the pin holds alone; the refusal names the other branch. A branch +integration that must price shared customers holds an unbound key an operator +keeps. A shared user's grant, revoke and home branch for the pin branch is +accepted as confined and audited: the writes record their audit rows +(`user.branch.granted`, `user.branch.revoked`, `user.home_branch.set`) with +the machine key named as the actor. + +The stated limit is a read, never a write: a bound key exists for a branch +scoped integration and no dealer wide write is branch scoped, so every +stated limit write is REFUSED and the reads stay stated limits, each with its +one line reason in the table. The reads: the branch directory (`GET +/api/v1/branches`, `GET /api/v1/branches/{id}`, reference data by the PR 39 +decision), the dealer wide reference data that carries no branch fact (the +product catalogue and its PIM content, the unit catalogue, vendors, EDI +partners and their catalogs, millwork and configurator options, governance +rfcs, the product pricing categories, the rebate programs and their claims, +the price level vocabulary, the market index history, the price escalation +calculator, the match tolerances, the charge codes, the payment terms), the +delivery fleet's vehicles, the AI settings and routing reads, the module and +app switches' reads, and the two calculators that spend the dealer's AI +credit (the parsing upload and the vision scan; each call is audited with +the key that spent it, `ai.parsing.upload` and `ai.vision.scan`). The +writes refused with them: the AI and routing settings (a bound key must not +rewrite the provider key or the base URL every branch's AI traffic rides +on), the module kill switches and app enablement, the match tolerances (a +financial control), vendors, EDI partners and their catalog import, units, +pricing categories, rebate programs, millwork options, governance rfcs and +their transitions, the market index metadata, the product catalogue writes +and the PIM writes (products, PIM content, margins, dimensions, lead time, +kit components, unit sets; the PIM generate routes spend the dealer's AI +credit), the charge codes and payment terms, and the reorder target refresh. +The drivers are refused reads included: driver rows carry personal data and +no branch column. REFUSED outright, whatever scope a key holds: the GL, AP +and bank reconciliation surfaces (reads and writes alike), the AR +reconciliation, every report and builder route, the events feed, the sales +team roster, the staff roster and its module grants, the known users list, +the exposure lists and the scan, the market index refresh and its preview, +the branch directory create, and the unfiltered pricing lists above. One branch per key, not a set: the repositories' branch idiom filters on one branch or none, and a set would need a second idiom in every module. A diff --git a/docs/modules/charge-codes.md b/docs/modules/charge-codes.md index 0cb4e045..a10f374c 100644 --- a/docs/modules/charge-codes.md +++ b/docs/modules/charge-codes.md @@ -62,7 +62,10 @@ handler is `core/internal/chargecode/handler.go`; the route census | PUT | `/api/v1/charge-codes/{id}` | Replace the name, revenue account, taxable flag, default price and `is_active`; `code` is never accepted (a 400); the revision precondition applies (`If-Match` and `revision`); `Idempotency-Key` rides the standard header. | The rows are dealer wide: `charge_codes` carries no `branch_id` and -no row is filtered by branch. The routes still run behind the branch +no row is filtered by branch. The reads are stated limits of the branch +bound key's allowlist (ADR 0007 section 5.5); the writes are dealer wide +writes and are refused to a branch bound key outright, whatever scope it +holds. The routes still run behind the branch middleware, so with branches switched on a non admin caller names a branch the user holds in `X-Branch-Id`, as on other scoped routes. The `include_inactive` flag is the only filter; nothing else narrows the diff --git a/docs/modules/delivery.md b/docs/modules/delivery.md index c02706d6..cc4eed04 100644 --- a/docs/modules/delivery.md +++ b/docs/modules/delivery.md @@ -413,8 +413,12 @@ and `driver` and to the branches the user is granted. A branch bound key (ADR 0007 section 5.5) is pinned to its branch: the machine key core puts that branch into the request's branch -context before the module runs, so lists, reads and writes on these routes see that -branch only. A key +context before the module runs, so lists, reads and writes on the stop and +route routes see that branch only. The fleet rows carry no branch column: +the vehicle reads stay stated limits (dealer wide reference data), every +vehicle write is refused to a bound key, and the driver routes are refused +outright, reads included (driver rows carry personal data and no branch +column). A key bound to no branch behaves as today's claims-less caller: it reads and writes across branches, scoped only by its `delivery:read` or `delivery:write` scope. diff --git a/docs/modules/locations.md b/docs/modules/locations.md index a9cfc256..1870db07 100644 --- a/docs/modules/locations.md +++ b/docs/modules/locations.md @@ -400,7 +400,10 @@ gate. The branch directory reads (`GET /api/v1/branches`, `GET /api/v1/branches/{id}`) stay reference data, unwalled by the PR 39 decision, a stated limit. A bound key that sets a user's home to its own branch clears `is_home` on that user's other grant rows: a side effect -inside the user's own grant set, not a cross branch grant. An unbound key +inside the user's own grant set, not a cross branch grant, and the grant, +revoke and home branch writes for the pin branch each record their audit row +(`user.branch.granted`, `user.branch.revoked`, `user.home_branch.set`) with +the machine key named as the actor (ADR 0007 section 5.5). An unbound key behaves as before. ## ADRs that govern this module diff --git a/docs/modules/products.md b/docs/modules/products.md index 1557177b..db8803ed 100644 --- a/docs/modules/products.md +++ b/docs/modules/products.md @@ -160,6 +160,15 @@ in `core/internal/app/serve/serve.go` at the `pimHandler.RegisterRoutes` line. A key without the scope is `403 forbidden`; the audit row carries the refused scope. +The catalogue is dealer wide: `products` and the PIM tables carry no +`branch_id` (only the stock totals narrow by branch). For a branch bound key +(ADR 0007 section 5.5) the catalogue and PIM reads are stated limits of the +allowlist and every catalogue or PIM write is refused outright, whatever +scope the key holds: a margin target, a dimension, a lead time, a kit +definition or a unit set written by one branch's integration changes every +branch's catalogue, and the PIM generate routes spend the dealer's AI +credit. + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 6, 7, 7a, 8, 9, 11, 12. diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 17294b40..445edc0c 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -275,3 +275,4 @@ get an entry. | C5-2a | `PUT /api/v1/quotes/{id}/file` | No such route: the AI parse's original file could only enter through the quote create, which a draft promotion replaces | The quote file route of ADR 0007 section 10: the raw file bytes with their content type, the same 5 MiB bound (413 above it), `quotes:write`, the revision precondition (428, 409), accepted only while the quote is in status `draft` (else 409 `invalid_state_transition` with the `quote_not_draft` blocker); it replaces any stored file, moves the revision by one, returns the quote with its new `ETag`, applies the branch wall like the read, writes the `quote.file_attached` audit row and no outbox event. Being an entity write on a gated module it is refused to an agent marked session | | C5-2a | `GET /api/v1/links/quotes/{id}`, `GET /api/v1/links/orders/{id}`, `GET /api/v1/links/invoices/{id}`, `GET /api/v1/links/customers/{id}`, `GET /api/v1/links/products/{id}`, `GET /api/v1/links/drafts/quotes/{id}`, `GET /api/v1/links/drafts/orders/{id}` | No link resolver existed: every frontend and agent carried its own route table | The link resolver of ADR 0007 section 8: `{id}` is a UUID or the entity's document number, the record must exist and be visible to the caller (the module's own read, so an invisible record is the same 404 a missing one gets), and the answer carries the desk link (absolute when `GABLE_PUBLIC_URL` is set, origin relative otherwise), the front door's `/?open=` form, `gable://` for the shell, a null portal until it has record screens, and the agent slot from `GABLE_AGENT_URL_TEMPLATE` (null when unset). The scope is read off the URL: the module `read` for an entity link, `propose` or `commit` for a draft link. The declarative table is the only copy and generates `core/api/links.json`; `core/api/link-paths.json` carries the plain segment vectors the shell, the door and their tests share | | SEC-BOUND-KEY-PIN | the whole `/api/v1` surface a machine key can reach (345 census routes, method plus pattern), held by one allowlist, `core/pkg/middleware/keybranch_allow.go` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin: a scope checked key passes the role guards on scope alone, and the fix that walled each route the sweep found was opt in, so every route a round missed failed open. Rounds 3 and 4 proved it by probe: the market index refresh preview returned other branches' customers and exposure; the customer scoped pricing and tax reads answered about branch B; a pricing rule naming a branch B customer's job set that customer's price with no audit row; dealer wide pricing writes (a rule naming neither customer nor job, a tier category rule) passed; and the GL, AP and bank reconciliation writes, the staff roster and the unfiltered pricing lists were open | Deny by default. One allowlist classifies every machine key reachable route as exactly one of WALLED (183, behind the branch middleware), CONFINED (28, held by the key branch wall or the customer branch wall, each entry naming its test), STATED LIMIT (69, reachable unconfined by design, each entry with its one line reason) or REFUSED (65, answered 403 `forbidden` with the `key.branch_refused` row). The one runtime check is `KeyBranchRouteGate`, mounted once in serve's chain inside auth: it resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, a route the router resolves to no pattern included, so a missed or new route fails closed. A census test (untagged, no database) fails when a machine key reachable census route is unclassified or a table entry is stale. The confinements are completed: `job_id` resolves to its projects row's customer beside `customer_id` (both are checked), the customer scoped reads that name a customer in a path, query or body are confined like the writes (the exemption read, the category rule list by `customer_id` with the unfiltered list refused, the rule audit, resolve, calculate, the tax preview), and a write that would be dealer wide is refused: a pricing rule naming neither a customer nor a job, a category rule or bulk element targeting a tier, and a bulk delete naming a tier row. REFUSED outright beside the round 3 list: the GL, AP and bank reconciliation writes (reads already refused), the staff roster and its module grants, the index refresh preview, `GET /pricing/rules`, `GET /pricing/matrix` and the unfiltered category rule list. Stated limits stay reachable with their reasons in the table, and ADR 0007 section 5.5 names them and advises which scopes not to mint on a bound key. Users and unbound keys keep exactly their reach on every route | +| SEC-BOUND-KEY-PIN-3 | the branch bound key's classes and walls, fix round 3 (the same 345 census routes as SEC-BOUND-KEY-PIN) | Rounds 5 and 6 proved by probe that three classes overstated: a tier category rule that also named a customer passed the customer wall and priced every branch's customers of the tier (201, no audit row); the POS till, Z report, return and line routes and the account summary, AR statement and reconciliation loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), added a line to B's transaction that stayed written behind the 500, and read B's till report, Z reports, returns, account, statement and reconciliation; `GET /pricing/calculate` confined its `customer_id` but not its `job_id`; a tax preview naming only B's `ship_to_id` passed; and the 18 dealer wide catalogue writes labelled WALLED (products, PIM, charge codes, payment terms, the reorder refresh) and every other stated limit write were reachable by a bound key | The category rule wall, handler and table now keep one invariant (an account rule carries no tier, a tier rule no customer), and migration 107 puts it on the table checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when one exists, validated on add otherwise. The pos repositories carry the branch predicate the transaction reads already carried (`till_sessions.branch_id`, `till_z_reports.branch_id`, `pos_returns.branch_id`, the line writes through their transaction's branch, the insert never happening on a foreign transaction), the account summary, subledger and statement mount the customer branch wall on the path customer, and the AR reconciliation is refused outright (its ledger half carries no branch fact). The calculation resolves `job_id` to `projects.customer_id` like the write; a tax preview naming a `ship_to_id` is confined through that ship to's customer. The classes are split per the principal's rulings: a stated limit is a read, never a write (WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118), so every dealer wide write (the catalogue and PIM writes above, the settings, module and app switches, the match tolerances, vendors, EDI partners and the catalog import, units, pricing categories, rebates, millwork options, governance rfcs, the index metadata) is refused with the `key.branch_refused` row, the driver routes are refused reads included (personal data, no branch column), and the vehicle reads stay stated limits. Every WALLED entry names the column or join its module filters on and a twin sweep drives every WALLED id or list route with the other branch's row (refused, or the missing row answer, nothing written). A shared customer may be read by a bound key but its customer scoped pricing rules and tax exemptions are refused unless the pin holds the customer alone, the refusal naming the other branch; the grant, revoke and home branch writes record their audit rows with the key as the actor; the parsing upload and vision scan stay stated limits and each call records its AI spend audit row with the key that spent it | From 2e3488e81263a1897e5a5aa02cc4f31332acf250 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 14:18:53 -0700 Subject: [PATCH 29/74] ci: request the hosted checks on this head Signed-off-by: colton From 0213a781d53a08031f3799011f711887df78c588 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 14:42:01 -0700 Subject: [PATCH 30/74] merge origin/refactor/v1: the AP wire contract rename and the census follow C4-1b renamed POST /ap/invoices/{id}/approve to /ap/invoices/{id}/transitions, so the allowlist entry follows the rename (payables stay refused to a bound key), the fixture's matching stub follows the ap.Invoice type, and the refusal audit counting is scoped to the bound key's own id so another package's refusal rows in the shared test database cannot look like this key's between two reads (the packages run in parallel against one database). Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 105 ++++++++++-------- .../app/serve/wire_key_branch_walled_test.go | 2 +- core/pkg/middleware/keybranch_allow.go | 2 +- 3 files changed, 58 insertions(+), 51 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 8123b020..973edced 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -80,44 +80,46 @@ import ( // routes through branchWall.locations and the exposure routes through // registerExposureRoutes, both behind the real machine key core. type keyPinFixture struct { - t *testing.T - db *database.DB - srv *httptest.Server - branchA uuid.UUID - branchB uuid.UUID - branchC uuid.UUID - branchC2 uuid.UUID - yardA uuid.UUID - yardB uuid.UUID - quoteA uuid.UUID - quoteB uuid.UUID - custID uuid.UUID - yardNull uuid.UUID // a legacy row that names no branch - custB uuid.UUID // a customer whose branches hold branch B only - category uuid.UUID // one product category the priced rules hang on - catRuleA uuid.UUID // a category rule scoped to the pin's customer - catRuleB uuid.UUID // a category rule scoped to the foreign customer - catRuleT uuid.UUID // a tier category rule that names no customer - taxExempt uuid.UUID // a tax exemption row of the foreign customer - jobA uuid.UUID // a project of the pin's customer - jobB uuid.UUID // a project of the foreign customer - regA string // a POS register per branch, on the branch's yard - regB string - tillA uuid.UUID // an OPEN till session per register - tillB uuid.UUID - txnA uuid.UUID // an OPEN POS transaction per branch - txnB uuid.UUID - lineA uuid.UUID // one line item on each transaction - lineB uuid.UUID - retA uuid.UUID // a completed POS return per branch - retB uuid.UUID - zrA uuid.UUID // a frozen Z report per branch's session - zrB uuid.UUID - posProd uuid.UUID // the product the POS lines carry - bound string // the key pinned to branch A - unbound string // the same scopes, no pin - bound2 string // a second bound key holding the document family scopes the mint cap keeps off the first - unbound2 string // the same scopes, no pin + t *testing.T + db *database.DB + srv *httptest.Server + branchA uuid.UUID + branchB uuid.UUID + branchC uuid.UUID + branchC2 uuid.UUID + yardA uuid.UUID + yardB uuid.UUID + quoteA uuid.UUID + quoteB uuid.UUID + custID uuid.UUID + yardNull uuid.UUID // a legacy row that names no branch + custB uuid.UUID // a customer whose branches hold branch B only + category uuid.UUID // one product category the priced rules hang on + catRuleA uuid.UUID // a category rule scoped to the pin's customer + catRuleB uuid.UUID // a category rule scoped to the foreign customer + catRuleT uuid.UUID // a tier category rule that names no customer + taxExempt uuid.UUID // a tax exemption row of the foreign customer + jobA uuid.UUID // a project of the pin's customer + jobB uuid.UUID // a project of the foreign customer + regA string // a POS register per branch, on the branch's yard + regB string + tillA uuid.UUID // an OPEN till session per register + tillB uuid.UUID + txnA uuid.UUID // an OPEN POS transaction per branch + txnB uuid.UUID + lineA uuid.UUID // one line item on each transaction + lineB uuid.UUID + retA uuid.UUID // a completed POS return per branch + retB uuid.UUID + zrA uuid.UUID // a frozen Z report per branch's session + zrB uuid.UUID + posProd uuid.UUID // the product the POS lines carry + bound string // the key pinned to branch A + unbound string // the same scopes, no pin + bound2 string // a second bound key holding the document family scopes the mint cap keeps off the first + unbound2 string // the same scopes, no pin + boundKeyID string // the bound key's id, the actor its refusal audit rows name + bound2KeyID string // the second bound key's id } // pinKeyScopes opens every route under test: the location writes, the branch @@ -564,22 +566,22 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) techadmin.NewHandler(keys).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) - mint := func(name string, branch *uuid.UUID) string { + mint := func(name string, branch *uuid.UUID) (string, string) { t.Helper() scopes := pinKeyScopes if strings.Contains(name, "documents") { scopes = pinDocumentScopes } - raw, _, err := keys.GenerateKey(ctx, name, scopes, branch) + raw, key, err := keys.GenerateKey(ctx, name, scopes, branch) if err != nil { t.Fatalf("mint %s: %v", name, err) } - return raw + return raw, key.ID.String() } - f.bound = mint("pin bound to A", &f.branchA) - f.unbound = mint("pin unbound", nil) - f.bound2 = mint("pin bound to A, documents", &f.branchA) - f.unbound2 = mint("pin unbound, documents", nil) + f.bound, f.boundKeyID = mint("pin bound to A", &f.branchA) + f.unbound, _ = mint("pin unbound", nil) + f.bound2, f.bound2KeyID = mint("pin bound to A, documents", &f.branchA) + f.unbound2, _ = mint("pin unbound, documents", nil) mkAuth := middleware.NewMachineKeyAuth(pinKeyValidator{svc: keys}, audit.NewLogger(db), []string{"/api/integration/"}, nil) // The production chain, built by the same builder Run uses, so every test // here drives the wiring serve serves: the machine key core, the request @@ -645,15 +647,15 @@ func (s pinPOSource) GetPOBranch(ctx context.Context, id uuid.UUID) (*uuid.UUID, // answer before any invoice is needed. type pinAPSource struct{} -func (pinAPSource) ListVendorInvoices(context.Context, *uuid.UUID, string) ([]ap.VendorInvoice, error) { +func (pinAPSource) ListVendorInvoices(context.Context, *uuid.UUID, string) ([]ap.Invoice, error) { return nil, nil } -func (pinAPSource) GetVendorInvoice(context.Context, uuid.UUID) (*ap.VendorInvoice, error) { +func (pinAPSource) GetVendorInvoice(context.Context, uuid.UUID) (*ap.Invoice, error) { return nil, nil } -func (pinAPSource) ApproveInvoice(context.Context, uuid.UUID, uuid.UUID) (*ap.VendorInvoice, error) { +func (pinAPSource) ApproveInvoice(context.Context, uuid.UUID, uuid.UUID) (*ap.Invoice, error) { return nil, nil } @@ -723,11 +725,16 @@ func (f *keyPinFixture) user(t *testing.T, method, path, body string, hdr map[st return res.StatusCode, buf } +// auditRows counts the pin key's refusal rows: scoped to the bound key's +// own id, so another package's test writing a refusal row for its own key +// into the shared database between two reads cannot look like this key's +// second refusal (the packages run in parallel against one database). func (f *keyPinFixture) auditRows(t *testing.T) int { t.Helper() var n int if err := f.db.Pool.QueryRow(context.Background(), - `SELECT COUNT(*) FROM audit_log WHERE action = 'key.branch_refused'`).Scan(&n); err != nil { + `SELECT COUNT(*) FROM audit_log WHERE action = 'key.branch_refused' AND actor_id = $1`, + f.boundKeyID).Scan(&n); err != nil { t.Fatal(err) } return n diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go index 70120c48..634c3c23 100644 --- a/core/internal/app/serve/wire_key_branch_walled_test.go +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -223,7 +223,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { f := newKeyPinFixture(t) // The sweep's key is the document family mint: the families it drives // live in the second scope list. - f.bound, f.unbound = f.bound2, f.unbound2 + f.bound, f.unbound, f.boundKeyID = f.bound2, f.unbound2, f.bound2KeyID w := f.seedWalledTwins(t) missing := uuid.NewString() A, B := f.branchA.String(), f.branchB.String() diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 0a314e5c..f4fa9d98 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -164,7 +164,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/ap/invoices/{id}/approve": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/invoices/{id}/transitions": {KeyBranchClassRefused, "payables span every branch"}, "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, "POST /api/v1/apps/{key}/disable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, "POST /api/v1/apps/{key}/enable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, From de06769f745f0e292dd6f1c3eab618bf66599775 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 16:59:33 -0700 Subject: [PATCH 31/74] security: hold the customer a body names to the key's pin on every create and update Round 9 P1-1. A key pinned to branch A could post a payment, a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto that customer, and promote a draft whose payload named it. Each route that takes customer_id from its body now runs the key branch wall's customer hold after its role and branch guard: POST /payments, /payments/card, /payments/intent, /credit-memos (and PUT), /orders and /quotes (and PUT), and the two draft promotions, which read the customer from the draft's stored payload. The wall is composed in the branchWall mount methods, so serve and the test fixture share it. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/serve/serve.go | 4 +- core/internal/app/serve/wire_branch_wall.go | 64 ++++- .../app/serve/wire_customer_branch.go | 38 ++- .../app/serve/wire_key_branch_pin_test.go | 6 +- .../app/serve/wire_key_branch_round4_test.go | 237 ++++++++++++++++++ 5 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_round4_test.go diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 8fac60fb..0bd3ea90 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -515,8 +515,8 @@ func Run() { func(ctx context.Context, keyID uuid.UUID) (bool, error) { return techAdminSvc.KeyActive(ctx, keyID) })) - quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, scoped(quoteKind.Roles()...)) - order.RegisterDraftRoutes(mux, draftsHandler, orderKind, scoped(orderKind.Roles()...)) + quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, wall.draftGuard(db, "quotes", quoteKind.Roles()...)) + order.RegisterDraftRoutes(mux, draftsHandler, orderKind, wall.draftGuard(db, "orders", orderKind.Roles()...)) // The link resolver (ADR 0007 section 8): one literal route per entity // and per draft kind, each behind the module's own read roles composed diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 29c56088..adb902d1 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -137,23 +137,34 @@ func (w *branchWall) delivery(mux *http.ServeMux, h *delivery.Handler, roles ... h.RegisterRoutes(mux, w.scoped(roles...)) } +// quotes mounts the quote routes: the create and the update take customer_id +// from their body, so a branch bound key reaches them only while the pin holds +// that customer (round 9 P1-1). func (w *branchWall) quotes(mux *http.ServeMux, svc *quote.Service) { - quote.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.scoped("admin", "owner", "sales")) + quote.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + w.bodyCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/quotes", "PUT /api/v1/quotes/{id}")) } // orders mounts the order routes: the create takes branch_id from its body, -// so it is held to the caller's branch context (ADR 0007 section 2.3). +// so it is held to the caller's branch context (ADR 0007 section 2.3), and the +// create and the update take customer_id from it, so a branch bound key +// reaches them only while the pin holds that customer (round 9 P1-1: the +// order would otherwise take another branch's tax exemption). func (w *branchWall) orders(mux *http.ServeMux, svc *order.Service) { - order.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.scoped("admin", "owner", "sales"), + order.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + w.bodyCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/orders", "PUT /api/v1/orders/{id}"), w.scoped("admin", "owner", "finance", "warehouse")) } // invoices mounts the invoice and credit memo routes: the credit memo create // takes branch_id from its body and the writes address a document by path id, // so the service holds both to the caller's branch context (ADR 0007 section -// 2.3) beside the wall every read already carries. +// 2.3) beside the wall every read already carries. The credit memo create and +// update take customer_id from their body, so a branch bound key reaches them +// only while the pin holds that customer (round 9 P1-1). func (w *branchWall) invoices(mux *http.ServeMux, svc *invoice.Service) { - invoice.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.scoped("admin", "owner", "sales", "finance")) + invoice.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + w.bodyCustomer(w.scoped("admin", "owner", "sales", "finance"), "POST /api/v1/credit-memos", "PUT /api/v1/credit-memos/{id}")) } // accounts mounts the account and AR routes: the reads (summary, subledger, @@ -177,9 +188,48 @@ func (w *branchWall) accounts(mux *http.ServeMux, svc *account.Service) { // payments mounts the payment routes: the create takes branch_id from its body // and the writes address a payment by path id, so the service holds both to the // caller's branch context (ADR 0007 section 2.3) beside the wall every read -// carries. +// carries. The cash, card and intent creates take customer_id from their body, +// so a branch bound key reaches them only while the pin holds that customer +// (round 9 P1-1: a posted payment and its GL entry land on that customer). func (w *branchWall) payments(mux *http.ServeMux, svc *payment.Service) { - payment.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.scoped("admin", "owner", "sales", "finance", "cashier")) + payment.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + w.bodyCustomer(w.scoped("admin", "owner", "sales", "finance", "cashier"), + "POST /api/v1/payments", "POST /api/v1/payments/card", "POST /api/v1/payments/intent")) +} + +// bodyCustomer composes a route group's guard with the key branch wall's hold +// on the customer_id the request body names, for the listed patterns only: the +// other routes of the group keep the guard alone. +func (w *branchWall) bodyCustomer(base func(http.Handler) http.Handler, patterns ...string) func(http.Handler) http.Handler { + return w.patternWall(base, w.keyWall.CustomerBodyBranch(), patterns...) +} + +// patternWall runs hold after base on the listed route patterns and base alone +// on every other route of the group. +func (w *branchWall) patternWall(base, hold func(http.Handler) http.Handler, patterns ...string) func(http.Handler) http.Handler { + held := make(map[string]bool, len(patterns)) + for _, p := range patterns { + held[p] = true + } + return func(next http.Handler) http.Handler { + plain, walled := base(next), middleware.Compose(base, hold)(next) + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if held[r.Pattern] { + walled.ServeHTTP(rw, r) + return + } + plain.ServeHTTP(rw, r) + }) + } +} + +// draftGuard is the guard of a draft kind's routes: the role and branch guard, +// and on the promotion the hold on the customer the draft's stored payload +// names (round 9 P1-1: a draft written by a user, or by the key before the +// customer was checked, is checked again where it becomes a document). +func (w *branchWall) draftGuard(db *database.DB, kind string, roles ...string) func(http.Handler) http.Handler { + return w.patternWall(w.scoped(roles...), w.keyWall.CustomerBranch(draftPayloadCustomerOf(db)), + "POST /api/v1/drafts/"+kind+"/{id}/promote") } func (w *branchWall) purchaseOrders(mux *http.ServeMux, h *purchase_order.Handler) { diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index f0a6d6fd..7eb4befc 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -198,10 +198,44 @@ func pricingRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.R } } +// draftPayloadCustomerOf resolves the customer the stored payload of the draft +// the path names carries. A draft that does not exist, or whose payload names +// no customer, names nothing the wall can hold and the promotion's own answer +// covers it. +func draftPayloadCustomerOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("draft customer lookup: no database") + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + return nil, nil // the handler answers the malformed id + } + var raw *string + err = db.GetExecutor(ctx).QueryRow(ctx, `SELECT payload->>'customer_id' FROM drafts WHERE id = $1`, id).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + customer, err := uuid.Parse(*raw) + if err != nil { + return nil, nil + } + return []uuid.UUID{customer}, nil + } +} + // categoryRuleRowCustomerOf resolves the customer a category rule row is // scoped to. A row that does not exist names nothing (the handler's own 404 -// answers); a row that targets a tier or the whole account names no customer, -// is dealer wide, and is refused. +// answers); a row that is not an account rule is dealer wide and is refused, +// whether or not it also names a customer (the legacy tier row migration 107 +// leaves behind prices every branch's customers of its tier), as is a row +// that names no customer. func categoryRuleRowCustomerOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { if db == nil { diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 973edced..d71d2046 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -134,7 +134,7 @@ var pinDocumentScopes = []string{ "customers:read", "customers:write", "contacts:read", "contacts:write", "ship-tos:read", "ship-tos:write", "invoices:read", "invoices:write", "payments:read", "payments:write", "credit-memos:read", "credit-memos:write", - "orders:read", "orders:write", "orders:propose", "quotes:propose", "quotes:commit", + "orders:read", "orders:write", "orders:propose", "orders:commit", "quotes:propose", "quotes:commit", "dashboard:read", "inventory:read", "inventory:write", "locations:read", "locations:write", "products:read", "branches:read", "matching:read", "matching:write", "delivery:read", "delivery:write", @@ -498,8 +498,8 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db)). WithBranchGuard(wall.guard) draftsHandler := drafts.NewHandler(draftsSvc) - quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, wall.scoped(quoteKind.Roles()...)) - order.RegisterDraftRoutes(mux, draftsHandler, orderKind, wall.scoped(orderKind.Roles()...)) + quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, wall.draftGuard(db, "quotes", quoteKind.Roles()...)) + order.RegisterDraftRoutes(mux, draftsHandler, orderKind, wall.draftGuard(db, "orders", orderKind.Roles()...)) linksHandler := links.NewHandler(links.Settings{}, links.Entity{Row: rowOrNone(links.RowFor("quotes", false)), Resolve: func(ctx context.Context, raw string) (uuid.UUID, string, error) { q, err := quoteSvc.GetQuoteByIDOrNumber(ctx, raw) diff --git a/core/internal/app/serve/wire_key_branch_round4_test.go b/core/internal/app/serve/wire_key_branch_round4_test.go new file mode 100644 index 00000000..c2d059e5 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round4_test.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// Fix round 4 of the branch bound key pin (principal rulings 43 to 45), every +// leg through the real serve chain with a key minted pinned to branch A and +// bodies that pass validation: each foreign leg is shadowed by an A control +// that proves the body valid, and every change is measured by the rows that +// name the twin customer or the ids the test made, never by whole table +// counts. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" +) + +// round4Fixture is the pin fixture on the document family mint (the scopes the +// customer, payment, order, quote and draft routes need) with the walled +// twins seeded. +func round4Fixture(t *testing.T) (*keyPinFixture, *walledTwins) { + t.Helper() + f := newKeyPinFixture(t) + f.bound, f.unbound, f.boundKeyID = f.bound2, f.unbound2, f.bound2KeyID + // A document is taxed at its branch's rate, and a branch with none refuses + // to save it. + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE locations SET default_tax_rate = 0.05 WHERE id IN ($1, $2)`, f.branchA, f.branchB); err != nil { + t.Fatalf("seed the branch tax rate: %v", err) + } + return f, f.seedWalledTwins(t) +} + +// madeRows removes, when the test ends, the document rows the legs created +// through the routes (their children first), so the shared database keeps no +// payments, orders, quotes or credit memos of the fixture's customers. +func (f *keyPinFixture) madeRow(t *testing.T, table string, id uuid.UUID) { + t.Helper() + t.Cleanup(func() { + ctx := context.Background() + exec := func(stmt string) { _, _ = f.db.Pool.Exec(ctx, stmt, id) } + switch table { + case "payments": + exec(`DELETE FROM ar_applications WHERE payment_id = $1`) + case "orders": + exec(`DELETE FROM order_lines WHERE order_id = $1`) + case "quotes": + exec(`DELETE FROM events_outbox WHERE entity_type = 'quote' AND entity_id = $1::text`) + exec(`DELETE FROM quote_lines WHERE quote_id = $1`) + case "credit_memos": + exec(`DELETE FROM credit_memo_lines WHERE credit_memo_id = $1`) + exec(`DELETE FROM ar_applications WHERE credit_memo_id = $1`) + } + exec(`DELETE FROM ` + table + ` WHERE id = $1`) + }) +} + +// send runs one call for who ("bound", "unbound" or "user") and returns the +// status and body. +func (f *keyPinFixture) send(t *testing.T, who, method, path, body string, hdr map[string]string) (int, []byte) { + t.Helper() + switch who { + case "bound": + return f.key(t, f.bound, method, path, body, hdr) + case "unbound": + return f.key(t, f.unbound, method, path, body, hdr) + } + return f.user(t, method, path, body, hdr) +} + +// made runs a create that must answer want and tracks the row it made. +func (f *keyPinFixture) made(t *testing.T, who, table, method, path, body string, want int) uuid.UUID { + t.Helper() + status, buf := f.send(t, who, method, path, body, nil) + if status != want { + t.Errorf("%s %s %s: %d %s, want %d", who, method, path, status, buf, want) + return uuid.Nil + } + var doc struct { + ID uuid.UUID `json:"id"` + } + if err := json.Unmarshal(buf, &doc); err != nil || doc.ID == uuid.Nil { + return uuid.Nil + } + if table != "" { + f.madeRow(t, table, doc.ID) + } + return doc.ID +} + +// heldBy reads what names a customer in the document tables the P1-1 legs +// write to, so a refused leg that wrote shows as a diff on the twin +// customer's own rows. +func (f *keyPinFixture) heldBy(t *testing.T, customer uuid.UUID) map[string]int { + t.Helper() + out := map[string]int{} + for table, col := range map[string]string{"payments": "customer_id", "orders": "customer_id", + "quotes": "customer_id", "credit_memos": "customer_id", "drafts": "payload->>'customer_id'"} { + arg := any(customer) + if table == "drafts" { + arg = customer.String() + } + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM `+table+` WHERE `+col+` = $1`, arg).Scan(&n); err != nil { + t.Fatal(err) + } + out[table] = n + } + return out +} + +func sameCounts(a, b map[string]int) bool { + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +// productLine is one order line the pricing engine prices; quoteLine is the +// quote's, which sends its own price. +func (f *keyPinFixture) productLine() string { + return fmt.Sprintf(`{"product_id":%q,"quantity":"1"}`, f.posProd.String()) +} + +func (f *keyPinFixture) quoteLine() string { + return fmt.Sprintf(`{"product_id":%q,"quantity":"1","unit_price_ten_thousandths":10000}`, f.posProd.String()) +} + +// refusedPin asserts one bound key call is the pin's 403 naming customer_id +// with its audit row, and that the twin customer's rows did not move. +func (f *keyPinFixture) refusedPin(t *testing.T, label, method, path, body string, twin uuid.UUID) { + t.Helper() + before := f.heldBy(t, twin) + rows := f.auditRows(t) + status, buf := f.key(t, f.bound, method, path, body, nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("%s: bound key %s %s naming the foreign customer: %d %s, want the pin's 403", label, method, path, status, buf) + } + if !strings.Contains(string(buf), "customer_id") { + t.Errorf("%s: the refusal does not name customer_id: %s", label, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("%s: key.branch_refused rows %d then %d, want one new row", label, rows, after) + } + if after := f.heldBy(t, twin); !sameCounts(before, after) { + t.Errorf("%s: the refusal still wrote rows naming the foreign customer: %v then %v", label, before, after) + } +} + +// TestKeyBranchPin_BodyCustomerWall is round 9 P1-1: a payment (with its GL +// entry), a credit memo, an order (the tax exemption included), a quote, a +// document moved onto another customer and a draft promotion that names a +// customer held only by branch B are refused to a key pinned to A, and the +// A control of each proves the body valid. The unbound key and the user keep +// the reach they had. +func TestKeyBranchPin_BodyCustomerWall(t *testing.T) { + f, _ := round4Fixture(t) + A, B := f.custID.String(), f.custB.String() + line := f.productLine() + + creates := []struct { + name, table, path string + body func(customer string) string + }{ + {"payment", "payments", "/api/v1/payments", func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"amount_cents":100,"method":"cash"}`, c) + }}, + {"credit memo", "credit_memos", "/api/v1/credit-memos", func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"reason_code":"other","reason":"pin","lines":[`+ + `{"description":"pin adjustment","quantity":"-1","uom":"EA","unit_price_ten_thousandths":10000}]}`, c) + }}, + {"order", "orders", "/api/v1/orders", func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup","lines":[%s]}`, c, line) + }}, + {"quote", "quotes", "/api/v1/quotes", func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"lines":[%s]}`, c, f.quoteLine()) + }}, + } + for _, c := range creates { + f.refusedPin(t, c.name, "POST", c.path, c.body(B), f.custB) + f.made(t, "bound", c.table, "POST", c.path, c.body(A), http.StatusCreated) + for _, who := range []string{"unbound", "user"} { + f.made(t, who, c.table, "POST", c.path, c.body(B), http.StatusCreated) + } + } + + // The card and intent creates name the customer the same way. No gateway + // is configured here, so the A control answers 503 or 400 and never 403. + for _, path := range []string{"/api/v1/payments/card", "/api/v1/payments/intent"} { + body := func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"amount_cents":100,"token_id":"teststub"}`, c) + } + f.refusedPin(t, path, "POST", path, body(B), f.custB) + if status, buf := f.key(t, f.bound, "POST", path, body(A), nil); status == http.StatusForbidden { + t.Errorf("bound key %s for the pin's own customer: %d %s, want past the wall", path, status, buf) + } + } + + // A document moved onto another customer: the quote update and the draft + // order update that change customer_id. The foreign leg runs before its + // control, which spends the revision. + quotePut := func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"revision":1,"lines":[%s]}`, c, f.quoteLine()) + } + f.refusedPin(t, "quote move", "PUT", "/api/v1/quotes/"+f.quoteA.String(), quotePut(B), f.custB) + f.allow(t, "bound", "PUT", "/api/v1/quotes/"+f.quoteA.String(), quotePut(A), nil, http.StatusOK) + + draftOrder := f.made(t, "bound", "orders", "POST", "/api/v1/orders", + fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup","lines":[%s]}`, A, line), http.StatusCreated) + orderPut := func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup","revision":1,"lines":[%s]}`, c, line) + } + f.refusedPin(t, "order move", "PUT", "/api/v1/orders/"+draftOrder.String(), orderPut(B), f.custB) + f.allow(t, "bound", "PUT", "/api/v1/orders/"+draftOrder.String(), orderPut(A), nil, http.StatusOK) + + // The draft promotion rechecks the payload's customer: a draft naming B is + // stored (the create is not where the document appears) and refused where + // it would become one. The draft open on the A payload promotes. + for _, kind := range []struct{ module, table, body string }{ + {"quotes", "quotes", `{"payload":{"customer_id":%q,"lines":[` + f.quoteLine() + `]}}`}, + {"orders", "orders", `{"payload":{"customer_id":%q,"delivery_type":"pickup","lines":[` + line + `]}}`}, + } { + foreign := f.made(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.body, B), http.StatusCreated) + own := f.made(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.body, A), http.StatusCreated) + f.refusedPin(t, "promote "+kind.module, "POST", "/api/v1/drafts/"+kind.module+"/"+foreign.String()+"/promote", `{"revision":1}`, f.custB) + f.made(t, "bound", kind.table, "POST", "/api/v1/drafts/"+kind.module+"/"+own.String()+"/promote", `{"revision":1}`, http.StatusCreated) + } +} From 6d7001c48fcd1bfcc2a705b5233720b7a3e67909 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 16:59:33 -0700 Subject: [PATCH 32/74] security: an order refuses a ship to or contact its customer does not own Round 9 P1-2. An order took the ship_to_id it was given and snapshotted that address whoever owned it, and checked only that ordered_by_contact_id existed. Create and update now check both against the order's customer, for every caller, and answer a 400 naming the field, as the credit memo create does. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_key_branch_orders_test.go | 66 +++++++++++++++++++ core/internal/order/repository.go | 20 ++++++ core/internal/order/service.go | 18 +++++ core/internal/portal/service_repo_test.go | 3 + 4 files changed, 107 insertions(+) create mode 100644 core/internal/app/serve/wire_key_branch_orders_test.go diff --git a/core/internal/app/serve/wire_key_branch_orders_test.go b/core/internal/app/serve/wire_key_branch_orders_test.go new file mode 100644 index 00000000..66168e91 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_orders_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" +) + +// TestKeyBranchPin_OrderOwnsItsShipToAndContact is round 9 P1-2: an order +// refuses a ship-to or a contact its customer does not own, for every caller, +// as the credit memo create does, naming the field; a matching ship-to and +// contact still work. Branch A's order naming branch B's ship-to would +// otherwise snapshot another customer's address. +func TestKeyBranchPin_OrderOwnsItsShipToAndContact(t *testing.T) { + f, w := round4Fixture(t) + A := f.custID.String() + line := f.productLine() + // The unbound key and the user name the branch; the bound key takes its pin. + branch := fmt.Sprintf(`"branch_id":%q,`, f.branchA.String()) + body := func(extra string) string { + return fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup",%s%s"lines":[%s]}`, A, branch, extra, line) + } + ordersOfA := func() int { + var n int + if err := f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM orders WHERE customer_id = $1`, f.custID).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + for _, who := range []string{"bound", "unbound", "user"} { + before := ordersOfA() + for _, c := range []struct{ field, extra string }{ + {"ship_to_id", fmt.Sprintf(`"ship_to_id":%q,`, w.shipB.String())}, + {"ordered_by_contact_id", fmt.Sprintf(`"ordered_by_contact_id":%q,`, w.conB.String())}, + } { + status, buf := f.send(t, who, "POST", "/api/v1/orders", body(c.extra), nil) + if status != http.StatusBadRequest || !strings.Contains(string(buf), c.field) { + t.Errorf("%s POST /orders naming the foreign %s: %d %s, want a 400 naming it", who, c.field, status, buf) + } + } + if n := ordersOfA(); n != before { + t.Errorf("%s: a refused order was written: %d then %d orders of the customer", who, before, n) + } + own := f.made(t, who, "orders", "POST", "/api/v1/orders", + body(fmt.Sprintf(`"ship_to_id":%q,"ordered_by_contact_id":%q,`, w.shipA.String(), w.conA.String())), http.StatusCreated) + if own == uuid.Nil { + continue + } + // The update reaches the same build. + put := func(extra string) string { + return fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup",%s"revision":1,"lines":[%s]}`, A, extra, line) + } + status, buf := f.send(t, who, "PUT", "/api/v1/orders/"+own.String(), put(fmt.Sprintf(`"ship_to_id":%q,`, w.shipB.String())), nil) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "ship_to_id") { + t.Errorf("%s PUT /orders naming the foreign ship-to: %d %s, want a 400 naming it", who, status, buf) + } + f.allow(t, who, "PUT", "/api/v1/orders/"+own.String(), put(fmt.Sprintf(`"ship_to_id":%q,`, w.shipA.String())), nil, http.StatusOK) + } +} diff --git a/core/internal/order/repository.go b/core/internal/order/repository.go index 54131519..bb890545 100644 --- a/core/internal/order/repository.go +++ b/core/internal/order/repository.go @@ -79,6 +79,9 @@ type Repository interface { CustomerFacts(ctx context.Context, customerID uuid.UUID) (CustomerFacts, error) ContactAuthority(ctx context.Context, contactID uuid.UUID) (ContactAuthority, error) DefaultShipToID(ctx context.Context, customerID uuid.UUID) (*uuid.UUID, error) + // OwnedBy checks that the ship-to and the contact a document names belong + // to its customer: the foreign keys alone would accept another customer's. + OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID *uuid.UUID) (shipToOK, contactOK bool, err error) ShipTo(ctx context.Context, shipToID uuid.UUID) (snapshot *ShipToSnapshot, rate *string, err error) BranchTaxRate(ctx context.Context, branchID uuid.UUID) (*string, error) CustomerExempt(ctx context.Context, customerID uuid.UUID) (bool, error) @@ -911,6 +914,23 @@ func (r *PostgresRepository) ContactAuthority(ctx context.Context, contactID uui return a, nil } +func (r *PostgresRepository) OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID *uuid.UUID) (shipToOK, contactOK bool, err error) { + shipToOK, contactOK = true, true + if shipToID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM customer_ship_tos WHERE id = $1 AND customer_id = $2)`, *shipToID, customerID).Scan(&shipToOK); err != nil { + return false, false, fmt.Errorf("failed to check the ship-to: %w", err) + } + } + if contactID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM customer_contacts WHERE id = $1 AND customer_id = $2)`, *contactID, customerID).Scan(&contactOK); err != nil { + return false, false, fmt.Errorf("failed to check the contact: %w", err) + } + } + return shipToOK, contactOK, nil +} + func (r *PostgresRepository) DefaultShipToID(ctx context.Context, customerID uuid.UUID) (*uuid.UUID, error) { var id *uuid.UUID err := r.db.GetExecutor(ctx).QueryRow(ctx, diff --git a/core/internal/order/service.go b/core/internal/order/service.go index 75d26b15..3e02b66b 100644 --- a/core/internal/order/service.go +++ b/core/internal/order/service.go @@ -186,6 +186,24 @@ func (s *Service) build(ctx context.Context, d *Draft, branchID uuid.UUID, actor Details: []httpx.FieldError{{Field: "customer_id", Message: "no such customer"}}} } + // A ship-to or a contact the customer does not own is refused for every + // caller, as the credit memo create refuses it (round 9 P1-2): the + // snapshot would carry another customer's address. + shipOK, contactOK, err := s.repo.OwnedBy(ctx, d.CustomerID, d.ShipToID, d.OrderedByContactID) + if err != nil { + return nil, err + } + var notOwned []httpx.FieldError + if !shipOK { + notOwned = append(notOwned, httpx.FieldError{Field: "ship_to_id", Message: "is not a ship-to of this customer"}) + } + if !contactOK { + notOwned = append(notOwned, httpx.FieldError{Field: "ordered_by_contact_id", Message: "is not a contact of this customer"}) + } + if len(notOwned) > 0 { + return nil, validationFailed("one or more fields failed validation", notOwned...) + } + o := Order{} o.ID = uuid.New() o.BranchID = branchID diff --git a/core/internal/portal/service_repo_test.go b/core/internal/portal/service_repo_test.go index b44a50ce..0a205bd7 100644 --- a/core/internal/portal/service_repo_test.go +++ b/core/internal/portal/service_repo_test.go @@ -664,6 +664,9 @@ func (f *fakeOrderRepo) CustomerFacts(_ context.Context, customerID uuid.UUID) ( func (f *fakeOrderRepo) ContactAuthority(context.Context, uuid.UUID) (order.ContactAuthority, error) { return order.ContactAuthority{Exists: true, CanPlaceOrders: true}, nil } +func (f *fakeOrderRepo) OwnedBy(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID) (bool, bool, error) { + return true, true, nil +} func (f *fakeOrderRepo) DefaultShipToID(context.Context, uuid.UUID) (*uuid.UUID, error) { return nil, nil } From e3a48b3c3041aff6bb903be68f11ff96c567f5a9 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 16:59:39 -0700 Subject: [PATCH 33/74] security: a legacy tier rule that names a customer stays dealer wide Round 9 P2. The category rule row and bulk walls read "names a customer" as "is customer scoped", so a TIER row that also names an A customer (the row migration 107 keeps NOT VALID) was deleted by a key pinned to A, singly and in bulk. Both resolvers now treat any row whose target_type is not ACCOUNT as dealer wide beside customer_id IS NULL. The rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, as PD-4 reads are) while a dealer wide row stays refused. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_customer_branch.go | 29 +++++--- .../app/serve/wire_key_branch_tier_test.go | 72 +++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_tier_test.go diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 7eb4befc..198d496e 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -249,16 +249,19 @@ func categoryRuleRowCustomerOf(db *database.DB) func(ctx context.Context, r *htt if err != nil { return nil, nil // the handler answers the malformed id } - var customer *uuid.UUID + var ( + customer *uuid.UUID + targetType string + ) err = db.GetExecutor(r.Context()).QueryRow(r.Context(), - `SELECT customer_id FROM category_pricing_rules WHERE id = $1`, id).Scan(&customer) + `SELECT customer_id, target_type FROM category_pricing_rules WHERE id = $1`, id).Scan(&customer, &targetType) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } - if customer == nil { + if customer == nil || targetType != "ACCOUNT" { return nil, middleware.ErrBodyRefused } return []uuid.UUID{*customer}, nil @@ -267,8 +270,8 @@ func categoryRuleRowCustomerOf(db *database.DB) func(ctx context.Context, r *htt // bulkRuleCustomersOf resolves the customers a bulk delete's rule ids belong // to. An id that does not parse is left to the handler's own 400; an id that -// names a tier or account row (no customer) is a dealer wide delete and -// refuses the whole request. +// names a row that is not an account rule, or that names no customer, is a +// dealer wide delete and refuses the whole request. func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { if db == nil { @@ -291,7 +294,7 @@ func bulkRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.Requ } var dealerWide int if err := db.GetExecutor(r.Context()).QueryRow(r.Context(), - `SELECT COUNT(*) FROM category_pricing_rules WHERE id = ANY($1) AND customer_id IS NULL`, ids). + `SELECT COUNT(*) FROM category_pricing_rules WHERE id = ANY($1) AND (customer_id IS NULL OR target_type <> 'ACCOUNT')`, ids). Scan(&dealerWide); err != nil { return nil, err } @@ -515,19 +518,22 @@ func pricingRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB // list is refused by the gate, so the parameter is required here), the create // and the bulk upsert name one per element (a tier target names none and is // refused), the update, the delete and the audit act on a row that belongs -// to one, the bulk delete's ids belong to theirs, and the resolve preview +// to one (the audit read is a read, so a customer shared with another branch +// still passes it, as the list and resolve reads do), the bulk delete's ids +// belong to theirs, and the resolve preview // names a customer when its query carries one. The matrix is REFUSED by the // route allowlist gate; the categories routes are stated limits. func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.DB, roles ...string) func(http.Handler) http.Handler { role := middleware.RequireRole(roles...) body := middleware.Compose(role, keyWall.CustomerBranchExclusive(categoryRuleBodyCustomers)) byRow := middleware.Compose(role, keyWall.CustomerBranchExclusive(categoryRuleRowCustomerOf(db))) + auditRead := middleware.Compose(role, keyWall.CustomerBranch(categoryRuleRowCustomerOf(db))) byBulkIDs := middleware.Compose(role, keyWall.CustomerBranchExclusive(bulkRuleCustomersOf(db))) list := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", true))) resolve := middleware.Compose(role, keyWall.CustomerBranch(queryCustomerOf("customer_id", false))) return func(next http.Handler) http.Handler { - bodyHandler, rowHandler, bulkHandler, listHandler, resolveHandler, roleHandler := - body(next), byRow(next), byBulkIDs(next), list(next), resolve(next), role(next) + bodyHandler, rowHandler, bulkHandler, listHandler, resolveHandler, auditHandler, roleHandler := + body(next), byRow(next), byBulkIDs(next), list(next), resolve(next), auditRead(next), role(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Pattern { case "GET /api/v1/pricing/category-rules": @@ -536,9 +542,10 @@ func categoryRulesCustomerWall(keyWall *middleware.KeyBranchWall, db *database.D bodyHandler.ServeHTTP(w, r) case "DELETE /api/v1/pricing/category-rules/bulk": bulkHandler.ServeHTTP(w, r) - case "PUT /api/v1/pricing/category-rules/{id}", "DELETE /api/v1/pricing/category-rules/{id}", - "GET /api/v1/pricing/category-rules/{id}/audit": + case "PUT /api/v1/pricing/category-rules/{id}", "DELETE /api/v1/pricing/category-rules/{id}": rowHandler.ServeHTTP(w, r) + case "GET /api/v1/pricing/category-rules/{id}/audit": + auditHandler.ServeHTTP(w, r) case "GET /api/v1/pricing/resolve": resolveHandler.ServeHTTP(w, r) default: diff --git a/core/internal/app/serve/wire_key_branch_tier_test.go b/core/internal/app/serve/wire_key_branch_tier_test.go new file mode 100644 index 00000000..87b05464 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_tier_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" +) + +// TestKeyBranchPin_LegacyTierRowStaysDealerWide is round 9 P2: a TIER category +// rule that also names a customer (the row migration 107 leaves behind, held +// NOT VALID) is dealer wide however it reads: a key pinned to A cannot delete +// it singly or in bulk, nor read its audit, while the unbound key still can. +func TestKeyBranchPin_LegacyTierRowStaysDealerWide(t *testing.T) { + f := newKeyPinFixture(t) + ctx := context.Background() + seed := func() uuid.UUID { + id := uuid.New() + tx, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + for _, stmt := range []string{ + `ALTER TABLE category_pricing_rules DROP CONSTRAINT category_pricing_rules_single_target_check`, + `INSERT INTO category_pricing_rules (id, target_type, tier, customer_id, category_id, rule_type, rule_value, created_by) + VALUES ('` + id.String() + `', 'TIER', 'PIN-TIER', '` + f.custID.String() + `', '` + f.category.String() + `', 'MARKUP', 10, 'test')`, + `ALTER TABLE category_pricing_rules ADD CONSTRAINT category_pricing_rules_single_target_check + CHECK ((target_type = 'ACCOUNT' AND tier IS NULL) OR (target_type = 'TIER' AND customer_id IS NULL)) NOT VALID`, + } { + if _, err := tx.Exec(ctx, stmt); err != nil { + t.Fatalf("seed the legacy row: %v", err) + } + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + return id + } + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM category_pricing_rules WHERE tier = 'PIN-TIER'`) + _, _ = f.db.Pool.Exec(ctx, `ALTER TABLE category_pricing_rules VALIDATE CONSTRAINT category_pricing_rules_single_target_check`) + }) + exists := func(id uuid.UUID) bool { + var n int + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM category_pricing_rules WHERE id = $1`, id).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 + } + rev := func(id uuid.UUID) map[string]string { return map[string]string{"If-Match": f.catRuleRev(t, id)} } + + legacy := seed() + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/"+legacy.String(), "", rev(legacy)) + f.refuseForeign(t, "DELETE", "/api/v1/pricing/category-rules/bulk", fmt.Sprintf(`{"ids":[%q]}`, legacy.String()), nil) + f.refuseForeign(t, "GET", "/api/v1/pricing/category-rules/"+legacy.String()+"/audit", "", nil) + if !exists(legacy) { + t.Fatal("the pinned key deleted the legacy tier row") + } + // The unbound key keeps the delete, single and bulk, each on a fresh row. + f.allow(t, "unbound", "DELETE", "/api/v1/pricing/category-rules/"+legacy.String(), "", rev(legacy), http.StatusNoContent) + again := seed() + f.allow(t, "unbound", "DELETE", "/api/v1/pricing/category-rules/bulk", fmt.Sprintf(`{"ids":[%q]}`, again.String()), nil, http.StatusNoContent) + if exists(legacy) || exists(again) { + t.Error("the unbound key could not delete the legacy tier row") + } +} From 12b5fefa80d0e8ea59818d4569f492dd8a099aaa Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 16:59:44 -0700 Subject: [PATCH 34/74] security: a key bound to one branch changes no restricted field of a shared customer Principal rulings 43 to 45. On a customer held by the pin's branch and another, a bound key may change only the branch local fields (email, phone); every other field of the record, the salesperson and the escalation policy is restricted by default (a field added later is restricted until listed as branch local), and the key is refused in the PD-4 words naming the other branch, with the key.branch_refused row. It may add a ship-to or a contact but change or delete none of a shared customer's. A customer held by the pin alone, a user and every unshared customer are unchanged. A dealer wide key changes the restricted fields and each change is audited as customer.shared_restricted_changed with the actor and the before and after value of every field. The rule lives in the customer service (it must see the field diff) behind a SharedGuard that customerguard adapts from the key branch wall. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 6 +- .../app/serve/wire_key_branch_pin_test.go | 3 +- .../app/serve/wire_key_branch_shared_test.go | 403 ++++++++++++++++++ .../customer/customerguard/customerguard.go | 24 ++ core/internal/customer/service.go | 72 +++- core/internal/customer/service_children.go | 17 +- core/internal/customer/shared.go | 143 +++++++ core/pkg/audit/audit.go | 20 + core/pkg/middleware/keybranch_shared.go | 117 +++++ 9 files changed, 795 insertions(+), 10 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_shared_test.go create mode 100644 core/internal/customer/shared.go create mode 100644 core/pkg/middleware/keybranch_shared.go diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index adb902d1..a7148b5e 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -114,8 +114,10 @@ func (w *branchWall) inventory(mux *http.ServeMux, svc *inventory.Service) { // its body, so it is held to the caller's branch context; payment terms writes // take the narrower finance guard. func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { - customer.NewHandler(svc.WithBranchGuard(customerguard.New(w.guard))). - RegisterRoutes(mux, w.scoped("admin", "owner", "sales"), w.scoped("admin", "owner", "finance")) + svc.WithBranchGuard(customerguard.New(w.guard)).WithSharedGuard(customerguard.NewShared(w.keyWall)) + customer.NewHandler(svc).RegisterRoutes(mux, + middleware.Compose(w.scoped("admin", "owner", "sales"), w.keyWall.RecordRequest()), + w.scoped("admin", "owner", "finance")) } // crm mounts the activity routes behind the branch middleware: every route diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index d71d2046..c978bae3 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -31,6 +31,7 @@ import ( "github.com/gablelbm/gable/internal/config" "github.com/gablelbm/gable/internal/crm" "github.com/gablelbm/gable/internal/customer" + "github.com/gablelbm/gable/internal/customer/customeraudit" "github.com/gablelbm/gable/internal/dashboard" "github.com/gablelbm/gable/internal/delivery" "github.com/gablelbm/gable/internal/document" @@ -394,7 +395,7 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { // The customer confined modules exactly as serve registers them: the tax // exemption writes and the customer priced rules. - customerSvc := customer.NewService(customer.NewRepository(db)) + customerSvc := customer.NewService(customer.NewRepository(db)).WithAudit(customeraudit.New(audit.NewLogger(db))) productSvc := product.NewService(product.NewRepository(db)) tax.NewHandler(orderwire.NewTaxService(db, &config.Config{}, slog.Default())). RegisterRoutes(mux, taxCustomerWall(wall.keyWall, db, "admin", "owner", "finance")) diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go new file mode 100644 index 00000000..0e90c5f5 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" +) + +// sharedCustomerFixture is a customer held by the branches given (with a +// ship-to, a contact and a salesperson to assign). +type sharedCustomerFixture struct { + id, shipTo, contact uuid.UUID + salesperson uuid.UUID + termsA, termsB uuid.UUID + priceLevel uuid.UUID +} + +func seedSharedCustomer(t *testing.T, f *keyPinFixture, branches ...uuid.UUID) *sharedCustomerFixture { + t.Helper() + ctx := context.Background() + s := &sharedCustomerFixture{id: uuid.New(), shipTo: uuid.New(), contact: uuid.New(), salesperson: uuid.New()} + exec := func(stmt string, args ...any) { + t.Helper() + if _, err := f.db.Pool.Exec(ctx, stmt, args...); err != nil { + t.Fatalf("seed shared customer: %v", err) + } + } + exec(`INSERT INTO customers (id, name, account_number, primary_branch_id, credit_limit) VALUES ($1, 'pin shared', $2, $3, 500)`, + s.id, "PINSH-"+s.id.String()[:8], f.branchA) + for _, b := range branches { + exec(`INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2)`, s.id, b) + } + exec(`INSERT INTO customer_ship_tos (id, customer_id, code, name, line1) VALUES ($1, $2, 'shared', 'pin shared', 'pin')`, s.shipTo, s.id) + exec(`INSERT INTO customer_contacts (id, customer_id, first_name, last_name) VALUES ($1, $2, 'pin', 'shared')`, s.contact, s.id) + exec(`INSERT INTO sales_team (id, name, email, role) VALUES ($1, 'pin rep', $2, 'sales')`, s.salesperson, "pin-rep-"+s.salesperson.String()[:8]+"@example.com") + rows, err := f.db.Pool.Query(ctx, `SELECT id FROM payment_terms ORDER BY code LIMIT 2`) + if err != nil { + t.Fatal(err) + } + var terms []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + terms = append(terms, id) + } + rows.Close() + if len(terms) < 2 { + t.Fatalf("the migrations seed %d payment terms, want 2", len(terms)) + } + s.termsA, s.termsB = terms[0], terms[1] + if err := f.db.Pool.QueryRow(ctx, `SELECT id FROM price_levels ORDER BY id LIMIT 1`).Scan(&s.priceLevel); err != nil { + t.Fatalf("a price level: %v", err) + } + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM audit_log WHERE entity_id = $1 AND action LIKE 'customer.%'`, s.id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'customer' AND entity_id = $1::text`, s.id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_contacts WHERE customer_id = $1`, s.id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_ship_tos WHERE customer_id = $1`, s.id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id = $1`, s.id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, s.id) + _, _ = f.db.Pool.Exec(ctx, `UPDATE customers SET salesperson_id = NULL WHERE salesperson_id = $1`, s.salesperson) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM sales_team WHERE id = $1`, s.salesperson) + }) + return s +} + +// customerDigest hashes a customer's whole row, so a refused write that +// changed any column shows. +func (f *keyPinFixture) customerDigest(t *testing.T, id uuid.UUID) string { + t.Helper() + var d string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT md5(row_to_json(c)::text) FROM customers c WHERE id = $1`, id).Scan(&d); err != nil { + t.Fatal(err) + } + return d +} + +// readJSON reads a record as the admin user, as a map. +func (f *keyPinFixture) readJSON(t *testing.T, path string) map[string]any { + t.Helper() + status, buf := f.user(t, "GET", path, "", nil) + if status != http.StatusOK { + t.Fatalf("read %s: %d %s", path, status, buf) + } + var m map[string]any + if err := json.Unmarshal(buf, &m); err != nil { + t.Fatal(err) + } + return m +} + +// sharedChangeRows reads the audit rows a dealer wide key's change to a +// shared customer left, oldest first. +func (f *keyPinFixture) sharedChangeRows(t *testing.T, customer uuid.UUID) []map[string]any { + t.Helper() + rows, err := f.db.Pool.Query(context.Background(), + `SELECT actor_kind, actor_id::text, changes FROM audit_log + WHERE action = 'customer.shared_restricted_changed' AND entity_id = $1 ORDER BY created_at, id`, customer) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []map[string]any + for rows.Next() { + var kind, actorID string + var raw []byte + if err := rows.Scan(&kind, &actorID, &raw); err != nil { + t.Fatal(err) + } + var changes map[string]any + if err := json.Unmarshal(raw, &changes); err != nil { + t.Fatal(err) + } + changes["_kind"], changes["_actor"] = kind, actorID + out = append(out, changes) + } + return out +} + +// TestKeyBranchPin_SharedCustomerRecord holds principal rulings 43 to 45: on +// a customer held by the pin and another branch, a key bound to the pin reads +// everything and changes only the branch local fields (email, phone), every +// other field of the record being restricted by default; it may add a ship-to +// or a contact but change or delete none. A customer the pin holds alone +// allows all of it. The unbound key changes the restricted fields, each change +// audited with the actor and the before and after values; a user is unchanged. +func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { + f, _ := round4Fixture(t) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + own := seedSharedCustomer(t, f, f.branchA) + sh := "/api/v1/customers/" + s.id.String() + alone := "/api/v1/customers/" + own.id.String() + + // put replaces the customer's header with its current values and the + // change given, on the revision it reads. + put := func(who, path string, change func(cur map[string]any) map[string]any) (int, []byte) { + cur := f.readJSON(t, path) + body := map[string]any{"revision": cur["revision"]} + for _, k := range []string{"account_number", "name", "email", "phone", "address", "tier", "is_active", + "price_level_id", "salesperson_id", "credit_limit_cents", "currency", "payment_terms_id", "po_required"} { + body[k] = cur[k] + } + for k, v := range change(cur) { + body[k] = v + } + raw, _ := json.Marshal(body) + return f.send(t, who, "PUT", path, string(raw), nil) + } + n := 0 + other := func(cur any, options ...any) any { + for _, o := range options { + if o != cur { + return o + } + } + return nil + } + fields := []struct { + name string + do func(who, path string) (int, []byte) + }{ + {"credit_limit_cents", func(who, path string) (int, []byte) { + n++ + return put(who, path, func(map[string]any) map[string]any { return map[string]any{"credit_limit_cents": 100000 + n} }) + }}, + {"tier", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { + return map[string]any{"tier": other(c["tier"], "silver", "gold")} + }) + }}, + {"price_level_id", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { + return map[string]any{"price_level_id": other(c["price_level_id"], s.priceLevel.String(), nil)} + }) + }}, + {"payment_terms_id", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { + return map[string]any{"payment_terms_id": other(c["payment_terms_id"], s.termsA.String(), s.termsB.String())} + }) + }}, + {"currency", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { + return map[string]any{"currency": other(c["currency"], "USD", nil)} + }) + }}, + {"po_required", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { + return map[string]any{"po_required": !(c["po_required"] == true)} + }) + }}, + {"name", func(who, path string) (int, []byte) { + n++ + return put(who, path, func(map[string]any) map[string]any { return map[string]any{"name": fmt.Sprintf("pin shared %d", n)} }) + }}, + {"address", func(who, path string) (int, []byte) { + n++ + return put(who, path, func(map[string]any) map[string]any { return map[string]any{"address": fmt.Sprintf("%d pin street", n)} }) + }}, + {"account_number", func(who, path string) (int, []byte) { + n++ + return put(who, path, func(map[string]any) map[string]any { + return map[string]any{"account_number": fmt.Sprintf("PINAN-%d-%s", n, uuid.NewString()[:6])} + }) + }}, + {"is_active", func(who, path string) (int, []byte) { + return put(who, path, func(c map[string]any) map[string]any { return map[string]any{"is_active": !(c["is_active"] == true)} }) + }}, + {"salesperson_id", func(who, path string) (int, []byte) { + cur := f.readJSON(t, path) + var sp any = s.salesperson.String() + if cur["salesperson_id"] != nil { + sp = nil + } + raw, _ := json.Marshal(map[string]any{"salesperson_id": sp, "revision": cur["revision"]}) + return f.send(t, who, "PATCH", path+"/salesperson", string(raw), nil) + }}, + {"escalation_policy", func(who, path string) (int, []byte) { + cur := f.readJSON(t, path) + n++ + raw, _ := json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": fmt.Sprintf("%d.5", 1+n%40), "revision": cur["revision"]}) + return f.send(t, who, "PUT", path+"/escalation-policy", string(raw), nil) + }}, + } + + // A branch local field passes on the shared customer for the bound key. + for _, c := range []string{"phone", "email"} { + status, buf := put("bound", sh, func(map[string]any) map[string]any { + n++ + if c == "email" { + return map[string]any{"email": fmt.Sprintf("pin%d@example.com", n)} + } + return map[string]any{"phone": fmt.Sprintf("555-01%02d", n%100)} + }) + if status != http.StatusOK { + t.Errorf("bound key changing the branch local %s of the shared customer: %d %s, want 200", c, status, buf) + } + } + + // Every restricted field is refused on the shared customer: 403 naming + // branch B, one key.branch_refused row, the whole row unchanged. The read + // of the record stays allowed. + f.allow(t, "bound", "GET", sh, "", nil, http.StatusOK) + f.allow(t, "bound", "GET", sh+"/escalation-policy", "", nil, http.StatusOK) + for _, fld := range fields { + digest, rows := f.customerDigest(t, s.id), f.auditRows(t) + status, buf := fld.do("bound", sh) + if status != http.StatusForbidden || !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + t.Errorf("bound key changing %s of the shared customer: %d %s, want a 403 naming branch B", fld.name, status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("bound key changing %s: key.branch_refused rows %d then %d, want one new row", fld.name, rows, after) + } + if f.customerDigest(t, s.id) != digest { + t.Errorf("the refused change of %s still changed the shared customer's row", fld.name) + } + } + + // The customer the pin holds alone allows every one of them. + for _, fld := range fields { + if status, buf := fld.do("bound", alone); status != http.StatusOK { + t.Errorf("bound key changing %s of the customer the pin holds alone: %d %s, want 200", fld.name, status, buf) + } + } + + // The dealer wide key and the user change the shared customer's + // restricted fields; only the key leaves the audit row, naming itself + // and the before and after value. + for _, fld := range fields { + before := len(f.sharedChangeRows(t, s.id)) + if status, buf := fld.do("unbound", sh); status != http.StatusOK { + t.Errorf("unbound key changing %s of the shared customer: %d %s, want 200", fld.name, status, buf) + continue + } + rows := f.sharedChangeRows(t, s.id) + if len(rows) != before+1 { + t.Errorf("unbound key changing %s: %d audit rows then %d, want one new row", fld.name, before, len(rows)) + continue + } + last := rows[len(rows)-1] + change, ok := last[fld.name].(map[string]any) + if !ok || last["_kind"] != "key" || last["_actor"] == "" || last["actor_key_id"] != last["_actor"] { + t.Errorf("unbound key changing %s: the audit row does not name the key and the field: %v", fld.name, last) + continue + } + if _, hasFrom := change["from"]; !hasFrom { + t.Errorf("unbound key changing %s: the audit row has no before value: %v", fld.name, change) + } + if _, hasTo := change["to"]; !hasTo { + t.Errorf("unbound key changing %s: the audit row has no after value: %v", fld.name, change) + } + } + rowsBefore := len(f.sharedChangeRows(t, s.id)) + for _, fld := range fields { + if status, buf := fld.do("user", sh); status != http.StatusOK { + t.Errorf("user changing %s of the shared customer: %d %s, want 200", fld.name, status, buf) + } + } + if rows := len(f.sharedChangeRows(t, s.id)); rows != rowsBefore { + t.Errorf("the user's changes left %d shared customer audit rows, want none", rows-rowsBefore) + } + + // Ship-tos and contacts: every existing one of a shared customer is + // shared. The bound key reads and adds, and changes or deletes none. + shipBody := func(c map[string]any, name string) string { + raw, _ := json.Marshal(map[string]any{"code": c["code"], "name": name, "line1": c["line1"], "revision": c["revision"]}) + return string(raw) + } + contactBody := func(c map[string]any, last string) string { + body := map[string]any{"last_name": last, "revision": c["revision"]} + for _, k := range []string{"first_name", "title", "email", "phone", "role", "is_primary", "is_active", "can_place_orders", "order_limit_cents"} { + body[k] = c[k] + } + raw, _ := json.Marshal(body) + return string(raw) + } + f.allow(t, "bound", "GET", sh+"/ship-tos", "", nil, http.StatusOK) + f.allow(t, "bound", "GET", sh+"/contacts", "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/ship-tos/"+s.shipTo.String(), "", nil, http.StatusOK) + f.allow(t, "bound", "GET", "/api/v1/contacts/"+s.contact.String(), "", nil, http.StatusOK) + if id := f.made(t, "bound", "", "POST", sh+"/ship-tos", `{"code":"pinnew","name":"pin new","line1":"1 pin"}`, http.StatusCreated); id != uuid.Nil { + t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_ship_tos WHERE id = $1`, id) }) + } + if id := f.made(t, "bound", "", "POST", sh+"/contacts", `{"first_name":"pin","last_name":"new"}`, http.StatusCreated); id != uuid.Nil { + t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_contacts WHERE id = $1`, id) }) + } + shipPath, contactPath := "/api/v1/ship-tos/"+s.shipTo.String(), "/api/v1/contacts/"+s.contact.String() + childRefusals := []struct { + label, method, path string + body func() (string, map[string]string) + }{ + {"ship-to update", "PUT", shipPath, func() (string, map[string]string) { + return shipBody(f.readJSON(t, shipPath), "pin renamed"), nil + }}, + {"contact update", "PUT", contactPath, func() (string, map[string]string) { + return contactBody(f.readJSON(t, contactPath), "renamed"), nil + }}, + {"contact delete", "DELETE", contactPath, func() (string, map[string]string) { + return "", map[string]string{"If-Match": fmt.Sprintf(`"%v"`, f.readJSON(t, contactPath)["revision"])} + }}, + } + childState := func() string { + var d string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT md5(COALESCE((SELECT row_to_json(s)::text FROM customer_ship_tos s WHERE id = $1), '') || + COALESCE((SELECT row_to_json(c)::text FROM customer_contacts c WHERE id = $2), 'gone'))`, + s.shipTo, s.contact).Scan(&d); err != nil { + t.Fatal(err) + } + return d + } + for _, c := range childRefusals { + before, rows := childState(), f.auditRows(t) + body, hdr := c.body() + status, buf := f.key(t, f.bound, c.method, c.path, body, hdr) + if status != http.StatusForbidden || !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + t.Errorf("bound key %s of the shared customer: %d %s, want a 403 naming branch B", c.label, status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("bound key %s: key.branch_refused rows %d then %d, want one new row", c.label, rows, after) + } + if childState() != before { + t.Errorf("the refused %s still changed the record", c.label) + } + } + + // The customer the pin holds alone: its ship-to and contact change and + // delete freely for the bound key; the unbound key and the user do the + // same on the shared customer's, each delete on a contact of its own. + ownShip, ownContact := "/api/v1/ship-tos/"+own.shipTo.String(), "/api/v1/contacts/"+own.contact.String() + f.allow(t, "bound", "PUT", ownShip, shipBody(f.readJSON(t, ownShip), "pin own"), nil, http.StatusOK) + f.allow(t, "bound", "PUT", ownContact, contactBody(f.readJSON(t, ownContact), "own"), nil, http.StatusOK) + f.allow(t, "bound", "DELETE", ownContact, "", + map[string]string{"If-Match": fmt.Sprintf(`"%v"`, f.readJSON(t, ownContact)["revision"])}, http.StatusNoContent) + for _, who := range []string{"unbound", "user"} { + if status, buf := f.send(t, who, "PUT", shipPath, shipBody(f.readJSON(t, shipPath), "by "+who), nil); status != http.StatusOK { + t.Errorf("%s changing the shared customer's ship-to: %d %s, want 200", who, status, buf) + } + if status, buf := f.send(t, who, "PUT", contactPath, contactBody(f.readJSON(t, contactPath), "by "+who), nil); status != http.StatusOK { + t.Errorf("%s changing the shared customer's contact: %d %s, want 200", who, status, buf) + } + extra := uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO customer_contacts (id, customer_id, first_name, last_name) VALUES ($1, $2, 'pin', 'extra')`, extra, s.id); err != nil { + t.Fatal(err) + } + status, buf := f.send(t, who, "DELETE", "/api/v1/contacts/"+extra.String(), "", + map[string]string{"If-Match": fmt.Sprintf(`"%v"`, f.readJSON(t, "/api/v1/contacts/"+extra.String())["revision"])}) + if status != http.StatusNoContent { + t.Errorf("%s deleting a contact of the shared customer: %d %s, want 204", who, status, buf) + } + } +} diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index 874ee681..04e2f8cf 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -29,3 +29,27 @@ func (a *Guard) CheckPayloadBranch(ctx context.Context, branch uuid.UUID) error } return err } + +// Shared adapts the key branch wall to the customer service's SharedGuard: +// the shared customer rule of principal rulings 43, 44 and 45. +type Shared struct{ w *middleware.KeyBranchWall } + +// NewShared wraps the key branch wall. +func NewShared(w *middleware.KeyBranchWall) *Shared { return &Shared{w: w} } + +// Shared answers how the caller stands to the customer. +func (a *Shared) Shared(ctx context.Context, customerID uuid.UUID) (customer.SharedVerdict, error) { + v, err := a.w.SharedCustomer(ctx, customerID) + if err != nil { + return customer.SharedVerdict{}, err + } + return customer.SharedVerdict{Refuse: v.Refuse, Branch: v.Branch, Audit: v.Audit}, nil +} + +// RecordRefusal writes the refusal's key.branch_refused row. +func (a *Shared) RecordRefusal(ctx context.Context) { a.w.RecordSharedRefusal(ctx) } + +// RecordChange writes the dealer wide key's audit row. +func (a *Shared) RecordChange(ctx context.Context, customerID uuid.UUID, changes map[string]any) error { + return a.w.RecordSharedChange(ctx, customerID, changes) +} diff --git a/core/internal/customer/service.go b/core/internal/customer/service.go index a19ef935..4fbbe89e 100644 --- a/core/internal/customer/service.go +++ b/core/internal/customer/service.go @@ -74,6 +74,7 @@ type Service struct { tx TxRunner // optional; nil runs each method unwrapped (unit tests) audit AuditLogger // optional; nil writes no audit rows (unit tests) branches BranchGuard // optional; nil leaves a payload branch unchecked (unit tests) + shared SharedGuard // optional; nil leaves a shared customer's fields unrestricted (unit tests) now func() time.Time } @@ -328,6 +329,9 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, d *Draft, pre Precon next.UpdatedAt = httpx.TimestampOf(s.now().UTC()) changed := diffCustomer(cur, &next) + if err := s.checkRestricted(ctx, id, headerChanges(cur, &next, restrictedFields(changed))); err != nil { + return err + } if contains(changed, "currency") { if err := s.checkCurrency(ctx, next.Currency); err != nil { return err @@ -364,7 +368,7 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, d *Draft, pre Precon return s.record(ctx, out, EventUpdated, part, map[string]any{"changed": nonNil(changed)}) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } @@ -453,6 +457,11 @@ func (s *Service) SetSalesperson(ctx context.Context, id uuid.UUID, d *Salespers if err := pre.check(cur.Revision); err != nil { return err } + if !eqID(cur.SalespersonID, d.SalespersonID) { + if err := s.checkRestricted(ctx, id, map[string]any{"salesperson_id": map[string]any{"from": cur.SalespersonID, "to": d.SalespersonID}}); err != nil { + return err + } + } if err := s.repo.SetSalesperson(ctx, id, d.SalespersonID); err != nil { return err } @@ -462,7 +471,7 @@ func (s *Service) SetSalesperson(ctx context.Context, id uuid.UUID, d *Salespers return s.record(ctx, out, EventUpdated, PartHeader, map[string]any{"changed": []string{"salesperson_id"}}) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } @@ -503,6 +512,14 @@ func (s *Service) SetEscalationPolicy(ctx context.Context, id uuid.UUID, d *Poli now := httpx.TimestampOf(s.now().UTC()) p.AgreementSignedAt = &now } + before, err := s.repo.GetEscalationPolicy(ctx, id) + if err != nil { + return notFound(err) + } + if err := s.checkRestricted(ctx, id, map[string]any{"escalation_policy": map[string]any{ + "from": policyFacts(before), "to": policyFacts(p)}}); err != nil { + return err + } if err := s.repo.SetEscalationPolicy(ctx, p); err != nil { return err } @@ -516,7 +533,7 @@ func (s *Service) SetEscalationPolicy(ctx context.Context, id uuid.UUID, d *Poli return s.record(ctx, updated, EventUpdated, PartEscalationPolicy, nil) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } @@ -609,3 +626,52 @@ func centsPtrValue(c *httpx.Cents) any { } return int64(*c) } + +// headerChanges is the before and after value of every restricted field a +// header write changes, the body of the audit row a dealer wide key's change +// to a shared customer leaves. +func headerChanges(cur, next *Customer, fields []string) map[string]any { + if len(fields) == 0 { + return nil + } + out := make(map[string]any, len(fields)) + for _, f := range fields { + out[f] = map[string]any{"from": headerValue(cur, f), "to": headerValue(next, f)} + } + return out +} + +func headerValue(c *Customer, field string) any { + switch field { + case "account_number": + return c.AccountNumber + case "name": + return c.Name + case "address": + return c.Address + case "tier": + return string(c.Tier) + case "is_active": + return c.IsActive + case "price_level_id": + return c.PriceLevelID + case "salesperson_id": + return c.SalespersonID + case "credit_limit_cents": + return centsPtrValue(c.CreditLimitCents) + case "currency": + return c.Currency + case "payment_terms_id": + return c.PaymentTermsID + case "po_required": + return c.POrequired + } + return nil +} + +func policyFacts(p *EscalationPolicy) map[string]any { + if p == nil { + return nil + } + return map[string]any{"policy": string(p.Policy), "threshold_percent": int64(p.ThresholdPercent), "agreement_ref": p.AgreementRef} +} diff --git a/core/internal/customer/service_children.go b/core/internal/customer/service_children.go index 0ab72e4d..88d64d21 100644 --- a/core/internal/customer/service_children.go +++ b/core/internal/customer/service_children.go @@ -104,6 +104,9 @@ func (s *Service) UpdateContact(ctx context.Context, id uuid.UUID, d *ContactDra if err := pre.check(cur.Revision); err != nil { return err } + if err := s.checkSharedChild(ctx, cur.CustomerID, "contact"); err != nil { + return err + } next := contactFrom(d, id, cur.CustomerID, httpx.TimestampOf(s.now().UTC())) if err := s.repo.UpdateContact(ctx, next); err != nil { return err @@ -121,7 +124,7 @@ func (s *Service) UpdateContact(ctx context.Context, id uuid.UUID, d *ContactDra return s.record(ctx, cust, EventUpdated, PartContact, map[string]any{"contact_id": id, "action": "updated"}) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } @@ -130,7 +133,7 @@ func (s *Service) DeleteContact(ctx context.Context, id uuid.UUID, pre Precondit if err := needRevision(pre); err != nil { return err } - return s.inTx(ctx, func(ctx context.Context) error { + return s.sharedOutcome(ctx, s.inTx(ctx, func(ctx context.Context) error { if err := s.repo.LockContact(ctx, id); err != nil { return notFound(err) } @@ -141,6 +144,9 @@ func (s *Service) DeleteContact(ctx context.Context, id uuid.UUID, pre Precondit if err := pre.check(cur.Revision); err != nil { return err } + if err := s.checkSharedChild(ctx, cur.CustomerID, "contact"); err != nil { + return err + } if err := s.repo.DeleteContact(ctx, id); err != nil { return err } @@ -149,7 +155,7 @@ func (s *Service) DeleteContact(ctx context.Context, id uuid.UUID, pre Precondit return notFound(err) } return s.record(ctx, cust, EventUpdated, PartContact, map[string]any{"contact_id": id, "action": "deleted"}) - }) + })) } // ---- ship-tos ---- @@ -254,6 +260,9 @@ func (s *Service) UpdateShipTo(ctx context.Context, id uuid.UUID, d *ShipToDraft if err := pre.check(cur.Revision); err != nil { return err } + if err := s.checkSharedChild(ctx, cur.CustomerID, "ship_to"); err != nil { + return err + } next := shipToFrom(d, id, cur.CustomerID, httpx.TimestampOf(s.now().UTC())) if next.IsDefault { if err := s.repo.ClearDefaultShipTo(ctx, cur.CustomerID, id); err != nil { @@ -273,7 +282,7 @@ func (s *Service) UpdateShipTo(ctx context.Context, id uuid.UUID, d *ShipToDraft return s.record(ctx, cust, EventUpdated, PartShipTo, map[string]any{"ship_to_id": id, "action": "updated"}) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } diff --git a/core/internal/customer/shared.go b/core/internal/customer/shared.go new file mode 100644 index 00000000..8f0629d7 --- /dev/null +++ b/core/internal/customer/shared.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package customer + +import ( + "context" + "errors" + "net/http" + "sort" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/google/uuid" +) + +// The shared customer rule (principal rulings 43, 44 and 45). A customer held +// by several branches carries fields that govern the other branches' sales, +// credit and collections. A key bound to one branch may change only the fields +// that are plainly branch local on such a customer; every other field is +// restricted, so a field added to the record is restricted until it is listed +// as branch local here (deny by default). Only a dealer wide key changes a +// restricted field of a shared customer, and the change is audited with the +// actor and the before and after values. A customer held by the pin alone, a +// user, and every unshared customer are unchanged. +// +// Ship-tos and contacts carry no branch column: for a shared customer every +// existing one is shared, so a bound key reads them and may add new ones but +// may not change or delete any. + +// branchLocalFields are the customer header fields a bound key may change on a +// shared customer: contact channels that belong to whoever deals with the +// customer. Everything else a header write can change is restricted. +var branchLocalFields = map[string]bool{"email": true, "phone": true} + +// restrictedFields filters the changed field names of a header write down to +// the restricted ones. +func restrictedFields(changed []string) []string { + var out []string + for _, f := range changed { + if !branchLocalFields[f] { + out = append(out, f) + } + } + return out +} + +// SharedVerdict is a SharedGuard's answer for one customer and one caller. +type SharedVerdict struct { + Refuse bool // a branch bound key, and another branch holds the customer + Branch uuid.UUID // the other branch the refusal names + Audit bool // a dealer wide key, and several branches hold the customer +} + +// SharedGuard decides how the caller stands to a shared customer. The +// customer package cannot import pkg/middleware (which imports this package +// for partner auth), so customerguard adapts the key branch wall to it. A nil +// guard (unit tests) leaves every write unrestricted. +type SharedGuard interface { + Shared(ctx context.Context, customerID uuid.UUID) (SharedVerdict, error) + // RecordRefusal writes the refusal's audit row; it runs after the refused + // write's transaction has rolled back. + RecordRefusal(ctx context.Context) + // RecordChange writes the audit row of a dealer wide key's restricted + // change, inside the write's transaction. + RecordChange(ctx context.Context, customerID uuid.UUID, changes map[string]any) error +} + +// WithSharedGuard wires the shared customer rule. +func (s *Service) WithSharedGuard(g SharedGuard) *Service { + s.shared = g + return s +} + +// sharedRefusal is the 403 a bound key gets on a restricted write; it carries +// the marker the outer method turns into the refusal's audit row. +type sharedRefusal struct{ err *httpx.Error } + +func (e *sharedRefusal) Error() string { return e.err.Message } + +func refusedOn(branch uuid.UUID, fields ...string) *sharedRefusal { + sort.Strings(fields) + details := make([]httpx.FieldError, 0, len(fields)) + for _, f := range fields { + details = append(details, httpx.FieldError{Field: f, Code: httpx.CodeForbidden, Message: "shared with another branch"}) + } + return &sharedRefusal{err: &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, + Message: "a branch bound key may act only on its own branch: the customer is also held by branch " + branch.String(), + Details: details}} +} + +// sharedOutcome turns a refusal raised inside a write's transaction into the +// plain 403 and writes its audit row, now that the transaction is gone. +func (s *Service) sharedOutcome(ctx context.Context, err error) error { + var ref *sharedRefusal + if errors.As(err, &ref) { + if s.shared != nil { + s.shared.RecordRefusal(ctx) + } + return ref.err + } + return err +} + +// checkRestricted applies the rule to a write that changes the restricted +// fields given, with their before and after values. It returns a refusal for +// a bound key on a shared customer and writes the audit row for a dealer wide +// key. A write that changes no restricted field is not asked. +func (s *Service) checkRestricted(ctx context.Context, id uuid.UUID, changes map[string]any) error { + if s.shared == nil || len(changes) == 0 { + return nil + } + verdict, err := s.shared.Shared(ctx, id) + if err != nil { + return err + } + if verdict.Refuse { + fields := make([]string, 0, len(changes)) + for f := range changes { + fields = append(fields, f) + } + return refusedOn(verdict.Branch, fields...) + } + if verdict.Audit { + return s.shared.RecordChange(ctx, id, changes) + } + return nil +} + +// checkSharedChild applies the ship-to and contact rule: a bound key may not +// change or delete one on a shared customer. No audit row for anyone else. +func (s *Service) checkSharedChild(ctx context.Context, customerID uuid.UUID, field string) error { + if s.shared == nil { + return nil + } + verdict, err := s.shared.Shared(ctx, customerID) + if err != nil { + return err + } + if verdict.Refuse { + return refusedOn(verdict.Branch, field) + } + return nil +} diff --git a/core/pkg/audit/audit.go b/core/pkg/audit/audit.go index fa897494..2fd23a79 100644 --- a/core/pkg/audit/audit.go +++ b/core/pkg/audit/audit.go @@ -430,6 +430,26 @@ func (l *Logger) AuditKeyBranchRefusal(ctx context.Context, keyID string, branch } } +// AuditSharedCustomerChange records a dealer wide machine key's change to the +// restricted fields of a customer held by more than one branch (principal +// ruling 43): the row's entity is the customer, its changes carry the key as +// the actor and the before and after value of every field. It implements the +// middleware package's SharedAuditor seam and joins the caller's transaction, +// so a failed row fails the change. +func (l *Logger) AuditSharedCustomerChange(ctx context.Context, keyID string, customer uuid.UUID, changes map[string]any) error { + out := make(map[string]any, len(changes)+1) + for k, v := range changes { + out[k] = v + } + out["actor_key_id"] = keyID + return l.Log(ctx, Entry{ + Action: "customer.shared_restricted_changed", + EntityType: "customer", + EntityID: customer, + Changes: out, + }) +} + // Drain is retained for graceful-shutdown callers: writes are synchronous // now, so there is never anything in flight to wait for. func (l *Logger) Drain() {} diff --git a/core/pkg/middleware/keybranch_shared.go b/core/pkg/middleware/keybranch_shared.go new file mode 100644 index 00000000..8805556c --- /dev/null +++ b/core/pkg/middleware/keybranch_shared.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package middleware + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// The shared customer rule (principal rulings 43, 44 and 45, ADR 0007 section +// 5.5): a customer held by the pin's branch and by another branch carries +// fields that govern the other branch's sales, credit and collections, and a +// key bound to the pin may not change them. The customer service decides per +// field and calls SharedCustomer for the verdict; this file holds the lookup, +// the request note the refusal row needs, and the audit seam for the dealer +// wide key that is allowed to make the change. + +type requestNoteKey struct{} + +type requestNote struct{ method, path string } + +// RecordRequest notes the method and path of the request in its context, so +// a refusal written below the HTTP layer (the customer service holds the +// field level rule) can name the call in its key.branch_refused row. +func (w *KeyBranchWall) RecordRequest() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), requestNoteKey{}, requestNote{method: r.Method, path: r.URL.Path}) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + +// SharedVerdict is the wall's answer for one customer and one caller. +type SharedVerdict struct { + // Refuse is set for a branch bound key when another branch holds the + // customer too; Branch is the lowest numbered such branch. + Refuse bool + Branch uuid.UUID + // Audit is set for an unbound key when two or more branches hold the + // customer: its change goes through, audited with the actor and the + // before and after values. + Audit bool +} + +// SharedAuditor writes the audit row of a dealer wide key's change to a +// shared customer's restricted fields. pkg/audit implements it. +type SharedAuditor interface { + AuditSharedCustomerChange(ctx context.Context, keyID string, customer uuid.UUID, changes map[string]any) error +} + +// SharedCustomer answers how the caller stands to a customer's restricted +// fields. A user passes unchanged (the zero verdict); a branch bound key is +// refused when any branch besides the pin holds the customer; an unbound key +// is audited when more than one branch holds it. +func (w *KeyBranchWall) SharedCustomer(ctx context.Context, customer uuid.UUID) (SharedVerdict, error) { + if _, isKey := KeyIDFromContext(ctx); !isKey { + return SharedVerdict{}, nil + } + if w.db == nil { + return SharedVerdict{}, errors.New("key branch wall: no database for the shared customer lookup") + } + pin, bound := KeyBranchPin(ctx) + if bound { + var other uuid.UUID + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT branch_id FROM customer_branches WHERE customer_id = $1 AND branch_id <> $2 ORDER BY branch_id LIMIT 1`, + customer, pin).Scan(&other) + if errors.Is(err, pgx.ErrNoRows) { + return SharedVerdict{}, nil + } + if err != nil { + return SharedVerdict{}, fmt.Errorf("key branch wall: shared customer lookup: %w", err) + } + return SharedVerdict{Refuse: true, Branch: other}, nil + } + var branches int + if err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT COUNT(*) FROM customer_branches WHERE customer_id = $1`, customer).Scan(&branches); err != nil { + return SharedVerdict{}, fmt.Errorf("key branch wall: shared customer lookup: %w", err) + } + return SharedVerdict{Audit: branches > 1}, nil +} + +// RecordSharedRefusal writes the key.branch_refused row of a refusal the +// customer service served. It runs outside the refused write's transaction +// (which rolled back), with the method and path RecordRequest noted. +func (w *KeyBranchWall) RecordSharedRefusal(ctx context.Context) { + pin, bound := KeyBranchPin(ctx) + keyID, isKey := KeyIDFromContext(ctx) + if !bound || !isKey || w.auditor == nil { + return + } + note, _ := ctx.Value(requestNoteKey{}).(requestNote) + w.auditor.AuditKeyBranchRefusal(ctx, keyID, pin, note.method, note.path) +} + +// RecordSharedChange writes the audit row of an unbound key's change to a +// shared customer's restricted fields, inside the caller's transaction. A +// wall with no audit writer fails the change rather than let it go unrecorded. +func (w *KeyBranchWall) RecordSharedChange(ctx context.Context, customer uuid.UUID, changes map[string]any) error { + keyID, isKey := KeyIDFromContext(ctx) + if !isKey { + return nil + } + a, ok := w.auditor.(SharedAuditor) + if !ok { + return errors.New("key branch wall: no audit writer for a shared customer change") + } + return a.AuditSharedCustomerChange(ctx, keyID, customer, changes) +} From 382c1f73d6d8890d5eea836bf8cae9b00e04b7d3 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 17:25:01 -0700 Subject: [PATCH 35/74] test: rebuild the walled twin sweep on valid bodies and the twins' own ids Round 8 and 9 found the sweep vacuous: it sent {} bodies that failed validation before the wall was reached, counted whole tables that other packages move, and skipped about 25 walled routes. Every write leg now sends a body that passes validation, shadowed by a control against the pin's own twin that must pass the module's validation and reach the row (a leg with no possible control names why); the foreign leg must answer 403 or 404 and never a scope refusal; changes are measured by a digest of every row that names branch B's twin rows by id, never by table counts. A coverage test holds the route allowlist against the legs and fails by name for any WALLED route no bound key leg drives. The fixture now builds the order service as serve does (price engine, exposure gate, quote convert) and the draft change feed, and removes the rows and keys it makes. The sweep found a real gap: the order exposure gate and override looked up the order's source quote by id alone, so a key pinned to A could read the gate of, and clear the exposure of, a quote behind branch B's order. Both now read the order under the branch wall first. Also: the branch named in the "also held by" refusal is the lowest numbered branch that is not the pin, the same answer every time. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_key_branch_allow_test.go | 11 +- .../app/serve/wire_key_branch_legs_test.go | 250 +++++++ .../app/serve/wire_key_branch_pin_test.go | 41 +- .../app/serve/wire_key_branch_round4_test.go | 4 + .../app/serve/wire_key_branch_shared_test.go | 4 +- .../app/serve/wire_key_branch_walled_test.go | 626 +++++++++++++----- .../serve/wire_key_branch_zcoverage_test.go | 57 ++ core/internal/order/service.go | 13 + core/pkg/middleware/keybranch.go | 8 +- 9 files changed, 840 insertions(+), 174 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_legs_test.go create mode 100644 core/internal/app/serve/wire_key_branch_zcoverage_test.go diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index dcc1c406..746903bb 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -347,10 +347,12 @@ func newAllowlistGateFixture(t *testing.T) *allowlistGateFixture { keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) mint := func(name string, branch *uuid.UUID) string { t.Helper() - raw, _, err := keys.GenerateKey(ctx, name, middleware.ValidScopeGrammar(), branch) + raw, key, err := keys.GenerateKey(ctx, name, middleware.ValidScopeGrammar(), branch) if err != nil { t.Fatalf("mint %s: %v", name, err) } + // The bound key references the branch, so it goes before the branch. + t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM api_keys WHERE id = $1`, key.ID) }) return raw } f := &allowlistGateFixture{t: t, @@ -611,6 +613,13 @@ func TestKeyBranchPin_SharedCustomerWrites(t *testing.T) { t.Fatalf("seed shared branches: %v", err) } t.Cleanup(func() { + ctx := context.Background() + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM tax_exemptions WHERE customer_id = $1`, shared) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type IN ('category_rule', 'pricing_rule') + AND entity_id IN (SELECT id::text FROM category_pricing_rules WHERE customer_id = $1 UNION SELECT id::text FROM pricing_rules WHERE customer_id = $1)`, shared) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM category_pricing_audit WHERE rule_id IN (SELECT id FROM category_pricing_rules WHERE customer_id = $1)`, shared) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM category_pricing_rules WHERE customer_id = $1`, shared) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pricing_rules WHERE customer_id = $1`, shared) _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_branches WHERE customer_id = $1`, shared) _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customers WHERE id = $1`, shared) }) diff --git a/core/internal/app/serve/wire_key_branch_legs_test.go b/core/internal/app/serve/wire_key_branch_legs_test.go new file mode 100644 index 00000000..71a6a755 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_legs_test.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The coverage half of the WALLED claim (ADR 0007 section 5.5): the tests that +// drive walled routes with a key bound to branch A mark every call they send +// through the pin fixture's recorder, and TestKeyBranchPin_ZWalledRoutesAllHaveALeg +// holds the route allowlist against the marks, so a WALLED entry no test +// drives with a bound key fails by name. The recorder is per process: the +// coverage test runs after the drivers (file order) and skips when a -run +// filter kept one of them from running. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/pkg/database" + "github.com/google/uuid" +) + +// walledLegDrivers are the tests whose legs the coverage test needs. +var walledLegDrivers = []string{"sweep", "pos", "body"} + +var walledLegs = struct { + sync.Mutex + seen map[string]bool + drivers map[string]bool +}{seen: map[string]bool{}, drivers: map[string]bool{}} + +// record makes the fixture mark every call its bound keys send, from here on. +func (f *keyPinFixture) record() { f.recording = true } + +// mark notes one bound key call when the fixture records. +func (f *keyPinFixture) mark(raw, method, path string) { + if !f.recording || (raw != f.bound && raw != f.bound2 && raw != f.boundFirst) { + return + } + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + walledLegs.Lock() + walledLegs.seen[method+" "+path] = true + walledLegs.Unlock() +} + +// driverDone notes that a driving test ran to its end. +func driverDone(t *testing.T, name string) { + t.Helper() + if t.Failed() { + return + } + walledLegs.Lock() + walledLegs.drivers[name] = true + walledLegs.Unlock() +} + +// twinState is a digest, per table, of every row that names branch B's twin +// rows by id: the rows themselves and the rows that point at them. A refused +// leg that wrote anywhere B owns or is named by shows as a changed digest, +// whatever other packages do to the same tables meanwhile. +func (f *keyPinFixture) twinState(t *testing.T, w *walledTwins) map[string]string { + t.Helper() + q := func(id uuid.UUID) string { return "'" + id.String() + "'" } + custB, branchB := q(f.custB), q(f.branchB) + reg := "'" + f.regB + "'" + rows := map[string]string{ + "orders": `SELECT * FROM orders WHERE id = ` + q(w.ordB) + ` OR customer_id = ` + custB + ` OR branch_id = ` + branchB, + "order_lines": `SELECT * FROM order_lines WHERE order_id IN (SELECT id FROM orders WHERE id = ` + q(w.ordB) + ` OR customer_id = ` + custB + ` OR branch_id = ` + branchB + `)`, + "invoices": `SELECT * FROM invoices WHERE id = ` + q(w.invB) + ` OR customer_id = ` + custB + ` OR branch_id = ` + branchB, + "credit_memos": `SELECT * FROM credit_memos WHERE id = ` + q(w.memoB) + ` OR customer_id = ` + custB + ` OR branch_id = ` + branchB, + "payments": `SELECT * FROM payments WHERE id = ` + q(w.payB) + ` OR customer_id = ` + custB + ` OR branch_id = ` + branchB, + "payment_refunds": `SELECT * FROM payment_refunds WHERE payment_id = ` + q(w.payB) + ` OR credit_memo_id = ` + q(w.memoB), + "ar_applications": `SELECT * FROM ar_applications WHERE id = ` + q(w.appB) + ` OR customer_id = ` + custB + ` OR invoice_id = ` + q(w.invB) + ` OR payment_id = ` + q(w.payB) + ` OR credit_memo_id = ` + q(w.memoB), + "customers": `SELECT * FROM customers WHERE id = ` + custB, + "activities": `SELECT * FROM crm_activities WHERE customer_id = ` + custB, + "contacts": `SELECT * FROM customer_contacts WHERE customer_id = ` + custB, + "ship_tos": `SELECT * FROM customer_ship_tos WHERE customer_id = ` + custB, + "quotes": `SELECT * FROM quotes WHERE id = ` + q(f.quoteB) + ` OR branch_id = ` + branchB, + "quote_lines": `SELECT * FROM quote_lines WHERE quote_id = ` + q(f.quoteB), + "deliveries": `SELECT * FROM deliveries WHERE id = ` + q(w.delB) + ` OR order_id = ` + q(w.ordB) + ` OR route_id = ` + q(w.routeB), + "delivery_routes": `SELECT * FROM delivery_routes WHERE id = ` + q(w.routeB), + "drafts": `SELECT * FROM drafts WHERE id IN (` + q(w.draftQB) + `, ` + q(w.draftOB) + `) OR branch_id = ` + branchB, + "purchase_orders": `SELECT * FROM purchase_orders WHERE id = ` + q(w.poB) + ` OR branch_id = ` + branchB, + "po_lines": `SELECT * FROM purchase_order_lines WHERE po_id = ` + q(w.poB), + "po_freight": `SELECT * FROM po_freight_charges WHERE po_id = ` + q(w.poB), + "inventory": `SELECT * FROM inventory WHERE location_id = ` + q(f.yardB), + "locations": `SELECT * FROM locations WHERE id IN (` + q(f.yardB) + `, ` + branchB + `) OR parent_id IN (` + q(f.yardB) + `, ` + branchB + `)`, + "pos_txns": `SELECT * FROM pos_transactions WHERE id = ` + q(f.txnB) + ` OR branch_id = ` + branchB + ` OR register_id = ` + reg, + "pos_lines": `SELECT * FROM pos_line_items WHERE transaction_id = ` + q(f.txnB), + "pos_tenders": `SELECT * FROM pos_tenders WHERE transaction_id = ` + q(f.txnB), + "pos_returns": `SELECT * FROM pos_returns WHERE branch_id = ` + branchB + ` OR register_id = ` + reg, + "tills": `SELECT * FROM till_sessions WHERE id = ` + q(f.tillB) + ` OR register_id = ` + reg, + "z_reports": `SELECT * FROM till_z_reports WHERE branch_id = ` + branchB, + } + out := make(map[string]string, len(rows)) + for name, sel := range rows { + var d string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT md5(COALESCE(string_agg(x::text, ',' ORDER BY x::text), '')) FROM (`+sel+`) x`).Scan(&d); err != nil { + t.Fatalf("twin state %s: %v", name, err) + } + out[name] = d + } + return out +} + +// changedTables names the tables whose twin digest differs. +func changedTables(before, after map[string]string) []string { + var out []string + for name, d := range before { + if after[name] != d { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// multipartFile builds a one file multipart body and its content type. +func multipartFile(field, name, content string) (string, string) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, _ := mw.CreateFormFile(field, name) + _, _ = part.Write([]byte(content)) + _ = mw.Close() + return buf.String(), mw.FormDataContentType() +} + +// customerReplace builds a PUT /customers/{id} body from the customer's +// current values with the change given applied, on the revision it reads. +func (f *keyPinFixture) customerReplace(t *testing.T, id uuid.UUID, change map[string]any) string { + t.Helper() + cur := f.readJSON(t, "/api/v1/customers/"+id.String()) + body := map[string]any{"revision": cur["revision"]} + for _, k := range []string{"account_number", "name", "email", "phone", "address", "tier", "is_active", + "price_level_id", "salesperson_id", "credit_limit_cents", "currency", "payment_terms_id", "po_required"} { + body[k] = cur[k] + } + for k, v := range change { + body[k] = v + } + raw, _ := json.Marshal(body) + return string(raw) +} + +// stream opens a server sent event stream as the key given, reads it for the +// time given and returns the status with everything it sent. +func (f *keyPinFixture) stream(t *testing.T, raw, path string, d time.Duration) (int, string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), d) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", f.srv.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+raw) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + buf, _ := io.ReadAll(res.Body) // the deadline ends the stream; what arrived before it is the answer + f.mark(raw, "GET", path) + return res.StatusCode, string(buf) +} + +// madeWith runs a create that must answer want, with the headers given. +func (f *keyPinFixture) madeWith(t *testing.T, who, table, method, path, body string, hdr map[string]string, want int) uuid.UUID { + t.Helper() + status, buf := f.send(t, who, method, path, body, hdr) + if status != want { + t.Errorf("%s %s %s: %d %s, want %d", who, method, path, status, buf, want) + return uuid.Nil + } + var doc struct { + ID uuid.UUID `json:"id"` + } + if err := json.Unmarshal(buf, &doc); err != nil || doc.ID == uuid.Nil { + return uuid.Nil + } + if table != "" { + f.madeRow(t, table, doc.ID) + } + return doc.ID +} + +// purgeFixtureRows removes, children first, every document row the fixture's +// customers and branches picked up while tests drove the routes, so the shared +// database keeps nothing of a run and the customer and branch rows can go. +func purgeFixtureRows(ctx context.Context, db *database.DB, customers, branches []uuid.UUID) { + // The ids are the fixture's own generated uuids, written into the + // statements as literals so a statement need not use both lists. + literal := func(ids []uuid.UUID) string { + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, "'"+id.String()+"'") + } + return "ARRAY[" + strings.Join(parts, ",") + "]::uuid[]" + } + lists := strings.NewReplacer("$1", literal(customers), "$2", literal(branches)) + own := func(table string) string { + return `(SELECT id FROM ` + table + ` WHERE customer_id = ANY($1) OR branch_id = ANY($2))` + } + for _, stmt := range []string{ + `DELETE FROM ar_applications WHERE customer_id = ANY($1)`, + `DELETE FROM payment_refunds WHERE payment_id IN ` + own("payments") + ` OR credit_memo_id IN ` + own("credit_memos"), + `DELETE FROM tender_lines WHERE payment_id IN ` + own("payments") + ` OR invoice_id IN ` + own("invoices"), + `DELETE FROM payments WHERE customer_id = ANY($1) OR branch_id = ANY($2)`, + `DELETE FROM credit_memo_lines WHERE credit_memo_id IN ` + own("credit_memos"), + `DELETE FROM credit_memos WHERE customer_id = ANY($1) OR branch_id = ANY($2)`, + `DELETE FROM invoice_lines WHERE invoice_id IN ` + own("invoices"), + `DELETE FROM invoices WHERE customer_id = ANY($1) OR branch_id = ANY($2)`, + `DELETE FROM order_fulfillment_requests WHERE order_id IN ` + own("orders"), + `DELETE FROM order_allocation_requests WHERE order_id IN ` + own("orders"), + `DELETE FROM delivery_qty_adjustments WHERE delivery_id IN (SELECT id FROM deliveries WHERE order_id IN ` + own("orders") + `)`, + `DELETE FROM delivery_pod_photos WHERE delivery_id IN (SELECT id FROM deliveries WHERE order_id IN ` + own("orders") + `)`, + `DELETE FROM delivery_notifications WHERE delivery_id IN (SELECT id FROM deliveries WHERE order_id IN ` + own("orders") + `)`, + `DELETE FROM deliveries WHERE order_id IN ` + own("orders"), + `DELETE FROM order_lines WHERE order_id IN ` + own("orders"), + `DELETE FROM orders WHERE customer_id = ANY($1) OR branch_id = ANY($2)`, + `DELETE FROM quote_exposure_events WHERE quote_id IN ` + own("quotes"), + `DELETE FROM quote_lines WHERE quote_id IN ` + own("quotes"), + `DELETE FROM quotes WHERE customer_id = ANY($1) OR branch_id = ANY($2)`, + `DELETE FROM draft_events WHERE draft_id IN (SELECT id FROM drafts WHERE branch_id = ANY($2))`, + `DELETE FROM drafts WHERE branch_id = ANY($2)`, + `DELETE FROM po_freight_charges WHERE po_id IN (SELECT id FROM purchase_orders WHERE branch_id = ANY($2))`, + `DELETE FROM purchase_order_lines WHERE po_id IN (SELECT id FROM purchase_orders WHERE branch_id = ANY($2))`, + `DELETE FROM purchase_orders WHERE branch_id = ANY($2)`, + `DELETE FROM pos_tenders WHERE transaction_id IN (SELECT id FROM pos_transactions WHERE branch_id = ANY($2))`, + `DELETE FROM pos_line_items WHERE transaction_id IN (SELECT id FROM pos_transactions WHERE branch_id = ANY($2))`, + `DELETE FROM pos_return_lines WHERE return_id IN (SELECT id FROM pos_returns WHERE branch_id = ANY($2))`, + `DELETE FROM pos_returns WHERE branch_id = ANY($2)`, + `DELETE FROM pos_transactions WHERE branch_id = ANY($2)`, + `DELETE FROM customer_ship_tos WHERE customer_id = ANY($1)`, + `DELETE FROM customer_contacts WHERE customer_id = ANY($1)`, + `DELETE FROM crm_activities WHERE customer_id = ANY($1)`, + } { + _, _ = db.Pool.Exec(ctx, lists.Replace(stmt)) + } +} diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index c978bae3..ba4d52b5 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -22,6 +22,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/ap" @@ -121,6 +122,8 @@ type keyPinFixture struct { unbound2 string // the same scopes, no pin boundKeyID string // the bound key's id, the actor its refusal audit rows name bound2KeyID string // the second bound key's id + boundFirst string // the first mint's bound key, kept when a test swaps in the second + recording bool // mark every bound key call for the walled route coverage test } // pinKeyScopes opens every route under test: the location writes, the branch @@ -137,7 +140,7 @@ var pinDocumentScopes = []string{ "payments:read", "payments:write", "credit-memos:read", "credit-memos:write", "orders:read", "orders:write", "orders:propose", "orders:commit", "quotes:propose", "quotes:commit", "dashboard:read", "inventory:read", "inventory:write", "locations:read", "locations:write", - "products:read", "branches:read", + "products:read", "branches:read", "documents:read", "activities:read", "activities:write", "ar:read", "ar:write", "matching:read", "matching:write", "delivery:read", "delivery:write", "purchase-orders:read", "purchase-orders:write", "quotes:read", "quotes:write", } @@ -309,6 +312,8 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Fatalf("seed z reports: %v", err) } t.Cleanup(func() { + purgeFixtureRows(ctx, db, []uuid.UUID{f.custID, f.custB}, []uuid.UUID{f.branchA, f.branchB, f.branchC, f.branchC2}) + _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE account_number LIKE 'PINNEW-%'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote_exposure_event' AND entity_id IN (SELECT id::text FROM quote_exposure_events WHERE quote_id IN ($1, $2))`, f.quoteA, f.quoteB) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'quote' AND entity_id IN ($1, $2)`, f.quoteA, f.quoteB) @@ -481,7 +486,15 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { quoteSvc := quote.NewService(quote.NewRepository(db)). WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db) wall.quotes(mux, quoteSvc) - orderSvc := order.NewService(order.NewRepository(db)).WithTxRunner(db) + // The order service exactly as serve builds it: the price engine, the tax + // provider, inventory, the invoice writer and the pre-ship exposure gate. + orderSvc := orderwire.New(orderwire.Deps{ + DB: db, Config: &config.Config{}, Logger: slog.Default(), AuditLog: audit.NewLogger(db), + Inventory: inventory.NewService(inventory.NewRepository(db)), Invoices: invoiceSvc, Accounts: accountSvc, + Pricing: pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), + Customers: customerSvc, Escalators: escRepo, QuoteLines: quoteRepo, + }) + quoteSvc.WithOrderCreator(orderSvc) wall.orders(mux, orderSvc) wall.invoices(mux, invoiceSvc.WithOrders(orderSvc).WithStock(inventory.NewService(inventory.NewRepository(db)))) wall.payments(mux, paymentSvc.WithBranchGuard(wall.guard)) @@ -495,10 +508,19 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { if derr != nil { t.Fatalf("draft kind registration: %v", derr) } - draftsSvc := drafts.NewService(drafts.NewRepository(db), draftsRegistry). + keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) + draftsRepo := drafts.NewRepository(db) + draftHub := drafts.NewHub(draftsRepo, 100*time.Millisecond, slog.Default()) + t.Cleanup(draftHub.Stop) + draftsSvc := drafts.NewService(draftsRepo, draftsRegistry). WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db)). - WithBranchGuard(wall.guard) - draftsHandler := drafts.NewHandler(draftsSvc) + WithBranchGuard(wall.guard).WithFeed(draftHub) + draftsHandler := drafts.NewHandler(draftsSvc).WithFeedHandler( + drafts.NewFeedHandler(draftsSvc, draftsRepo, draftHub, drafts.FeedSettings{ + Heartbeat: 200 * time.Millisecond, Poll: 100 * time.Millisecond, Batch: 100, + WriteTimeout: 2 * time.Second, MaxLifetime: 10 * time.Second, Retention: time.Hour, + MaxStreamsPerPrincipal: 8, MaxStreams: 32, + }, func(ctx context.Context, keyID uuid.UUID) (bool, error) { return keys.KeyActive(ctx, keyID) })) quote.RegisterDraftRoutes(mux, draftsHandler, quoteKind, wall.draftGuard(db, "quotes", quoteKind.Roles()...)) order.RegisterDraftRoutes(mux, draftsHandler, orderKind, wall.draftGuard(db, "orders", orderKind.Roles()...)) linksHandler := links.NewHandler(links.Settings{}, @@ -565,7 +587,6 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { RegisterRoutes(mux, wall.scoped("admin", "owner", "finance")) wall.inventory(mux, inventory.NewService(inventory.NewRepository(db))) - keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) techadmin.NewHandler(keys).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) mint := func(name string, branch *uuid.UUID) (string, string) { t.Helper() @@ -577,6 +598,11 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { if err != nil { t.Fatalf("mint %s: %v", name, err) } + // A bound key references its branch, so the fixture's branch rows can + // only be removed once the keys it minted are gone. + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM api_keys WHERE id = $1`, key.ID) + }) return raw, key.ID.String() } f.bound, f.boundKeyID = mint("pin bound to A", &f.branchA) @@ -698,6 +724,7 @@ func (f *keyPinFixture) key(t *testing.T, raw, method, path, body string, hdr ma if err != nil { t.Fatal(err) } + f.mark(raw, method, path) return res.StatusCode, buf } @@ -1480,6 +1507,8 @@ func TestKeyBranchPin_RowWithoutBranch(t *testing.T) { // refused outright (the allowlist gate), like the GL surface. func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { f := newKeyPinFixture(t) + f.record() + defer driverDone(t, "pos") custA, custB := f.custID.String(), f.custB.String() missing := uuid.NewString() linesOn := func(tx uuid.UUID) (n int) { diff --git a/core/internal/app/serve/wire_key_branch_round4_test.go b/core/internal/app/serve/wire_key_branch_round4_test.go index c2d059e5..b9b2b439 100644 --- a/core/internal/app/serve/wire_key_branch_round4_test.go +++ b/core/internal/app/serve/wire_key_branch_round4_test.go @@ -46,6 +46,8 @@ func (f *keyPinFixture) madeRow(t *testing.T, table string, id uuid.UUID) { ctx := context.Background() exec := func(stmt string) { _, _ = f.db.Pool.Exec(ctx, stmt, id) } switch table { + case "drafts": + exec(`DELETE FROM draft_events WHERE draft_id = $1`) case "payments": exec(`DELETE FROM ar_applications WHERE payment_id = $1`) case "orders": @@ -164,6 +166,8 @@ func (f *keyPinFixture) refusedPin(t *testing.T, label, method, path, body strin // the reach they had. func TestKeyBranchPin_BodyCustomerWall(t *testing.T) { f, _ := round4Fixture(t) + f.record() + defer driverDone(t, "body") A, B := f.custID.String(), f.custB.String() line := f.productLine() diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index 0e90c5f5..00cf8c59 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -40,7 +40,7 @@ func seedSharedCustomer(t *testing.T, f *keyPinFixture, branches ...uuid.UUID) * } exec(`INSERT INTO customer_ship_tos (id, customer_id, code, name, line1) VALUES ($1, $2, 'shared', 'pin shared', 'pin')`, s.shipTo, s.id) exec(`INSERT INTO customer_contacts (id, customer_id, first_name, last_name) VALUES ($1, $2, 'pin', 'shared')`, s.contact, s.id) - exec(`INSERT INTO sales_team (id, name, email, role) VALUES ($1, 'pin rep', $2, 'sales')`, s.salesperson, "pin-rep-"+s.salesperson.String()[:8]+"@example.com") + exec(`INSERT INTO sales_team (id, name, email, phone, role) VALUES ($1, 'pin rep', $2, '555-0100', 'sales')`, s.salesperson, "pin-rep-"+s.salesperson.String()[:8]+"@example.com") rows, err := f.db.Pool.Query(ctx, `SELECT id FROM payment_terms ORDER BY code LIMIT 2`) if err != nil { t.Fatal(err) @@ -62,13 +62,13 @@ func seedSharedCustomer(t *testing.T, f *keyPinFixture, branches ...uuid.UUID) * t.Fatalf("a price level: %v", err) } t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `UPDATE customers SET salesperson_id = NULL WHERE salesperson_id = $1`, s.salesperson) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM audit_log WHERE entity_id = $1 AND action LIKE 'customer.%'`, s.id) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'customer' AND entity_id = $1::text`, s.id) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_contacts WHERE customer_id = $1`, s.id) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_ship_tos WHERE customer_id = $1`, s.id) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id = $1`, s.id) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, s.id) - _, _ = f.db.Pool.Exec(ctx, `UPDATE customers SET salesperson_id = NULL WHERE salesperson_id = $1`, s.salesperson) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM sales_team WHERE id = $1`, s.salesperson) }) return s diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go index 634c3c23..31169316 100644 --- a/core/internal/app/serve/wire_key_branch_walled_test.go +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -16,11 +16,16 @@ package serve import ( "context" + "encoding/json" "fmt" "net/http" + "os" "strings" "testing" + "time" + "github.com/gablelbm/gable/internal/drafts" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/google/uuid" ) @@ -38,6 +43,7 @@ type walledTwins struct { poA, poB, poLineA, poLineB, freightA, freightB uuid.UUID appA, appB uuid.UUID shipA, shipB uuid.UUID + vendor uuid.UUID } // seedWalledTwins seeds the twin rows with their cleanup. @@ -55,6 +61,7 @@ func (f *keyPinFixture) seedWalledTwins(t *testing.T) *walledTwins { shipA: uuid.New(), shipB: uuid.New(), } vendor := uuid.New() + w.vendor = vendor vendorName := "pin twin vendor " + vendor.String()[:8] if _, err := f.db.Pool.Exec(ctx, `INSERT INTO vendors (id, name) VALUES ($1, $2)`, vendor, vendorName); err != nil { t.Fatalf("seed vendor: %v", err) @@ -156,6 +163,13 @@ func (f *keyPinFixture) seedWalledTwins(t *testing.T) *walledTwins { t.Fatalf("seed purchase order line: %v", err) } } + for _, fr := range []struct{ id, po uuid.UUID }{{w.freightA, w.poA}, {w.freightB, w.poB}} { + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO po_freight_charges (id, po_id, carrier_name, invoice_number, total_amount_cents, status) + VALUES ($1, $2, 'pin carrier', 'PIN-FRT', 100, 'PENDING')`, fr.id, fr.po); err != nil { + t.Fatalf("seed freight charge: %v", err) + } + } if _, err := f.db.Pool.Exec(ctx, `INSERT INTO inventory (product_id, location_id, location, quantity) VALUES ($1, $2, 'pin', 7), ($1, $3, 'pin', 9)`, f.posProd, f.yardA, f.yardB); err != nil { @@ -178,9 +192,13 @@ func (f *keyPinFixture) seedWalledTwins(t *testing.T) *walledTwins { } exec(`DELETE FROM inventory WHERE product_id = $1 AND location_id IN ($2, $3)`, f.posProd, f.yardA, f.yardB) exec(`DELETE FROM ar_applications WHERE id IN ($1, $2)`, w.appA, w.appB) + exec(`DELETE FROM po_freight_charges WHERE po_id IN ($1, $2)`, w.poA, w.poB) exec(`DELETE FROM purchase_order_lines WHERE po_id IN ($1, $2)`, w.poA, w.poB) exec(`DELETE FROM purchase_orders WHERE id IN ($1, $2)`, w.poA, w.poB) exec(`DELETE FROM drafts WHERE id IN ($1, $2, $3, $4)`, w.draftQA, w.draftQB, w.draftOA, w.draftOB) + for _, child := range []string{"delivery_pod_photos", "delivery_qty_adjustments", "delivery_notifications", "order_fulfillment_requests"} { + exec(`DELETE FROM `+child+` WHERE delivery_id IN (SELECT id FROM deliveries WHERE id IN ($1, $2) OR route_id IN ($3, $4))`, w.delA, w.delB, w.routeA, w.routeB) + } exec(`DELETE FROM deliveries WHERE id IN ($1, $2) OR route_id IN ($3, $4)`, w.delA, w.delB, w.routeA, w.routeB) exec(`DELETE FROM delivery_routes WHERE id IN ($1, $2)`, w.routeA, w.routeB) exec(`DELETE FROM credit_memos WHERE id IN ($1, $2)`, w.memoA, w.memoB) @@ -195,24 +213,6 @@ func (f *keyPinFixture) seedWalledTwins(t *testing.T) *walledTwins { return w } -// familyCounts reads the row counts of every table the sweep's write legs -// could touch, so a confined write that still wrote shows as a diff. -func (f *keyPinFixture) familyCounts(t *testing.T) map[string]int { - t.Helper() - tables := []string{"orders", "invoices", "credit_memos", "payments", "crm_activities", "customer_contacts", - "deliveries", "delivery_routes", "drafts", "purchase_orders", "purchase_order_lines", - "po_freight_charges", "inventory", "quotes", "customer_ship_tos", "ar_applications"} - out := map[string]int{} - for _, tb := range tables { - var n int - if err := f.db.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM `+tb).Scan(&n); err != nil { - t.Fatal(err) - } - out[tb] = n - } - return out -} - // TestKeyBranchPin_WalledRowsHoldThePin drives every WALLED route that takes // an id (or a list) with branch B's twin row: the bound key's answer for the // foreign row equals its answer for a missing row (or a 403 or 404) and never @@ -222,133 +222,396 @@ func (f *keyPinFixture) familyCounts(t *testing.T) map[string]int { func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { f := newKeyPinFixture(t) // The sweep's key is the document family mint: the families it drives - // live in the second scope list. + // live in the second scope list. The first mint keeps the pos and AR scopes. + firstBound := f.bound f.bound, f.unbound, f.boundKeyID = f.bound2, f.unbound2, f.bound2KeyID w := f.seedWalledTwins(t) missing := uuid.NewString() A, B := f.branchA.String(), f.branchB.String() - // Every by id route with the foreign twin it drives. + f.record() + defer driverDone(t, "sweep") + f.boundFirst = firstBound + line, qline := f.productLine(), f.quoteLine() + ownCust := func(row uuid.UUID) string { + if row == w.ordB || row == w.memoB || row == w.payB || row == w.invB { + return f.custB.String() + } + return f.custID.String() + } + quotePut := func(uuid.UUID) string { + return fmt.Sprintf(`{"customer_id":%q,"revision":1,"lines":[%s]}`, f.custID.String(), qline) + } + orderPut := func(row uuid.UUID) string { + return fmt.Sprintf(`{"customer_id":%q,"delivery_type":"pickup","revision":1,"lines":[%s]}`, ownCust(row), line) + } + creditPut := func(row uuid.UUID) string { + return fmt.Sprintf(`{"customer_id":%q,"reason_code":"other","reason":"pin","revision":1,"lines":[`+ + `{"description":"pin adjustment","quantity":"-1","uom":"EA","unit_price_ten_thousandths":10000}]}`, ownCust(row)) + } + activityPut := func(uuid.UUID) string { + return `{"activity_type":"call","description":"pin twin","revision":1}` + } + contactPut := func(uuid.UUID) string { + return `{"first_name":"pin","last_name":"twin","can_place_orders":true,"order_limit_cents":null,"is_primary":false,"is_active":true,"revision":1}` + } + shipToPut := func(uuid.UUID) string { + return `{"code":"twin","name":"pin twin","line1":"pin","revision":1}` + } + draftPut := func(payloadLine string) func(uuid.UUID) string { + return func(uuid.UUID) string { + return fmt.Sprintf(`{"revision":1,"payload":{"customer_id":%q,"lines":[%s]}}`, f.custID.String(), payloadLine) + } + } + fixed := func(s string) func(uuid.UUID) string { return func(uuid.UUID) string { return s } } + ifMatch := map[string]string{"If-Match": `"1"`} + reorderBody := func(row uuid.UUID) string { + d := w.delA + if row == w.routeB { + d = w.delB + } + return fmt.Sprintf(`{"ordered_delivery_ids":[%q]}`, d.String()) + } + // The proof of delivery control stores the photo under uploads/ in the + // package directory; the test removes what it made. + if _, err := os.Stat("uploads"); os.IsNotExist(err) { + t.Cleanup(func() { _ = os.RemoveAll("uploads") }) + } + podPhoto, podType := multipartFile("photo", "pin.png", "pin photo") + freightFile, freightType := multipartFile("file", "pin.pdf", "pin freight invoice") + if _, err := f.db.Pool.Exec(context.Background(), `UPDATE customers SET email = 'pin@example.com' WHERE id IN ($1, $2)`, f.custID, f.custB); err != nil { + t.Fatalf("seed customer email: %v", err) + } + + // The cashier transaction the complete and void legs act on: one extra + // open transaction at the pin's branch, so the completion does not spend + // the fixture's. + txnA2 := uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id) + SELECT $1, register_id, cashier_id, 'OPEN', branch_id, till_session_id FROM pos_transactions WHERE id = $2`, txnA2, f.txnA); err != nil { + t.Fatalf("seed the second open transaction: %v", err) + } + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE pos_transactions SET subtotal = 1, total = 1 WHERE id IN ($1, $2, $3)`, f.txnA, f.txnB, txnA2); err != nil { + t.Fatalf("seed the transaction totals: %v", err) + } + vehicle, driver := uuid.New(), uuid.New() + if _, err := f.db.Pool.Exec(context.Background(), `INSERT INTO vehicles (id, name, vehicle_type, license_plate) VALUES ($1, 'pin truck', 'FLATBED', $2)`, + vehicle, "PIN"+vehicle.String()[:6]); err != nil { + t.Fatalf("seed vehicle: %v", err) + } + if _, err := f.db.Pool.Exec(context.Background(), `INSERT INTO drivers (id, name) VALUES ($1, 'pin driver')`, driver); err != nil { + t.Fatalf("seed driver: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_tenders WHERE transaction_id = $1`, txnA2) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, txnA2) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, txnA2) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customer_ship_tos WHERE customer_id IN ($1, $2)`, f.custID, f.custB) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM customers WHERE account_number LIKE 'PINNEW-%'`) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM delivery_routes WHERE vehicle_id = $1`, vehicle) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM vehicles WHERE id = $1`, vehicle) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM drivers WHERE id = $1`, driver) + }) + customerPut := func(row uuid.UUID) string { return f.customerReplace(t, row, nil) } + escalationPut := func(row uuid.UUID) string { + cur := f.readJSON(t, "/api/v1/customers/"+row.String()) + raw, _ := json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": "1.5", "revision": cur["revision"]}) + return string(raw) + } + salespersonPut := func(row uuid.UUID) string { + cur := f.readJSON(t, "/api/v1/customers/"+row.String()) + raw, _ := json.Marshal(map[string]any{"salesperson_id": nil, "revision": cur["revision"]}) + return string(raw) + } + regOf := func(row uuid.UUID) string { + if row == f.txnB { + return f.regB + } + return f.regA + } + posTender := fixed(`{"tenders":[{"method":"CASH","amount":5}]}`) + posReturn := func(row uuid.UUID) string { + return fmt.Sprintf(`{"register_id":%q,"refund_method":"CASH","reason":"pin","lines":[{"product_id":%q,"quantity":1,"unit_price":1}]}`, regOf(row), f.posProd.String()) + } + posSync := func(row uuid.UUID) string { + return fmt.Sprintf(`{"batch_id":"pin-%s","register_id":%q,"items":[{"client_id":%q,"register_id":%q,"cashier_id":%q,`+ + `"items":[{"product_id":%q,"quantity":1,"uom":"EA"}],"tenders":[{"method":"CASH","amount":5}],"client_created_at":%q}]}`, + uuid.NewString()[:8], regOf(row), uuid.NewString(), regOf(row), uuid.NewString(), f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) + } + posOpen := func(row uuid.UUID) string { + return fmt.Sprintf(`{"register_id":%q,"opening_float":10}`, regOf(row)) + } + posStart := func(row uuid.UUID) string { + return fmt.Sprintf(`{"register_id":%q,"cashier_id":%q}`, regOf(row), uuid.NewString()) + } + inBranch := func(prefix string) func(uuid.UUID) string { + return func(branch uuid.UUID) string { + return fmt.Sprintf(prefix, uuid.NewString()[:8], branch.String()) + } + } + + // Every by id route with the foreign twin it drives, and the pin's own + // twin that proves its body valid (the control). + type leg struct { + method, path string + body func(row uuid.UUID) string // the body for a row; nil is an empty body + twin, own uuid.UUID + hdr map[string]string // headers beyond the body (an If-Match revision) + twin2, own2 uuid.UUID // the second id of a route that names two + first bool // sent with the first mint (the pos and AR scopes) + sameAsMissing bool // the module answers a missing row with one error code whatever the cause (pos), so the wall verdict is the same answer and an unchanged state + noControl string // why no A control can prove the body valid here + reach string // why the leg names no foreign row: it proves the call reaches its module and leaves branch B's rows as they were + tolerate500 string // the reason a reach leg may answer 500 for every caller (the module's own wiring is not in this fixture) + landsOn string // the table whose new row must land at the pin's branch + madeTable string // the table of the row a reach leg makes, removed when the test ends + controlUser bool // the control is the admin user (a machine key is refused the route outright) + } // The routes whose user answer for the twin is not a plain 200: a quote - // with no stored file answers its own 404, and the exposure gate answers - // a missing order rather than echoing it. + // with no stored file answers its own 404. noRealUserAnswer := map[string]bool{ "/api/v1/quotes/%s/file": true, - "/api/v1/orders/%s/exposure-gate": true, "/api/v1/matching/results/%s": true, // no match results are seeded, both rows answer 404 "/api/v1/purchase-orders/%s/freight": true, // an empty freight list answers 200 for any order } - idRoutes := []struct { - method, path, body string - twin uuid.UUID - }{ - {"GET", "/api/v1/quotes/%s", "", f.quoteB}, - {"PUT", "/api/v1/quotes/%s", `{}`, f.quoteB}, - {"POST", "/api/v1/quotes/%s/convert", `{}`, f.quoteB}, - {"POST", "/api/v1/quotes/%s/transitions", `{"action":"send"}`, f.quoteB}, - {"GET", "/api/v1/quotes/%s/file", "", f.quoteB}, - {"PUT", "/api/v1/quotes/%s/file", `{}`, f.quoteB}, - {"GET", "/api/v1/orders/%s", "", w.ordB}, - {"PUT", "/api/v1/orders/%s", `{}`, w.ordB}, - {"GET", "/api/v1/orders/%s/exposure-gate", "", w.ordB}, - {"POST", "/api/v1/orders/%s/allocate", `{}`, w.ordB}, - {"POST", "/api/v1/orders/%s/exposure-override", `{"notes":"pin"}`, w.ordB}, - {"POST", "/api/v1/orders/%s/fulfillments", `{}`, w.ordB}, - {"POST", "/api/v1/orders/%s/transitions", `{"action":"confirm"}`, w.ordB}, - {"POST", "/api/v1/orders/fulfillment-requests/%s/retry", "", w.delB}, - {"GET", "/api/v1/invoices/%s", "", w.invB}, - {"GET", "/api/v1/invoices/%s/payments", "", w.invB}, - {"POST", "/api/v1/invoices/%s/email", `{}`, w.invB}, - {"POST", "/api/v1/invoices/%s/transitions", `{"action":"send"}`, w.invB}, - {"POST", "/api/v1/invoices/%s/write-offs", `{"amount_cents":1,"reason":"pin"}`, w.invB}, - {"GET", "/api/v1/documents/print/invoice/%s", "", w.invB}, - {"GET", "/api/v1/documents/print/pickticket/%s", "", w.ordB}, - {"GET", "/api/v1/payments/%s", "", w.payB}, - {"POST", "/api/v1/payments/%s/applications", `{}`, w.payB}, - {"POST", "/api/v1/payments/%s/refunds", `{}`, w.payB}, - {"POST", "/api/v1/payments/%s/transitions", `{}`, w.payB}, - {"POST", "/api/v1/ar/applications/%s/reverse", `{}`, w.appB}, - {"GET", "/api/v1/credit-memos/%s", "", w.memoB}, - {"PUT", "/api/v1/credit-memos/%s", `{}`, w.memoB}, - {"POST", "/api/v1/credit-memos/%s/applications", `{}`, w.memoB}, - {"POST", "/api/v1/credit-memos/%s/refunds", `{}`, w.memoB}, - {"POST", "/api/v1/credit-memos/%s/transitions", `{}`, w.memoB}, - {"GET", "/api/v1/activities/%s", "", w.actB}, - {"PUT", "/api/v1/activities/%s", `{}`, w.actB}, - {"DELETE", "/api/v1/activities/%s", "", w.actB}, - {"GET", "/api/v1/contacts/%s", "", w.conB}, - {"PUT", "/api/v1/contacts/%s", `{}`, w.conB}, - {"DELETE", "/api/v1/contacts/%s", "", w.conB}, - {"GET", "/api/v1/ship-tos/%s", "", w.shipB}, - {"PUT", "/api/v1/ship-tos/%s", `{}`, w.shipB}, - {"GET", "/api/v1/drafts/quotes/%s", "", w.draftQB}, - {"PUT", "/api/v1/drafts/quotes/%s", `{}`, w.draftQB}, - {"POST", "/api/v1/drafts/quotes/%s/promote", `{}`, w.draftQB}, - {"POST", "/api/v1/drafts/quotes/%s/transitions", `{}`, w.draftQB}, - {"GET", "/api/v1/drafts/orders/%s", "", w.draftOB}, - {"PUT", "/api/v1/drafts/orders/%s", `{}`, w.draftOB}, - {"POST", "/api/v1/drafts/orders/%s/promote", `{}`, w.draftOB}, - {"POST", "/api/v1/drafts/orders/%s/transitions", `{}`, w.draftOB}, - {"GET", "/api/v1/links/quotes/%s", "", f.quoteB}, - {"GET", "/api/v1/links/orders/%s", "", w.ordB}, - {"GET", "/api/v1/links/invoices/%s", "", w.invB}, - {"GET", "/api/v1/links/customers/%s", "", f.custB}, - {"GET", "/api/v1/links/drafts/quotes/%s", "", w.draftQB}, - {"GET", "/api/v1/links/drafts/orders/%s", "", w.draftOB}, - {"GET", "/api/v1/delivery/deliveries/%s", "", w.delB}, - {"GET", "/api/v1/delivery/deliveries/%s/pod-photos", "", w.delB}, - {"POST", "/api/v1/delivery/deliveries/%s/adjust-qty", `{}`, w.delB}, - {"POST", "/api/v1/delivery/deliveries/%s/pod-photo", `{}`, w.delB}, - {"POST", "/api/v1/delivery/deliveries/%s/transitions", `{"status":"COMPLETED"}`, w.delB}, - {"GET", "/api/v1/delivery/routes/%s", "", w.routeB}, - {"GET", "/api/v1/delivery/routes/%s/deliveries", "", w.routeB}, - {"POST", "/api/v1/delivery/routes/%s/optimize", "", w.routeB}, - {"POST", "/api/v1/delivery/routes/%s/reorder", "", w.routeB}, - {"POST", "/api/v1/delivery/routes/%s/transitions", `{"status":"PLANNED"}`, w.routeB}, - {"GET", "/api/v1/matching/results/%s", "", w.poB}, - {"POST", "/api/v1/matching/run/%s", "", w.poB}, - {"GET", "/api/v1/purchase-orders/%s", "", w.poB}, - {"GET", "/api/v1/purchase-orders/%s/freight", "", w.poB}, - {"POST", "/api/v1/purchase-orders/%s/freight", `{"carrier":"pin","cost_cents":1}`, w.poB}, - {"POST", "/api/v1/purchase-orders/%s/freight/%s/apply", "", w.freightB}, - {"POST", "/api/v1/purchase-orders/%s/receive", `{}`, w.poB}, - {"POST", "/api/v1/purchase-orders/%s/submit", "", w.poB}, - } - before := f.familyCounts(t) - for _, c := range idRoutes { - var bPath, mPath string - if strings.Count(c.path, "%s") == 2 { - // The freight apply route names the order and the freight quote. - bPath = fmt.Sprintf(c.path, w.poB, w.freightB) - mPath = fmt.Sprintf(c.path, uuid.MustParse(missing), uuid.MustParse(missing)) - } else { - bPath = fmt.Sprintf(c.path, c.twin) - mPath = fmt.Sprintf(c.path, uuid.MustParse(missing)) - } - sb, bb := f.key(t, f.bound, c.method, bPath, c.body, nil) - sm, _ := f.key(t, f.bound, c.method, mPath, c.body, nil) - if sb != sm && sb != http.StatusForbidden && sb != http.StatusNotFound { - t.Errorf("bound key %s %s: %d %s for the foreign row, want the missing row answer (%d), a 403 or a 404", - c.method, bPath, sb, bb, sm) - } - if strings.Contains(string(bb), c.twin.String()) { - t.Errorf("bound key %s %s: the answer names the foreign row: %s", c.method, bPath, bb) - } - // The twin is real: a user reads it where a missing row is a 404 (the - // write legs prove the same rows exist through their family tables). - if c.method == "GET" && !noRealUserAnswer[c.path] { - ub, ubuf := f.user(t, c.method, bPath, c.body, nil) - um, _ := f.user(t, c.method, mPath, c.body, nil) + legs := []leg{ + {method: "GET", path: "/api/v1/quotes/%s", twin: f.quoteB, own: f.quoteA}, + {method: "PUT", path: "/api/v1/quotes/%s", body: quotePut, twin: f.quoteB, own: f.quoteA}, + {method: "GET", path: "/api/v1/quotes/%s/file", twin: f.quoteB, own: f.quoteA}, + {method: "PUT", path: "/api/v1/quotes/%s/file", body: fixed("pin file"), hdr: ifMatch, twin: f.quoteB, own: f.quoteA}, + {method: "POST", path: "/api/v1/quotes/%s/transitions", body: fixed(`{"to":"sent","revision":1}`), twin: f.quoteB, own: f.quoteA}, + {method: "GET", path: "/api/v1/orders/%s", twin: w.ordB, own: w.ordA}, + {method: "PUT", path: "/api/v1/orders/%s", body: orderPut, twin: w.ordB, own: w.ordA}, + {method: "GET", path: "/api/v1/orders/%s/exposure-gate", twin: w.ordB, own: w.ordA}, + {method: "POST", path: "/api/v1/orders/%s/allocate", body: fixed(`{}`), hdr: ifMatch, twin: w.ordB, own: w.ordA}, + {method: "POST", path: "/api/v1/orders/%s/exposure-override", body: fixed(`{"notes":"a pin override note"}`), twin: w.ordB, own: w.ordA}, + {method: "POST", path: "/api/v1/orders/%s/fulfillments", body: fixed(`{}`), hdr: ifMatch, twin: w.ordB, own: w.ordA, + noControl: "the seeded order has no lines to fulfil, so the control answers 500 past the body"}, + {method: "POST", path: "/api/v1/orders/%s/transitions", body: fixed(`{"to":"cancelled","reason":"pin","revision":1}`), twin: w.ordB, own: w.ordA}, + {method: "POST", path: "/api/v1/orders/fulfillment-requests/%s/retry", twin: w.delB, own: w.delA, + noControl: "no fulfilment request row is seeded for the stop"}, + {method: "GET", path: "/api/v1/invoices/%s", twin: w.invB, own: w.invA}, + {method: "GET", path: "/api/v1/invoices/%s/payments", twin: w.invB, own: w.invA}, + {method: "POST", path: "/api/v1/invoices/%s/email", body: fixed(`{"to":"pin@example.com"}`), twin: w.invB, own: w.invA}, + {method: "POST", path: "/api/v1/invoices/%s/transitions", body: fixed(`{"to":"void","reason":"pin","revision":1}`), twin: w.invB, own: w.invA}, + {method: "POST", path: "/api/v1/invoices/%s/write-offs", body: fixed(`{"amount_cents":1,"reason":"pin","revision":1}`), twin: w.invB, own: w.invA}, + {method: "GET", path: "/api/v1/documents/print/invoice/%s", twin: w.invB, own: w.invA}, + {method: "GET", path: "/api/v1/documents/print/pickticket/%s", twin: w.ordB, own: w.ordA}, + {method: "GET", path: "/api/v1/payments/%s", twin: w.payB, own: w.payA}, + {method: "POST", path: "/api/v1/payments/%s/applications", twin: w.payB, own: w.payA, + body: func(uuid.UUID) string { + return fmt.Sprintf(`{"revision":1,"applications":[{"invoice_id":%q,"amount_cents":1}]}`, w.invA.String()) + }}, + {method: "POST", path: "/api/v1/payments/%s/refunds", body: fixed(`{"amount_cents":1,"reason":"pin","revision":1}`), twin: w.payB, own: w.payA}, + {method: "POST", path: "/api/v1/payments/%s/transitions", body: fixed(`{"to":"voided","reason":"pin","revision":1}`), twin: w.payB, own: w.payA}, + {method: "POST", path: "/api/v1/ar/applications/%s/reverse", body: fixed(`{"reason":"pin"}`), twin: w.appB, own: w.appA, + noControl: "the seeded application posted no GL entry, so the reversal fails 500 after the body validated"}, + {method: "GET", path: "/api/v1/credit-memos/%s", twin: w.memoB, own: w.memoA}, + {method: "PUT", path: "/api/v1/credit-memos/%s", body: creditPut, twin: w.memoB, own: w.memoA}, + {method: "POST", path: "/api/v1/credit-memos/%s/applications", twin: w.memoB, own: w.memoA, + body: func(uuid.UUID) string { + return fmt.Sprintf(`{"revision":1,"applications":[{"invoice_id":%q,"amount_cents":1}]}`, w.invA.String()) + }}, + {method: "POST", path: "/api/v1/credit-memos/%s/refunds", body: fixed(`{"amount_cents":1,"method":"cash","reason":"pin","revision":1}`), twin: w.memoB, own: w.memoA}, + {method: "POST", path: "/api/v1/credit-memos/%s/transitions", body: fixed(`{"to":"void","reason":"pin","revision":1}`), twin: w.memoB, own: w.memoA}, + {method: "GET", path: "/api/v1/activities/%s", twin: w.actB, own: w.actA}, + {method: "PUT", path: "/api/v1/activities/%s", body: activityPut, twin: w.actB, own: w.actA}, + {method: "DELETE", path: "/api/v1/activities/%s", hdr: ifMatch, twin: w.actB, own: w.actA}, + {method: "GET", path: "/api/v1/contacts/%s", twin: w.conB, own: w.conA}, + {method: "PUT", path: "/api/v1/contacts/%s", body: contactPut, twin: w.conB, own: w.conA}, + {method: "DELETE", path: "/api/v1/contacts/%s", hdr: ifMatch, twin: w.conB, own: w.conA}, + {method: "GET", path: "/api/v1/ship-tos/%s", twin: w.shipB, own: w.shipA}, + {method: "PUT", path: "/api/v1/ship-tos/%s", body: shipToPut, twin: w.shipB, own: w.shipA}, + {method: "GET", path: "/api/v1/drafts/quotes/%s", twin: w.draftQB, own: w.draftQA}, + {method: "PUT", path: "/api/v1/drafts/quotes/%s", body: draftPut(qline), twin: w.draftQB, own: w.draftQA}, + {method: "POST", path: "/api/v1/drafts/quotes/%s/promote", body: fixed(`{"revision":1}`), twin: w.draftQB, own: w.draftQA}, + {method: "POST", path: "/api/v1/drafts/quotes/%s/transitions", body: fixed(`{"to":"discarded","revision":1}`), twin: w.draftQB, own: w.draftQA}, + {method: "GET", path: "/api/v1/drafts/orders/%s", twin: w.draftOB, own: w.draftOA}, + {method: "PUT", path: "/api/v1/drafts/orders/%s", body: draftPut(line), twin: w.draftOB, own: w.draftOA}, + {method: "POST", path: "/api/v1/drafts/orders/%s/promote", body: fixed(`{"revision":1}`), twin: w.draftOB, own: w.draftOA}, + {method: "POST", path: "/api/v1/drafts/orders/%s/transitions", body: fixed(`{"to":"discarded","revision":1}`), twin: w.draftOB, own: w.draftOA}, + {method: "GET", path: "/api/v1/links/quotes/%s", twin: f.quoteB, own: f.quoteA}, + {method: "GET", path: "/api/v1/links/orders/%s", twin: w.ordB, own: w.ordA}, + {method: "GET", path: "/api/v1/links/invoices/%s", twin: w.invB, own: w.invA}, + {method: "GET", path: "/api/v1/links/customers/%s", twin: f.custB, own: f.custID}, + {method: "GET", path: "/api/v1/links/drafts/quotes/%s", twin: w.draftQB, own: w.draftQA}, + {method: "GET", path: "/api/v1/links/drafts/orders/%s", twin: w.draftOB, own: w.draftOA}, + {method: "GET", path: "/api/v1/delivery/deliveries/%s", twin: w.delB, own: w.delA}, + {method: "GET", path: "/api/v1/delivery/deliveries/%s/pod-photos", twin: w.delB, own: w.delA}, + {method: "POST", path: "/api/v1/delivery/deliveries/%s/adjust-qty", twin: w.delB, own: w.delA, + noControl: "an adjustment needs a delivered order line to adjust; the seeded stop has none, so the control answers its own 400", + body: fixed(fmt.Sprintf(`{"adjusted_by":%q,"adjustments":[{"product_id":%q,"original_qty":"1","adjusted_qty":"1","reason_code":"damaged"}]}`, uuid.NewString(), f.posProd.String()))}, + {method: "POST", path: "/api/v1/delivery/deliveries/%s/pod-photo", twin: w.delB, own: w.delA, body: fixed(podPhoto), hdr: map[string]string{"Content-Type": podType}}, + {method: "POST", path: "/api/v1/delivery/deliveries/%s/transitions", body: fixed(`{"to":"out_for_delivery","revision":1}`), twin: w.delB, own: w.delA}, + {method: "GET", path: "/api/v1/delivery/routes/%s", twin: w.routeB, own: w.routeA}, + {method: "GET", path: "/api/v1/delivery/routes/%s/deliveries", twin: w.routeB, own: w.routeA}, + {method: "POST", path: "/api/v1/delivery/routes/%s/optimize", hdr: ifMatch, twin: w.routeB, own: w.routeA}, + {method: "POST", path: "/api/v1/delivery/routes/%s/reorder", hdr: ifMatch, body: reorderBody, twin: w.routeB, own: w.routeA}, + {method: "POST", path: "/api/v1/delivery/routes/%s/transitions", body: fixed(`{"to":"scheduled","revision":1}`), twin: w.routeB, own: w.routeA}, + {method: "GET", path: "/api/v1/matching/results/%s", twin: w.poB, own: w.poA}, + {method: "POST", path: "/api/v1/matching/run/%s", twin: w.poB, own: w.poA, + noControl: "the matcher's purchase order source is the fixture stub, which answers 500 for the pin's own purchase order"}, + {method: "GET", path: "/api/v1/purchase-orders/%s", twin: w.poB, own: w.poA}, + {method: "GET", path: "/api/v1/purchase-orders/%s/freight", twin: w.poB, own: w.poA}, + {method: "POST", path: "/api/v1/purchase-orders/%s/freight", twin: w.poB, own: w.poA, body: fixed(freightFile), hdr: map[string]string{"Content-Type": freightType}, + noControl: "the upload extracts the invoice with the AI provider, which this harness does not configure, so the control cannot finish"}, + {method: "POST", path: "/api/v1/purchase-orders/%s/freight/%s/apply", twin: w.poB, own: w.poA, twin2: w.freightB, own2: w.freightA, + noControl: "the apply answers 400 for a freight charge with no extracted lines (the module maps every apply error to 400)"}, + {method: "POST", path: "/api/v1/purchase-orders/%s/receive", twin: w.poB, own: w.poA, + body: func(row uuid.UUID) string { + line, yard := w.poLineA, f.yardA + if row == w.poB { + line, yard = w.poLineB, f.yardB + } + return fmt.Sprintf(`{"lines":[{"line_id":%q,"qty_received":1,"location_id":%q}]}`, line.String(), yard.String()) + }}, + {method: "POST", path: "/api/v1/purchase-orders/%s/submit", twin: w.poB, own: w.poA}, + {method: "POST", path: "/api/v1/quotes/%s/convert", body: fixed(`{}`), hdr: ifMatch, twin: f.quoteB, own: f.quoteA}, + // The customer record, its children and its policy. + {method: "GET", path: "/api/v1/customers/%s", twin: f.custB, own: f.custID}, + {method: "PUT", path: "/api/v1/customers/%s", body: customerPut, twin: f.custB, own: f.custID}, + {method: "GET", path: "/api/v1/customers/%s/escalation-policy", twin: f.custB, own: f.custID}, + {method: "PUT", path: "/api/v1/customers/%s/escalation-policy", body: escalationPut, twin: f.custB, own: f.custID}, + {method: "PATCH", path: "/api/v1/customers/%s/salesperson", body: salespersonPut, twin: f.custB, own: f.custID}, + {method: "POST", path: "/api/v1/customers/%s/activities", body: fixed(`{"activity_type":"call","description":"pin new"}`), twin: f.custB, own: f.custID}, + {method: "POST", path: "/api/v1/customers/%s/contacts", body: fixed(`{"first_name":"pin","last_name":"new"}`), twin: f.custB, own: f.custID}, + {method: "POST", path: "/api/v1/customers/%s/ship-tos", body: fixed(`{"code":"pinnew","name":"pin new","line1":"1 pin"}`), twin: f.custB, own: f.custID}, + // A completion and a void act on a transaction the module reads under + // the wall (it answers a missing one 500). + {method: "POST", path: "/api/v1/pos/transactions/%s/complete", body: posTender, twin: f.txnB, own: f.txnA, first: true, sameAsMissing: true, + noControl: "the completion deducts stock from a location this fixture does not stock, so the control answers 422 past the body and the transaction read"}, + {method: "POST", path: "/api/v1/pos/transactions/%s/void", twin: f.txnB, own: txnA2, first: true, sameAsMissing: true}, + {method: "POST", path: "/api/v1/pos/sync", body: posSync, twin: f.txnB, own: f.txnA, first: true, + reach: "the sync replays a batch for the register its body names; the leg proves the call leaves branch B's transactions as they were"}, + // The creates that take the branch they land on from the body. + {method: "POST", path: "/api/v1/customers", twin: f.branchB, own: f.branchA, + body: inBranch(`{"account_number":"PINNEW-%s","name":"pin new","primary_branch_id":%q}`)}, + {method: "POST", path: "/api/v1/locations", twin: f.branchB, own: f.branchA, + body: inBranch(`{"type":"yard","code":"pin-new-%s","name":"pin new yard","parent_id":%q}`)}, + {method: "POST", path: "/api/v1/delivery/deliveries", twin: w.ordB, own: w.ordA, + body: func(order uuid.UUID) string { + return fmt.Sprintf(`{"route_id":%q,"order_id":%q}`, w.routeA.String(), order.String()) + }}, + // The creates that take the branch from the caller name no foreign + // row to refuse: the leg is the call and the unchanged twin state. + {method: "POST", path: "/api/v1/delivery/routes", twin: uuid.Nil, own: uuid.Nil, madeTable: "delivery_routes", + body: fixed(fmt.Sprintf(`{"vehicle_id":%q,"driver_id":%q,"scheduled_date":%q}`, vehicle.String(), driver.String(), time.Now().UTC().Format("2006-01-02"))), + reach: "a route carries no branch fact until a stop is assigned, so there is no foreign row to name"}, + {method: "POST", path: "/api/v1/purchase-orders", twin: uuid.Nil, own: uuid.Nil, landsOn: "purchase_orders", + body: fixed(fmt.Sprintf(`{"vendor_id":%q,"lines":[{"product_id":%q,"description":"pin","quantity":1,"cost":1}]}`, w.vendor.String(), f.posProd.String())), + reach: "the purchase order takes its branch from the caller; the leg proves it lands at the pin's branch"}, + {method: "POST", path: "/api/v1/purchase-orders/reorder-check", twin: uuid.Nil, own: uuid.Nil, + reach: "the reorder check reads the caller's branch stock; the leg proves it leaves branch B's purchase orders as they were", + tolerate500: "the reorder pass needs the vendor catalogue wiring this fixture does not build, so it answers 500 to every caller"}, + // The cashier routes refuse every machine key; the user control proves + // the body valid. + {method: "POST", path: "/api/v1/pos/returns", twin: f.txnB, own: f.txnA, body: posReturn, first: true, controlUser: true}, + {method: "POST", path: "/api/v1/pos/till/open", twin: f.txnB, own: f.txnA, body: posOpen, first: true, controlUser: true}, + {method: "POST", path: "/api/v1/pos/transactions", twin: f.txnB, own: f.txnA, body: posStart, first: true, controlUser: true}, + } + pathFor := func(l leg, row, row2 uuid.UUID) string { + if !strings.Contains(l.path, "%s") { + return l.path + } + if strings.Count(l.path, "%s") == 2 { + return fmt.Sprintf(l.path, row, row2) + } + return fmt.Sprintf(l.path, row) + } + bodyFor := func(l leg, row uuid.UUID) string { + if l.body == nil { + return "" + } + return l.body(row) + } + raw := func(l leg) string { + if l.first { + return firstBound + } + return f.bound + } + // Control statuses that prove a body valid and a row reached: anything + // the module answers past its validation and its wall. + controlOK := func(status int) bool { + switch status { + case 200, 201, 202, 204, 409, 402, 503: + return true + } + return false + } + + before := f.twinState(t, w) + for _, l := range legs { + if l.reach != "" { + // There is no foreign row to name: the call is made once, must + // reach its module past the key's scopes, and (for a create that + // takes its branch from the caller) must land at the pin's branch. + status, buf := f.key(t, raw(l), l.method, pathFor(l, l.twin, l.twin2), bodyFor(l, l.twin), l.hdr) + if !controlOK(status) && !(l.tolerate500 != "" && status == http.StatusInternalServerError) { + t.Errorf("bound key %s %s: %d %s, want the call to reach its module", l.method, l.path, status, buf) + } + var made struct { + ID uuid.UUID `json:"id"` + } + if status == http.StatusCreated && json.Unmarshal(buf, &made) == nil && made.ID != uuid.Nil && l.madeTable != "" { + f.madeRow(t, l.madeTable, made.ID) + } + if l.landsOn != "" && status == http.StatusCreated { + doc := made + var branch uuid.UUID + if err := json.Unmarshal(buf, &doc); err != nil { + t.Errorf("bound key %s %s: the answer holds no id: %s", l.method, l.path, buf) + } else if err := f.db.Pool.QueryRow(context.Background(), + `SELECT branch_id FROM `+l.landsOn+` WHERE id = $1`, doc.ID).Scan(&branch); err != nil || branch != f.branchA { + t.Errorf("bound key %s %s landed on branch %v (%v), want the pin's %v", l.method, l.path, branch, err, f.branchA) + } + } + continue + } + bPath := pathFor(l, l.twin, l.twin2) + mPath := pathFor(l, uuid.MustParse(missing), uuid.MustParse(missing)) + sb, bb := f.key(t, raw(l), l.method, bPath, bodyFor(l, l.twin), l.hdr) + sm, _ := f.key(t, raw(l), l.method, mPath, bodyFor(l, l.twin), l.hdr) + scopeRefusal := sb == http.StatusForbidden && strings.Contains(string(bb), "lacks required scope") + held := !scopeRefusal && (sb == http.StatusForbidden || sb == http.StatusNotFound || + (sb == sm && (l.method == "GET" || (l.sameAsMissing && sm >= 400)))) + if !held { + t.Errorf("bound key %s %s: %d %s for the foreign row, want a 403 or a 404 (the missing row answers %d)", l.method, bPath, sb, bb, sm) + } + if strings.Contains(string(bb), l.twin.String()) { + t.Errorf("bound key %s %s: the answer names the foreign row: %s", l.method, bPath, bb) + } + // The twin is real: a user reads it where a missing row is a 404. + if l.method == "GET" && !noRealUserAnswer[l.path] { + ub, ubuf := f.user(t, l.method, bPath, "", nil) + um, _ := f.user(t, l.method, mPath, "", nil) if ub != http.StatusOK || um != http.StatusNotFound { - t.Errorf("user %s %s: %d for the twin and %d for a missing row, want 200 and 404 (%s)", - c.method, bPath, ub, um, ubuf) + t.Errorf("user %s %s: %d for the twin and %d for a missing row, want 200 and 404 (%s)", l.method, bPath, ub, um, ubuf) } } } - after := f.familyCounts(t) - for tb, n := range before { - if after[tb] != n { - t.Errorf("table %s: %d rows before the sweep, %d after, want no writes", tb, n, after[tb]) + if changed := changedTables(before, f.twinState(t, w)); len(changed) > 0 { + t.Errorf("the foreign legs changed rows that name branch B's twins: %v", changed) + } + // The controls: the same body against the pin's own twin must pass the + // module's validation and reach the row, or the foreign leg above never + // reached the wall it claims to test. Writes only; a read has no body. + for _, l := range legs { + if l.method == "GET" || l.reach != "" { + continue + } + var sa int + var ba []byte + if l.controlUser { + sa, ba = f.user(t, l.method, pathFor(l, l.own, l.own2), bodyFor(l, l.own), l.hdr) + } else { + sa, ba = f.key(t, raw(l), l.method, pathFor(l, l.own, l.own2), bodyFor(l, l.own), l.hdr) + } + if l.noControl == "" && !controlOK(sa) { + t.Errorf("control: bound key %s %s on the pin's own twin: %d %s, want the body valid and the row reached", l.method, pathFor(l, l.own, l.own2), sa, ba) } } @@ -357,36 +620,46 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { lists := []struct { path string absent, present []string + first bool // sent with the first mint (the pos scopes) }{ - {"/api/v1/customers", []string{f.custB.String()}, []string{f.custID.String()}}, - {"/api/v1/credit-memos", []string{w.memoB.String()}, []string{w.memoA.String()}}, - {"/api/v1/invoices", []string{w.invB.String()}, []string{w.invA.String()}}, - {"/api/v1/payments", []string{w.payB.String()}, []string{w.payA.String()}}, - {"/api/v1/quotes", []string{f.quoteB.String()}, []string{f.quoteA.String()}}, - {"/api/v1/orders", []string{w.ordB.String()}, []string{w.ordA.String()}}, - {"/api/v1/orders/fulfillment-requests", []string{w.ordB.String()}, nil}, - {"/api/v1/drafts/quotes", []string{w.draftQB.String()}, []string{w.draftQA.String()}}, - {"/api/v1/drafts/orders", []string{w.draftOB.String()}, []string{w.draftOA.String()}}, - {"/api/v1/purchase-orders", []string{w.poB.String()}, []string{w.poA.String()}}, - {"/api/v1/purchase-orders/recommendations", []string{}, nil}, - {"/api/v1/purchase-orders/reorder-runs", []string{}, nil}, - {"/api/v1/purchase-orders/source-summary", []string{}, nil}, - {"/api/v1/delivery/routes", []string{w.routeB.String()}, []string{w.routeA.String()}}, - {"/api/v1/matching/exceptions", []string{w.poB.String()}, nil}, - {"/api/v1/customers/" + f.custID.String() + "/activities", []string{w.actB.String()}, []string{w.actA.String()}}, - {"/api/v1/customers/" + f.custID.String() + "/contacts", []string{w.conB.String()}, []string{w.conA.String()}}, - {"/api/v1/customers/" + f.custID.String() + "/ship-tos", []string{w.shipB.String()}, []string{w.shipA.String()}}, - {"/api/v1/dashboard/summary", []string{f.custB.String()}, nil}, - {"/api/v1/dashboard/top-customers", []string{f.custB.String()}, nil}, - {"/api/v1/dashboard/order-activity", []string{w.ordB.String()}, nil}, - {"/api/v1/dashboard/revenue-trend", []string{w.invB.String()}, nil}, - {"/api/v1/dashboard/inventory-alerts", []string{f.yardB.String()}, nil}, - {"/api/v1/inventory", []string{f.yardB.String()}, nil}, - {"/api/v1/locations", []string{f.yardB.String()}, nil}, - {"/api/v1/products/reorder-alerts", []string{f.yardB.String()}, nil}, + {path: "/api/v1/customers", absent: []string{f.custB.String()}, present: []string{f.custID.String()}}, + {path: "/api/v1/credit-memos", absent: []string{w.memoB.String()}, present: []string{w.memoA.String()}}, + {path: "/api/v1/invoices", absent: []string{w.invB.String()}, present: []string{w.invA.String()}}, + {path: "/api/v1/payments", absent: []string{w.payB.String()}, present: []string{w.payA.String()}}, + {path: "/api/v1/quotes", absent: []string{f.quoteB.String()}, present: []string{f.quoteA.String()}}, + {path: "/api/v1/orders", absent: []string{w.ordB.String()}, present: []string{w.ordA.String()}}, + {path: "/api/v1/orders/fulfillment-requests", absent: []string{w.ordB.String()}, present: nil}, + {path: "/api/v1/drafts/quotes", absent: []string{w.draftQB.String()}, present: []string{w.draftQA.String()}}, + {path: "/api/v1/drafts/orders", absent: []string{w.draftOB.String()}, present: []string{w.draftOA.String()}}, + {path: "/api/v1/purchase-orders", absent: []string{w.poB.String()}, present: []string{w.poA.String()}}, + {path: "/api/v1/purchase-orders/recommendations", absent: []string{}, present: nil}, + {path: "/api/v1/purchase-orders/reorder-runs", absent: []string{}, present: nil}, + {path: "/api/v1/purchase-orders/source-summary", absent: []string{}, present: nil}, + {path: "/api/v1/delivery/routes", absent: []string{w.routeB.String()}, present: []string{w.routeA.String()}}, + {path: "/api/v1/matching/exceptions", absent: []string{w.poB.String()}, present: nil}, + {path: "/api/v1/customers/" + f.custID.String() + "/activities", absent: []string{w.actB.String()}, present: []string{w.actA.String()}}, + {path: "/api/v1/customers/" + f.custID.String() + "/contacts", absent: []string{w.conB.String()}, present: []string{w.conA.String()}}, + {path: "/api/v1/customers/" + f.custID.String() + "/ship-tos", absent: []string{w.shipB.String()}, present: []string{w.shipA.String()}}, + {path: "/api/v1/dashboard/summary", absent: []string{f.custB.String()}, present: nil}, + {path: "/api/v1/dashboard/top-customers", absent: []string{f.custB.String()}, present: nil}, + {path: "/api/v1/dashboard/order-activity", absent: []string{w.ordB.String()}, present: nil}, + {path: "/api/v1/dashboard/revenue-trend", absent: []string{w.invB.String()}, present: nil}, + {path: "/api/v1/dashboard/inventory-alerts", absent: []string{f.yardB.String()}, present: nil}, + {path: "/api/v1/inventory", absent: []string{f.yardB.String()}, present: nil}, + {path: "/api/v1/locations", absent: []string{f.yardB.String()}, present: nil}, + {path: "/api/v1/products/reorder-alerts", absent: []string{f.yardB.String()}, present: nil}, + {path: "/api/v1/quotes/analytics", absent: []string{f.quoteB.String()}, present: nil}, + {path: "/api/v1/ar/aging", absent: []string{f.custB.String(), w.invB.String()}, present: nil}, + {path: "/api/v1/ar/aging/summary", absent: []string{f.custB.String(), w.invB.String()}, present: nil}, + {path: "/api/v1/pos/transactions?register_id=" + f.regB, absent: []string{f.txnB.String()}, present: nil, first: true}, + {path: "/api/v1/pos/transactions?register_id=" + f.regA, absent: []string{f.txnB.String()}, present: []string{f.txnA.String()}, first: true}, } for _, l := range lists { - sb, bb := f.key(t, f.bound, "GET", l.path, "", nil) + key := f.bound + if l.first { + key = firstBound + } + sb, bb := f.key(t, key, "GET", l.path, "", nil) if sb != http.StatusOK { t.Errorf("bound key GET %s: %d %s, want 200", l.path, sb, bb) continue @@ -401,8 +674,37 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { t.Errorf("bound key GET %s: the list does not carry the pin's own row %s", l.path, id) } } - if sk, _ := f.key(t, f.unbound, "GET", l.path, "", nil); sk != http.StatusOK { - t.Errorf("unbound key GET %s: %d, want 200", l.path, sk) + if !l.first { + if sk, _ := f.key(t, f.unbound, "GET", l.path, "", nil); sk != http.StatusOK { + t.Errorf("unbound key GET %s: %d, want 200", l.path, sk) + } + } + } + + // The draft change feeds are streams: from the first position, a key + // pinned to A sees the draft it made at A and never the one a user made at + // B (the feed applies the branch wall to each row). + cursor0, err := httpx.MintCursor(drafts.FeedCursorScope, "0") + if err != nil { + t.Fatal(err) + } + for _, kind := range []struct{ module, payload string }{ + {"quotes", fmt.Sprintf(`{"payload":{"customer_id":%%q,"lines":[%s]}}`, qline)}, + {"orders", fmt.Sprintf(`{"payload":{"customer_id":%%q,"delivery_type":"pickup","lines":[%s]}}`, line)}, + } { + foreign := f.madeWith(t, "user", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.payload, f.custB.String()), + map[string]string{"X-Branch-Id": B}, http.StatusCreated) + own := f.madeWith(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.payload, f.custID.String()), nil, http.StatusCreated) + status, body := f.stream(t, f.bound, "/api/v1/drafts/"+kind.module+"/feed?cursor="+cursor0, 1500*time.Millisecond) + if status != http.StatusOK { + t.Errorf("bound key GET /drafts/%s/feed: %d %s, want the stream open", kind.module, status, body) + continue + } + if foreign != uuid.Nil && strings.Contains(body, foreign.String()) { + t.Errorf("bound key GET /drafts/%s/feed carries the draft made at branch B", kind.module) + } + if own != uuid.Nil && !strings.Contains(body, own.String()) { + t.Errorf("bound key GET /drafts/%s/feed does not carry the draft made at the pin's branch: %.300s", kind.module, body) } } @@ -421,7 +723,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { // The inventory adjust and transfer bodies name locations: the foreign // yard is refused (the branch guard's own refusal) with nothing written. - beforeInv := f.familyCounts(t)["inventory"] + beforeInv := f.twinState(t, w) if sb, bb := f.key(t, f.bound, "POST", "/api/v1/inventory/adjust", fmt.Sprintf(`{"product_id":%q,"location_id":%q,"quantity":5,"reason":"pin"}`, f.posProd.String(), f.yardB.String()), nil); sb != http.StatusForbidden { t.Errorf("bound key inventory adjust naming the foreign yard: %d %s, want 403", sb, bb) @@ -431,7 +733,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { f.posProd.String(), f.yardA.String(), f.yardB.String()), nil); sb != http.StatusForbidden { t.Errorf("bound key inventory transfer into the foreign yard: %d %s, want 403", sb, bb) } - if n := f.familyCounts(t)["inventory"]; n != beforeInv { - t.Errorf("refused inventory writes moved rows: %d then %d", beforeInv, n) + if changed := changedTables(beforeInv, f.twinState(t, w)); len(changed) > 0 { + t.Errorf("refused inventory writes changed rows that name branch B's twins: %v", changed) } } diff --git a/core/internal/app/serve/wire_key_branch_zcoverage_test.go b/core/internal/app/serve/wire_key_branch_zcoverage_test.go new file mode 100644 index 00000000..14174459 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_zcoverage_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The coverage check sorts last by file name, so it runs after the tests whose +// legs it counts (Go runs a package's tests in file order). + +import ( + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/pkg/middleware" +) + +// TestKeyBranchPin_ZWalledRoutesAllHaveALeg fails for every WALLED entry of the +// allowlist that no driving test sent a bound key call to. +func TestKeyBranchPin_ZWalledRoutesAllHaveALeg(t *testing.T) { + walledLegs.Lock() + defer walledLegs.Unlock() + for _, d := range walledLegDrivers { + if !walledLegs.drivers[d] { + t.Skipf("driver %q did not run to its end in this invocation (a -run filter or an earlier failure)", d) + } + } + noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + mux := http.NewServeMux() + var walled []string + for _, route := range middleware.KeyBranchAllowlistRoutes() { + method, pattern, _ := strings.Cut(route, " ") + if class, ok := middleware.KeyBranchRouteClassFor(method, pattern); ok && class == middleware.KeyBranchClassWalled { + mux.Handle(route, noop) + walled = append(walled, route) + } + } + covered := map[string]bool{} + for call := range walledLegs.seen { + method, path, _ := strings.Cut(call, " ") + _, pattern := mux.Handler(httptest.NewRequest(method, path, nil)) + if pattern != "" { + covered[pattern] = true + } + } + var missing []string + for _, route := range walled { + if !covered[route] { + missing = append(missing, route) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("%d of %d WALLED routes have no bound key twin leg:\n%s", len(missing), len(walled), strings.Join(missing, "\n")) + } +} diff --git a/core/internal/order/service.go b/core/internal/order/service.go index 3e02b66b..35ed3ee0 100644 --- a/core/internal/order/service.go +++ b/core/internal/order/service.go @@ -1547,6 +1547,13 @@ func (s *Service) writeEvent(ctx context.Context, ev *outbox.Event) error { // CheckExposureGate reports whether the order is currently blocked by the // pre-ship gate. func (s *Service) CheckExposureGate(ctx context.Context, id uuid.UUID) error { + // The exposure lookups read the order's source quote by id alone, so the + // order is read under the caller's branch wall first: a key bound to one + // branch finds another branch's order a 404 and learns nothing about its + // quote. + if _, err := s.GetOrder(ctx, id); err != nil { + return err + } if s.exposure == nil { return nil } @@ -1555,6 +1562,12 @@ func (s *Service) CheckExposureGate(ctx context.Context, id uuid.UUID) error { // OverrideExposure records an explicit owner override of the pre-ship gate. func (s *Service) OverrideExposure(ctx context.Context, id uuid.UUID, notes, actor, role string) error { + // The override clears the exposure of the order's source quote, found by + // the order id alone: read the order under the caller's branch wall first, + // so a key bound to one branch cannot clear another branch's quote. + if _, err := s.GetOrder(ctx, id); err != nil { + return err + } if s.overrider == nil { return &httpx.Error{Status: http.StatusBadRequest, Code: httpx.CodeValidationFailed, Message: "exposure override not available"} diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index e92b4d94..cb212f63 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -320,7 +320,8 @@ func (w *KeyBranchWall) CustomerBranchExclusive(customersOf func(ctx context.Con // customerHeldOutsidePin answers one branch that holds any of the customers // besides the pin, and whether the pin holds every one of them at all. A // customer the pin does not hold (no row, or only other branches) sets held -// false; the message names the other branch only when one exists. +// false; the message names the other branch only when one exists, the lowest +// numbered one that is not the pin, so the answer is the same every time. func (w *KeyBranchWall) customerHeldOutsidePin(ctx context.Context, customers []uuid.UUID, pin uuid.UUID) (uuid.UUID, bool, error) { if w.db == nil { return uuid.Nil, false, errors.New("key branch wall: no database for the customer branch lookup") @@ -344,7 +345,8 @@ func (w *KeyBranchWall) customerHeldOutsidePin(ctx context.Context, customers [] // its branches when one exists. var other *uuid.UUID err := w.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) LIMIT 1`, ids).Scan(&other) + `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) AND branch_id <> $2 ORDER BY branch_id LIMIT 1`, + ids, pin).Scan(&other) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) } @@ -355,7 +357,7 @@ func (w *KeyBranchWall) customerHeldOutsidePin(ctx context.Context, customers [] } var outside *uuid.UUID err := w.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) AND branch_id <> $2 LIMIT 1`, ids, pin).Scan(&outside) + `SELECT branch_id FROM customer_branches WHERE customer_id = ANY($1) AND branch_id <> $2 ORDER BY branch_id LIMIT 1`, ids, pin).Scan(&outside) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, false, fmt.Errorf("key branch wall: customer branch lookup: %w", err) } From e8ca16d311576b0961deb615ed0c0a58ea4e4bc4 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 17:27:34 -0700 Subject: [PATCH 36/74] test: close the round 7 test strength gaps and audit the AI upload first Round 7 P2-2: a migration 107 test over a scratch database holds the check violation, the NOTICE naming the rows, the unrewritten rows, the NOT VALID branch with planted rows and the validated branch without them. P2-3: the shared customer writes test now drives DELETE and POST on the category bulk routes and the audit read of a shared customer's rule. P2-4: the AI spend and grant assertions measure a delta by action name and by the key's own id; serve.go's own WithAuditLog calls for the location, parsing and vision handlers are pinned in the source level wiring test. P3: the dealer wide AP leg drives /transitions (the renamed route); the parsing upload is audited before the extractor runs, so a failure after the AI call still records the spend; the AP refusal notes call the dealer wide books a policy choice. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_confinement_source_test.go | 3 + .../app/serve/wire_key_branch_allow_test.go | 23 +- .../app/serve/wire_key_branch_pin_test.go | 2 +- core/internal/parsing/handler.go | 8 +- core/internal/parsing/handler_audit_test.go | 67 ++++++ core/internal/pricing/migration_107_test.go | 218 ++++++++++++++++++ core/pkg/middleware/keybranch_allow.go | 14 +- 7 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 core/internal/parsing/handler_audit_test.go create mode 100644 core/internal/pricing/migration_107_test.go diff --git a/core/internal/app/serve/wire_confinement_source_test.go b/core/internal/app/serve/wire_confinement_source_test.go index 8594dbb5..0fe35e92 100644 --- a/core/internal/app/serve/wire_confinement_source_test.go +++ b/core/internal/app/serve/wire_confinement_source_test.go @@ -38,6 +38,9 @@ func TestServeWiringMountsTheConfinements(t *testing.T) { {string(serveSrc), "pricingRulesCustomerWall(wall.keyWall", "the pricing rule create and calculate confinement"}, {string(serveSrc), "categoryRulesCustomerWall(wall.keyWall", "the category rule confinement"}, {string(chainSrc), "middleware.KeyBranchRouteGate(d.Mux", "the route allowlist gate in the chain"}, + {string(serveSrc), "\t\tmiddleware.RequireRole(\"admin\", \"owner\"),\n\t).WithAuditLog(auditLog)", "the location handler's audit log (the PD-5 grant, revoke and home branch rows)"}, + {string(serveSrc), "parsing.NewHandler(parsingSvc).WithAuditLog(auditLog)", "the parsing handler's audit log (the PD-6 upload row)"}, + {string(serveSrc), "vision.NewHandler(visionSvc).WithAuditLog(auditLog)", "the vision handler's audit log (the PD-6 scan row)"}, } { if !strings.Contains(pin.file, pin.call) { t.Errorf("serve's wiring no longer calls %s (%s); the confinement it mounts is gone from production", pin.call, pin.what) diff --git a/core/internal/app/serve/wire_key_branch_allow_test.go b/core/internal/app/serve/wire_key_branch_allow_test.go index 746903bb..5d9fd59a 100644 --- a/core/internal/app/serve/wire_key_branch_allow_test.go +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -669,6 +669,11 @@ func TestKeyBranchPin_SharedCustomerWrites(t *testing.T) { refusedNaming("PUT", "/api/v1/pricing/category-rules/"+catShared.String(), `{"rule_type":"markdown","value_pct":"2"}`) refusedNaming("DELETE", "/api/v1/pricing/category-rules/"+catShared.String(), "") + refusedNaming("DELETE", "/api/v1/pricing/category-rules/bulk", fmt.Sprintf(`{"ids":[%q]}`, catShared.String())) + refusedNaming("POST", "/api/v1/pricing/category-rules/bulk", "["+categoryRuleBody(shared, f.freshCategory(t))+"]") + // The read of the shared customer's rule audit passes (PD-4 allows shared + // reads): only the writes above are held to the pin alone. + f.allow(t, "bound", "GET", "/api/v1/pricing/category-rules/"+catShared.String()+"/audit", "", nil, http.StatusOK) r, c, e := counts() if r != before.rules || c != before.catRules || e != before.exemptions { t.Errorf("refused shared customer writes changed rows: rules %d->%d, category %d->%d, exemptions %d->%d", @@ -699,7 +704,8 @@ func TestKeyBranchPin_GrantWritesAreAudited(t *testing.T) { A := f.branchA.String() grantRows := func(action string) (n int) { if err := f.db.Pool.QueryRow(context.Background(), - `SELECT COUNT(*) FROM audit_log WHERE action = $1 AND actor_kind = 'key'`, action).Scan(&n); err != nil { + `SELECT COUNT(*) FROM audit_log WHERE action = $1 AND actor_kind = 'key' AND actor_id = $2`, + action, f.boundKeyID).Scan(&n); err != nil { t.Fatal(err) } return n @@ -726,9 +732,11 @@ func TestKeyBranchPin_GrantWritesAreAudited(t *testing.T) { // with the key that spent the dealer's AI credit. func TestKeyBranchPin_AISpendsWithItsKey(t *testing.T) { f := newKeyPinFixture(t) - spendRows := func() (n int) { + spendRows := func(action string) (n int) { + t.Helper() if err := f.db.Pool.QueryRow(context.Background(), - `SELECT COUNT(*) FROM audit_log WHERE action IN ('ai.parsing.upload', 'ai.vision.scan') AND actor_kind = 'key'`).Scan(&n); err != nil { + `SELECT COUNT(*) FROM audit_log WHERE action = $1 AND actor_kind = 'key' AND actor_id = $2`, + action, f.boundKeyID).Scan(&n); err != nil { t.Fatal(err) } return n @@ -748,6 +756,7 @@ func TestKeyBranchPin_AISpendsWithItsKey(t *testing.T) { if err := writer.Close(); err != nil { t.Fatal(err) } + beforeUpload := spendRows("ai.parsing.upload") req, err := http.NewRequest("POST", f.srv.URL+"/api/v1/parsing/upload", body) if err != nil { t.Fatal(err) @@ -766,16 +775,16 @@ func TestKeyBranchPin_AISpendsWithItsKey(t *testing.T) { if res.StatusCode == http.StatusForbidden { t.Errorf("bound key parsing upload refused: %s", uploadBody) } - if n := spendRows(); n < 1 { - t.Errorf("parsing upload audit rows naming the key: %d, want at least one", n) + if after := spendRows("ai.parsing.upload"); after != beforeUpload+1 { + t.Errorf("parsing upload audit rows naming the key: %d then %d, want one new row", beforeUpload, after) } - before := spendRows() + before := spendRows("ai.vision.scan") status, buf := f.key(t, f.bound, "POST", "/api/v1/vision/scan", `{"blueprint_text":"pin wall probe"}`, nil) if status == http.StatusForbidden { t.Errorf("bound key vision scan refused: %s", buf) } - if after := spendRows(); after != before+1 { + if after := spendRows("ai.vision.scan"); after != before+1 { t.Errorf("vision scan audit rows naming the key: %d then %d, want one new row", before, after) } } diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index ba4d52b5..eea77041 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -1192,7 +1192,7 @@ func TestKeyBranchPin_DealerWideReads(t *testing.T) { {"GET", "/api/v1/ap/aging", ""}, {"POST", "/api/v1/ap/invoices", `{"vendor_id":"` + uuid.NewString() + `"}`}, {"POST", "/api/v1/ap/payments", `{"invoice_id":"` + uuid.NewString() + `"}`}, - {"POST", "/api/v1/ap/invoices/" + uuid.NewString() + "/approve", ""}, + {"POST", "/api/v1/ap/invoices/" + uuid.NewString() + "/transitions", ""}, {"GET", "/api/v1/bankrecon/accounts", ""}, {"GET", "/api/v1/bankrecon/sessions", ""}, {"POST", "/api/v1/bankrecon/import", ""}, diff --git a/core/internal/parsing/handler.go b/core/internal/parsing/handler.go index b1f3310d..04405fbd 100644 --- a/core/internal/parsing/handler.go +++ b/core/internal/parsing/handler.go @@ -42,7 +42,7 @@ func (h *Handler) WithAuditLog(l *audit.Logger) *Handler { } // recordUpload audits one upload; a missing logger or a failed row never -// blocks the answer (the spend already happened when the service answered). +// blocks the answer. func (h *Handler) recordUpload(ctx context.Context, filename string) { if h.auditLog == nil { return @@ -129,6 +129,11 @@ func (h *Handler) HandleUpload(w http.ResponseWriter, r *http.Request) { aiContentType = "text/plain" } + // The spend is audited before the extractor runs, so a failure after the + // AI call (the product match, the response) still leaves the row that + // names who spent the credit. + h.recordUpload(r.Context(), header.Filename) + // Extract items. synthetic==true means the AI extractor was unavailable // and `extracted` is the canned demo list rather than anything read out // of the upload — it must reach the client labelled as such. @@ -167,7 +172,6 @@ func (h *Handler) HandleUpload(w http.ResponseWriter, r *http.Request) { "filename", header.Filename, "item_count", len(items)) } - h.recordUpload(r.Context(), header.Filename) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) diff --git a/core/internal/parsing/handler_audit_test.go b/core/internal/parsing/handler_audit_test.go new file mode 100644 index 00000000..3f5d706a --- /dev/null +++ b/core/internal/parsing/handler_audit_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package parsing + +import ( + "bytes" + "context" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gablelbm/gable/internal/product" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" +) + +// failingProducts is a product repository whose catalogue read fails, so the +// upload fails after the extractor has run. +type failingProducts struct{ product.Repository } + +func (failingProducts) ListProducts(context.Context) ([]product.Product, error) { + return nil, errors.New("catalogue unavailable") +} + +// TestUploadAuditRowSurvivesALaterFailure holds the PD-6 ruling's edge: the +// upload is audited before the AI extractor runs, so a failure after the AI +// call (here the product match) still leaves the row that records the spend. +func TestUploadAuditRowSurvivesALaterFailure(t *testing.T) { + db := testutil.RequireDB(t) + rows := func() (n int) { + if err := db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'ai.parsing.upload' AND changes->>'filename' = 'pin-audit-order.csv'`).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM audit_log WHERE action = 'ai.parsing.upload' AND changes->>'filename' = 'pin-audit-order.csv'`) + }) + + mux := http.NewServeMux() + NewHandler(NewService(failingProducts{}, nil)).WithAuditLog(audit.NewLogger(db)).RegisterRoutes(mux) + + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + part, err := w.CreateFormFile("file", "pin-audit-order.csv") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write([]byte("sku,qty\nPIN-1,2\n")) + _ = w.Close() + req := httptest.NewRequest("POST", "/api/v1/parsing/upload", body) + req.Header.Set("Content-Type", w.FormDataContentType()) + + before := rows() + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("upload with a failing catalogue answered %d, want 500: %s", rec.Code, rec.Body.String()) + } + if after := rows(); after != before+1 { + t.Errorf("upload audit rows: %d then %d, want one new row although the upload failed after the extractor", before, after) + } +} diff --git a/core/internal/pricing/migration_107_test.go b/core/internal/pricing/migration_107_test.go new file mode 100644 index 00000000..644a6e1e --- /dev/null +++ b/core/internal/pricing/migration_107_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pricing_test + +// Migration 107 on rows that exist (round 6 P1-1 and round 7 P2-2): a category +// pricing rule targets exactly one customer scope. The schema up to 106 is built +// in a scratch database of its own, a rule that breaks the invariant is planted +// the way an earlier version could write it, and 107 is applied: it must report +// the row in a NOTICE without rewriting it and put the check on NOT VALID, then +// on a database with no such row it must validate on add. Either way the check +// refuses a new rule that names both. Replacing the file with SELECT 1 fails +// every assertion here. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +const constraint107 = "category_pricing_rules_single_target_check" + +func scratch107(t *testing.T) (*pgx.Conn, *[]string) { + t.Helper() + db := testutil.RequireDB(t) + base, err := url.Parse(db.Pool.Config().ConnString()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, base.String()) + if err != nil { + t.Fatal(err) + } + suffix := make([]byte, 6) + if _, err := rand.Read(suffix); err != nil { + t.Fatal(err) + } + name := "gv1_m107_" + hex.EncodeToString(suffix) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, name)); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE %q WITH (FORCE)`, name)) + admin.Close(context.Background()) + }) + scratch := *base + scratch.Path = "/" + name + cfg, err := pgx.ParseConfig(scratch.String()) + if err != nil { + t.Fatal(err) + } + var notices []string + cfg.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) { notices = append(notices, n.Message) } + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn, ¬ices +} + +func applyFile(t *testing.T, conn *pgx.Conn, file string) { + t.Helper() + sql, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("applying %s: %v", filepath.Base(file), err) + } +} + +func scalar107[T any](t *testing.T, conn *pgx.Conn, sql string, args ...any) T { + t.Helper() + var v T + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +func TestMigration107_SingleTargetCheck(t *testing.T) { + conn, notices := scratch107(t) + all, err := filepath.Glob("../../migrations/*.sql") + if err != nil || len(all) == 0 { + t.Fatalf("no migrations found: %v", err) + } + sort.Strings(all) + var target string + for _, f := range all { + base := filepath.Base(f) + switch { + case strings.HasSuffix(base, "_down.sql"): + case strings.HasPrefix(base, "107_"): + target = f + case base < "107_": + applyFile(t, conn, f) + } + } + if target == "" { + t.Fatal("migration 107 not found") + } + ctx := context.Background() + const ( + tierRule = "00000000-0000-0000-0000-0000000001a1" + accountRule = "00000000-0000-0000-0000-0000000001a2" + goodTier = "00000000-0000-0000-0000-0000000001a3" + customer = "00000000-0000-0000-0000-0000000001c1" + category = "00000000-0000-0000-0000-0000000001d1" + category2 = "00000000-0000-0000-0000-0000000001d2" + ) + setup := fmt.Sprintf(` + INSERT INTO customers (id, name, account_number, primary_branch_id) + VALUES ('%[1]s', 'm107 customer', 'M107-1', (SELECT id FROM locations LIMIT 1)); + INSERT INTO product_categories (id, name, slug, path) VALUES ('%[2]s', 'm107 category', 'm107-cat', 'm107'), + ('00000000-0000-0000-0000-0000000001d2', 'm107 other category', 'm107-cat2', 'm107b'); + INSERT INTO category_pricing_rules (id, target_type, tier, customer_id, category_id, rule_type, rule_value, created_by) VALUES + ('%[3]s', 'TIER', 'M107', '%[1]s', '%[2]s', 'MARKUP', 10, 'test'), + ('%[4]s', 'ACCOUNT', 'M107', '%[1]s', '%[2]s', 'MARKUP', 10, 'test'), + ('%[5]s', 'TIER', 'M107-OK', NULL, '%[2]s', 'MARKUP', 10, 'test'); + `, customer, category, tierRule, accountRule, goodTier) + if _, err := conn.Exec(ctx, setup); err != nil { + t.Fatalf("plant the legacy rows: %v", err) + } + rowsDigest := func() string { + return scalar107[string](t, conn, `SELECT md5(string_agg(x::text, ',' ORDER BY x::text)) FROM (SELECT * FROM category_pricing_rules) x`) + } + before := rowsDigest() + + // 1. On a database that holds rows breaking the invariant: a NOTICE names + // the count and both ids, no row is rewritten, and the check goes on NOT + // VALID so the report and the schema can be reconciled by hand. + *notices = nil + applyFile(t, conn, target) + var noticed string + for _, n := range *notices { + if strings.HasPrefix(n, "107:") { + noticed = n + } + } + if !strings.Contains(noticed, "2 category pricing rule row(s) name both") || !strings.Contains(noticed, tierRule) || !strings.Contains(noticed, accountRule) { + t.Errorf("the NOTICE does not report the two rows and their ids: %q", noticed) + } + if strings.Contains(noticed, goodTier) { + t.Errorf("the NOTICE names a row that breaks nothing: %q", noticed) + } + if after := rowsDigest(); after != before { + t.Error("migration 107 rewrote a category pricing rule row") + } + if validated := scalar107[bool](t, conn, `SELECT convalidated FROM pg_constraint WHERE conname = $1`, constraint107); validated { + t.Error("the check is validated although two rows break it: it must be NOT VALID") + } + + // 2. The check refuses a new rule that names both, and a rule edited into + // that shape, however the table got there. + insertRule := func(targetType, tier, customerArg string) error { + _, err := conn.Exec(ctx, `INSERT INTO category_pricing_rules (target_type, tier, customer_id, category_id, rule_type, rule_value, created_by) + VALUES ($1, NULLIF($2, ''), NULLIF($3, '')::uuid, $4, 'MARKUP', 5, 'test')`, targetType, tier, customerArg, category2) + return err + } + for _, c := range []struct{ targetType, tier, customer string }{ + {"TIER", "M107-NEW", customer}, + {"ACCOUNT", "M107-NEW", customer}, + } { + var pgErr *pgconn.PgError + if err := insertRule(c.targetType, c.tier, c.customer); !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != constraint107 { + t.Errorf("a new %s rule naming a tier and a customer: %v, want the check violation of %s", c.targetType, err, constraint107) + } + } + if err := insertRule("ACCOUNT", "", customer); err != nil { + t.Errorf("a plain account rule was refused: %v", err) + } + if err := insertRule("TIER", "M107-NEW", ""); err != nil { + t.Errorf("a plain tier rule was refused: %v", err) + } + + // 3. Once the legacy rows are reconciled the check validates. + if _, err := conn.Exec(ctx, `DELETE FROM category_pricing_rules WHERE id IN ($1, $2)`, tierRule, accountRule); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, `ALTER TABLE category_pricing_rules VALIDATE CONSTRAINT `+constraint107); err != nil { + t.Errorf("the check does not validate once the legacy rows are gone: %v", err) + } + + // 4. On a database with no such row the migration validates on add: drop + // the check and run the file again over clean rows. + if _, err := conn.Exec(ctx, `ALTER TABLE category_pricing_rules DROP CONSTRAINT `+constraint107); err != nil { + t.Fatal(err) + } + *notices = nil + applyFile(t, conn, target) + noticed = "" + for _, n := range *notices { + if strings.HasPrefix(n, "107:") { + noticed = n + } + } + if !strings.Contains(noticed, "no category pricing rule row names both") { + t.Errorf("the clean database NOTICE is wrong: %q", noticed) + } + if validated := scalar107[bool](t, conn, `SELECT convalidated FROM pg_constraint WHERE conname = $1`, constraint107); !validated { + t.Error("the check is NOT VALID on a database with no row that breaks it: it must validate on add") + } +} diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index f4fa9d98..8a359ff6 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -123,10 +123,10 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "DELETE /api/v1/reporting/schedules/{id}": {KeyBranchClassRefused, "reports span every branch"}, "GET /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "GET /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "GET /api/v1/ap/aging": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables span every branch"}, - "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "GET /api/v1/ap/aging": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, + "GET /api/v1/ap/invoices": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, + "GET /api/v1/ap/invoices/{id}": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, + "GET /api/v1/ap/payments": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, "GET /api/v1/ar/reconciliation": {KeyBranchClassRefused, "the reconciliation reads every branch's books; its ledger half carries no branch fact"}, "GET /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, "GET /api/v1/bankrecon/sessions": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, @@ -163,9 +163,9 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/admin/exposure-scan": {KeyBranchClassRefused, "the scan re-checks every branch quotes"}, "POST /api/v1/admin/staff": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, "POST /api/v1/admin/staff/{id}/modules": {KeyBranchClassRefused, "the staff roster and its module grants span every branch"}, - "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/ap/invoices/{id}/transitions": {KeyBranchClassRefused, "payables span every branch"}, - "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables span every branch"}, + "POST /api/v1/ap/invoices": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, + "POST /api/v1/ap/invoices/{id}/transitions": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, + "POST /api/v1/ap/payments": {KeyBranchClassRefused, "payables stay dealer wide: AP bills carry a branch now, so this is a policy choice, not a limit"}, "POST /api/v1/apps/{key}/disable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, "POST /api/v1/apps/{key}/enable": {KeyBranchClassRefused, "an app enablement switch is a dealer wide write"}, "POST /api/v1/bankrecon/accounts": {KeyBranchClassRefused, "bank reconciliation spans every branch"}, From 6753d2515e5447dfc7c876fd8f254cf6df35b490 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 17:32:03 -0700 Subject: [PATCH 37/74] test: the refusal names the lowest numbered other branch, never the pin Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/pkg/middleware/keybranch_db_test.go | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/core/pkg/middleware/keybranch_db_test.go b/core/pkg/middleware/keybranch_db_test.go index e39e6e3a..26e1bfa1 100644 --- a/core/pkg/middleware/keybranch_db_test.go +++ b/core/pkg/middleware/keybranch_db_test.go @@ -11,6 +11,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gablelbm/gable/internal/testutil" @@ -50,3 +51,68 @@ func TestKeyBranchWall_LocationRowQueryErrorFailsClosed(t *testing.T) { t.Error("the handler ran under a failed branch lookup; the wall failed open") } } + +// TestKeyBranchWall_ExclusiveNamesAnotherBranchDeterministically holds the +// refusal's "also held by" branch (round 7 P3): it is never the pin itself, and +// among several other branches it is the lowest numbered one every time, so a +// reviewer comparing two answers sees the same branch. +func TestKeyBranchWall_ExclusiveNamesAnotherBranchDeterministically(t *testing.T) { + db := testutil.RequireDB(t) + ctx := context.Background() + pin, c, d := uuid.New(), uuid.New(), uuid.New() + own, shared, nobody := uuid.New(), uuid.New(), uuid.New() + for _, b := range []uuid.UUID{pin, c, d} { + if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'wall order')`, + b, "wall-ord-"+b.String()[:8]); err != nil { + t.Fatalf("seed branch: %v", err) + } + } + for _, cu := range []uuid.UUID{own, shared, nobody} { + if _, err := db.Pool.Exec(ctx, `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'wall order', $2, $3)`, + cu, "WALLORD-"+cu.String()[:8], pin); err != nil { + t.Fatalf("seed customer: %v", err) + } + } + if _, err := db.Pool.Exec(ctx, `INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2), ($3, $4), ($3, $5)`, + own, pin, shared, c, d); err != nil { + t.Fatalf("seed customer branches: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id IN ($1, $2, $3)`, own, shared, nobody) + _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id IN ($1, $2, $3)`, own, shared, nobody) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2, $3)`, pin, c, d) + }) + + lower := c + if d.String() < c.String() { + lower = d + } + w := NewKeyBranchWall(db, nil) + refusal := func(customers ...uuid.UUID) string { + t.Helper() + handler := w.CustomerBranchExclusive(func(context.Context, *http.Request) ([]uuid.UUID, error) { + return customers, nil + })(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("the handler ran for a refused customer") })) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/exemptions", nil).WithContext(branchctx.WithKeyBranch(ctx, pin)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("answer %d, want 403: %s", rec.Code, rec.Body.String()) + } + return rec.Body.String() + } + for i := 0; i < 5; i++ { + got := refusal(own, shared) + if !strings.Contains(got, "also held by branch "+lower.String()) { + t.Errorf("run %d: %s, want it to name the lowest numbered other branch %s", i, got, lower) + } + if strings.Contains(got, pin.String()) { + t.Errorf("run %d: the refusal names the pin itself: %s", i, got) + } + } + // A customer held by nobody next to one the pin holds: refused, and the + // message names no branch (the pin must not be offered as the other one). + if got := refusal(own, nobody); strings.Contains(got, "also held by branch") { + t.Errorf("a customer held by nobody: %s, want no branch named", got) + } +} From 7116cc5d3984df35e092cce3f141958a1de31515 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 17:33:15 -0700 Subject: [PATCH 38/74] docs: state the round 4 rules in ADR 0007 section 5.5, the contract changes and the module pages The sweep claim now says what it does (valid bodies, an own twin control, the twins' ids, a coverage test), the body customer walls and the order ship to and contact ownership are stated, and the shared customer rule carries its field by field table (RESTRICTED or BRANCH LOCAL, with the reason) and the ship to and contact rule. A SEC-BOUND-KEY-PIN-4 row joins the contract changes; the customers, orders, quotes, payments, invoices and drafts pages carry the rule that touches them. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 7 +- ...7-drafts-links-and-confirm-gated-scopes.md | 86 ++++++++++++++++--- docs/modules/customers.md | 30 +++++++ docs/modules/drafts.md | 7 ++ docs/modules/invoices.md | 5 ++ docs/modules/orders.md | 9 ++ docs/modules/payments.md | 5 ++ docs/modules/quotes.md | 5 ++ docs/refactor/CONTRACT-CHANGES.md | 1 + 9 files changed, 144 insertions(+), 11 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index a7148b5e..a5133800 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -112,7 +112,12 @@ func (w *branchWall) inventory(mux *http.ServeMux, svc *inventory.Service) { // customers mounts the customer routes: the create takes primary_branch_id from // its body, so it is held to the caller's branch context; payment terms writes -// take the narrower finance guard. +// take the narrower finance guard. The service also carries the shared +// customer guard (principal rulings 43 to 45): a key bound to one branch +// changes only the branch local fields of a customer another branch holds, +// and adds but never changes or deletes its ship-tos and contacts. The routes +// note their method and path so a refusal the service serves can name them in +// its key.branch_refused row. func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { svc.WithBranchGuard(customerguard.New(w.guard)).WithSharedGuard(customerguard.NewShared(w.keyWall)) customer.NewHandler(svc).RegisterRoutes(mux, diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 05669641..0304a3e1 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -707,10 +707,32 @@ unbound keys keep exactly their reach on every route. A WALLED entry is a tested claim, not a label: its note names the column or join its module filters on (`quotes.branch_id`, a customer through `customer_branches`, a delivery through its order's branch, the dashboard -and inventory through `locations.branch_id`), and a sweep drives every WALLED -route that takes an id or a list with the other branch's twin row as a bound -key's target: the foreign row answers exactly what a missing row answers (or -a 403), never names the row, and nothing is written. +and inventory through `locations.branch_id`), and a sweep drives the WALLED +routes with the other branch's twin row as a bound key's target. Every write +leg sends a body that passes the module's validation, shadowed by a control +against the pin's own twin that must pass the same validation and reach the +row (a leg whose control cannot finish in the harness says why in the test); +the foreign leg must answer 403 or 404 and never a scope refusal; and a +change is measured by a digest of every row that names the twin rows by id, +never by table counts. A coverage test holds the allowlist against the legs +and fails by name for any WALLED route no bound key leg drives. The routes +that take their branch from the caller, and so name no foreign row to +refuse, are driven once and must leave branch B's rows as they were and land +the row they make at the pin's branch. + +A document write that names a customer in its body is held to the pin the +same way a path customer is: the cash and card payment, the credit memo +(create and update), the order and the quote (create and update, a document +moved onto another customer included) and the draft promotions, which read +the customer from the draft's stored payload, are refused a bound key whose +pin does not hold that customer (403 naming `customer_id`, the +`key.branch_refused` row, nothing written). Without it the payment and its +GL entry, the credit, or the order with the other customer's tax exemption +landed on a customer the key could not see. An order, for every caller, +refuses a `ship_to_id` or an `ordered_by_contact_id` its customer does not +own (400 naming the field, as the credit memo create does), so an order can +never carry another customer's address. The order exposure gate and override +read the order under the branch wall before they look up its source quote. The confinement contract the CONFINED routes hold: a body is read whole (a body that is not exactly one complete JSON value is refused, so the wall and @@ -740,12 +762,56 @@ names a customer while targeting a tier is refused however many branches of that customer hold the pin: it prices every branch's customers of the tier. The stricter line on shared customers: a bound key may READ a customer its -branch shares with another, but may not add or change a customer scoped -pricing rule or tax exemption for it, because the write prices the other -branch's sales to that customer too. Those writes are held to the customers -the pin holds alone; the refusal names the other branch. A branch -integration that must price shared customers holds an unbound key an operator -keeps. A shared user's grant, revoke and home branch for the pin branch is +branch shares with another (and the audit of that customer's category rules), +but may not add or change a customer scoped pricing rule or tax exemption +for it, because the write prices the other branch's sales to that customer +too. Those writes are held to the customers the pin holds alone; the refusal +names the other branch (the lowest numbered branch that is not the pin). A +category rule whose target is not an account rule is dealer wide whether or +not it also names a customer, so the legacy tier row migration 107 keeps NOT +VALID cannot be deleted, singly or in bulk, by a bound key. + +The customer record follows the same rule, deny by default. On a customer +held by the pin's branch and another, a bound key changes only the branch +local fields; every other field of the record, and the salesperson and the +escalation policy, is restricted, and a field added to the header write is +restricted until it is listed as branch local. A restricted write is refused +403 in the same words naming the other branch, with the `key.branch_refused` +row and nothing changed. A customer the pin holds alone, a user and an +unshared customer are unchanged. A dealer wide (unbound) key changes the +restricted fields of a shared customer and every such change is audited as +`customer.shared_restricted_changed` with the key as the actor and the before +and after value of each field (a machine key carries no role, so the finance +and owner condition of the ruling cannot be evaluated for a key and is +recorded as an open question). Ship-tos and contacts carry no branch column, +so for a shared customer every existing one is shared: a bound key reads +them and may add new ones but may not change or delete any (the same +refusal); for a customer the pin holds alone all of it is allowed. The +fields: + +| Route | Field | Class | Reason | +|---|---|---|---| +| `PUT /customers/{id}` | `account_number` | RESTRICTED | the identifier every branch's documents and statements carry | +| | `name` | RESTRICTED | printed on every branch's documents | +| | `email` | BRANCH LOCAL | a contact channel of whoever deals with the customer | +| | `phone` | BRANCH LOCAL | a contact channel of whoever deals with the customer | +| | `address` | RESTRICTED | the billing address on every branch's invoices and statements | +| | `tier` | RESTRICTED | prices every branch's sales | +| | `is_active` | RESTRICTED | deactivates the customer for every branch | +| | `price_level_id` | RESTRICTED | prices every branch's sales | +| | `salesperson_id` | RESTRICTED | one salesperson per customer across branches; not plainly branch local | +| | `credit_limit_cents` | RESTRICTED | the credit every branch's orders draw on | +| | `currency` | RESTRICTED | the currency of every branch's documents | +| | `payment_terms_id` | RESTRICTED | due dates and collections at every branch | +| | `po_required` | RESTRICTED | a purchase order number required on every branch's orders | +| | `primary_branch_id` | not changeable | 400 for every caller: it cannot be changed by an edit | +| `PATCH /customers/{id}/salesperson` | `salesperson_id` | RESTRICTED | as above | +| `PUT /customers/{id}/escalation-policy` | `policy`, `threshold_percent`, `agreement_signed_at`, `agreement_ref` | RESTRICTED | governs the repricing of every branch's quotes | +| `POST /customers/{id}/ship-tos`, `/contacts` | a new record | allowed | adds; changes nothing shared | +| `PUT /ship-tos/{id}`, `PUT /contacts/{id}`, `DELETE /contacts/{id}` | an existing record | shared records | refused on a shared customer, allowed on a customer the pin holds alone | + +A branch integration that must price shared customers holds an unbound key an +operator keeps. A shared user's grant, revoke and home branch for the pin branch is accepted as confined and audited: the writes record their audit rows (`user.branch.granted`, `user.branch.revoked`, `user.home_branch.set`) with the machine key named as the actor. diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 4461dba4..7335a057 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -173,6 +173,36 @@ the exact guard is composed in A key without the scope is `403 forbidden`; the audit row carries the refused scope. +A key bound to one branch (ADR 0007 section 5.5) reads a customer its +branch shares with another, and changes a shared customer's record only on +the branch local fields. The rule is deny by default: any header field not +listed as branch local is restricted, so a field added later is restricted +until it is listed in `branchLocalFields` (`core/internal/customer/shared.go`). +A restricted write is `403 forbidden` in the words "a branch bound key may act +only on its own branch: the customer is also held by branch " (the lowest +numbered other branch), with the `key.branch_refused` row, and the record is +untouched. A customer the key's branch holds alone, a user and an unshared +customer are unchanged. A dealer wide key changes the restricted fields and +each change writes a `customer.shared_restricted_changed` audit row naming +the key and the before and after value of every field. + +| Route | Field | Class | Reason | +|---|---|---|---| +| `PUT /customers/{id}` | `account_number`, `name`, `address` | RESTRICTED | the identity and billing address every branch's documents carry | +| | `email`, `phone` | BRANCH LOCAL | contact channels of whoever deals with the customer | +| | `tier`, `price_level_id` | RESTRICTED | price every branch's sales | +| | `is_active` | RESTRICTED | deactivates the customer for every branch | +| | `credit_limit_cents`, `currency`, `payment_terms_id`, `po_required` | RESTRICTED | credit, currency, terms and the purchase order rule at every branch | +| | `salesperson_id` | RESTRICTED | one salesperson per customer across branches | +| `PATCH /customers/{id}/salesperson` | `salesperson_id` | RESTRICTED | as above | +| `PUT /customers/{id}/escalation-policy` | policy, threshold, agreement | RESTRICTED | governs the repricing of every branch's quotes | + +Ship-tos and contacts carry no branch column, so on a shared customer every +existing one is shared: a bound key reads them and may add a new one with +`POST`, and is refused `PUT /ship-tos/{id}`, `PUT /contacts/{id}` and +`DELETE /contacts/{id}` in the same words. On a customer the key's branch +holds alone, all of them are allowed. + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 6, 7, 8, 9, 11, 12. diff --git a/docs/modules/drafts.md b/docs/modules/drafts.md index 1800318e..79fb7143 100644 --- a/docs/modules/drafts.md +++ b/docs/modules/drafts.md @@ -788,6 +788,13 @@ then up test (`TestMigration103_DownThenUpLosesNoRow` in `drafts` or `draft_events` row counts, because the down drops those tables and the row loss is by design. +The draft promotions recheck the customer: a draft's payload may name any +customer when it is saved, but `POST /drafts/quotes/{id}/promote` and +`POST /drafts/orders/{id}/promote` read the customer from the stored payload +and refuse a branch bound key whose pin does not hold it (`403 forbidden` +naming `customer_id`), so a draft written by a user or an earlier call cannot +become a document on another branch's customer. + ## ADRs that govern this module - [`docs/adr/0007-drafts-links-and-confirm-gated-scopes.md`](../adr/0007-drafts-links-and-confirm-gated-scopes.md): the design. Sections 1 and 2 set the resource and the lifecycle; section 3 is the change feed (cursor, heartbeat, the key recheck, the hub, the stream limits, the retention); section 4 is promotion and the kind registry (the `Kind` interface and the promoter); section 5 is the scope grammar (read, write, propose, commit; the finer names; the policy table; the `ScopeTarget` resolution; the user only routes; the branch bound key); section 6 is the actor quadruple; section 7 is document numbers in record URLs; section 8 is the links surface; section 9 is the migration order; section 10 is the quote and order kinds and the file attach. diff --git a/docs/modules/invoices.md b/docs/modules/invoices.md index 599eb783..50e36cb6 100644 --- a/docs/modules/invoices.md +++ b/docs/modules/invoices.md @@ -279,6 +279,11 @@ invoices segment. The user guard at the serve layer is key without the scope is `403 forbidden`; the audit row carries the refused scope. +A key bound to one branch (ADR 0007 section 5.5) may create or update a +credit memo (`POST /credit-memos`, `PUT /credit-memos/{id}`) only for a +customer its branch holds: `403 forbidden` naming `customer_id`, nothing +written. + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 6, 7, 7a, 8, 9, 11, 12. diff --git a/docs/modules/orders.md b/docs/modules/orders.md index 50a37d4f..a4898fa0 100644 --- a/docs/modules/orders.md +++ b/docs/modules/orders.md @@ -235,6 +235,15 @@ finance or warehouse paths (the exact guard is composed in Releasing a hold needs `admin`, `owner` or `finance`. A key without the scope is `403 forbidden`; the audit row carries the refused scope. +A key bound to one branch (ADR 0007 section 5.5) may create or update an +order only for a customer its branch holds (`POST /orders`, `PUT +/orders/{id}`: `403 forbidden` naming `customer_id`, nothing written), and +the exposure gate and override read the order under the branch wall before +they look up its source quote. For every caller, an order refuses a +`ship_to_id` or `ordered_by_contact_id` its customer does not own, with a +`400 validation_failed` naming the field ("is not a ship-to of this +customer", "is not a contact of this customer"). + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 6, 7, 7a, 8, 9, 11, 12. diff --git a/docs/modules/payments.md b/docs/modules/payments.md index b707c066..14d5cb9b 100644 --- a/docs/modules/payments.md +++ b/docs/modules/payments.md @@ -423,6 +423,11 @@ a credit memo")` at `payment/service_card.go:192`). The branch wall applies: a payment is read and written under the branch the request carries through `X-Branch-Id` (ADR 0007). +A key bound to one branch (ADR 0007 section 5.5) may post a payment (`POST +/payments`, `/payments/card`) only for a customer its branch holds: `403 +forbidden` naming `customer_id`, nothing written, so no payment or GL entry +lands on a customer the key cannot see. + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 7, 7a, 9, 11, 12. diff --git a/docs/modules/quotes.md b/docs/modules/quotes.md index d6783b07..bd6c2ba5 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -235,6 +235,11 @@ is registered at the serve layer as `admin`, `owner`, `sales` (see A key without the scope is `403 forbidden`; the route names the path segment of the scope so the audit row carries it. +A key bound to one branch (ADR 0007 section 5.5) may create or update a +quote only for a customer its branch holds (`POST /quotes`, `PUT +/quotes/{id}`: `403 forbidden` naming `customer_id`, nothing written), +which also keeps a quote from being moved onto another branch's customer. + ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md) sections 1, 2, 3, 6, 7, 7a, 8, 9, 11, 12: the list envelope, the cursor, the error envelope, the enum casing, the money and quantity rules, the document number, the idempotency key, the revision precondition, the timestamps. diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 49a7a33e..3ac36e8e 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -293,3 +293,4 @@ get an entry. | C4-1b | goldens that follow | `ap` created a bill in float dollars and read it back; `ap_payments` scripted the 401 approve, the float create and payments that paid a pending bill | `ap` and `ap_payments` re-recorded on the converted routes: the create in cents, ten thousandths and a decimal string quantity, the 428 transition without a precondition, the approve through `/transitions` and its 409 on a second attempt, the events feed step, the partial and settling payments, the paid bill's further payment refused `invoice_not_approved`, the void with payments refused `has_payments`, the validation 400, the status filters and the pending void | | SEC-BOUND-KEY-PIN | the whole `/api/v1` surface a machine key can reach (345 census routes, method plus pattern), held by one allowlist, `core/pkg/middleware/keybranch_allow.go` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin: a scope checked key passes the role guards on scope alone, and the fix that walled each route the sweep found was opt in, so every route a round missed failed open. Rounds 3 and 4 proved it by probe: the market index refresh preview returned other branches' customers and exposure; the customer scoped pricing and tax reads answered about branch B; a pricing rule naming a branch B customer's job set that customer's price with no audit row; dealer wide pricing writes (a rule naming neither customer nor job, a tier category rule) passed; and the GL, AP and bank reconciliation writes, the staff roster and the unfiltered pricing lists were open | Deny by default. One allowlist classifies every machine key reachable route as exactly one of WALLED (183, behind the branch middleware), CONFINED (28, held by the key branch wall or the customer branch wall, each entry naming its test), STATED LIMIT (69, reachable unconfined by design, each entry with its one line reason) or REFUSED (65, answered 403 `forbidden` with the `key.branch_refused` row). The one runtime check is `KeyBranchRouteGate`, mounted once in serve's chain inside auth: it resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, a route the router resolves to no pattern included, so a missed or new route fails closed. A census test (untagged, no database) fails when a machine key reachable census route is unclassified or a table entry is stale. The confinements are completed: `job_id` resolves to its projects row's customer beside `customer_id` (both are checked), the customer scoped reads that name a customer in a path, query or body are confined like the writes (the exemption read, the category rule list by `customer_id` with the unfiltered list refused, the rule audit, resolve, calculate, the tax preview), and a write that would be dealer wide is refused: a pricing rule naming neither a customer nor a job, a category rule or bulk element targeting a tier, and a bulk delete naming a tier row. REFUSED outright beside the round 3 list: the GL, AP and bank reconciliation writes (reads already refused), the staff roster and its module grants, the index refresh preview, `GET /pricing/rules`, `GET /pricing/matrix` and the unfiltered category rule list. Stated limits stay reachable with their reasons in the table, and ADR 0007 section 5.5 names them and advises which scopes not to mint on a bound key. Users and unbound keys keep exactly their reach on every route | | SEC-BOUND-KEY-PIN-3 | the branch bound key's classes and walls, fix round 3 (the same 345 census routes as SEC-BOUND-KEY-PIN) | Rounds 5 and 6 proved by probe that three classes overstated: a tier category rule that also named a customer passed the customer wall and priced every branch's customers of the tier (201, no audit row); the POS till, Z report, return and line routes and the account summary, AR statement and reconciliation loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), added a line to B's transaction that stayed written behind the 500, and read B's till report, Z reports, returns, account, statement and reconciliation; `GET /pricing/calculate` confined its `customer_id` but not its `job_id`; a tax preview naming only B's `ship_to_id` passed; and the 18 dealer wide catalogue writes labelled WALLED (products, PIM, charge codes, payment terms, the reorder refresh) and every other stated limit write were reachable by a bound key | The category rule wall, handler and table now keep one invariant (an account rule carries no tier, a tier rule no customer), and migration 107 puts it on the table checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when one exists, validated on add otherwise. The pos repositories carry the branch predicate the transaction reads already carried (`till_sessions.branch_id`, `till_z_reports.branch_id`, `pos_returns.branch_id`, the line writes through their transaction's branch, the insert never happening on a foreign transaction), the account summary, subledger and statement mount the customer branch wall on the path customer, and the AR reconciliation is refused outright (its ledger half carries no branch fact). The calculation resolves `job_id` to `projects.customer_id` like the write; a tax preview naming a `ship_to_id` is confined through that ship to's customer. The classes are split per the principal's rulings: a stated limit is a read, never a write (WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118), so every dealer wide write (the catalogue and PIM writes above, the settings, module and app switches, the match tolerances, vendors, EDI partners and the catalog import, units, pricing categories, rebates, millwork options, governance rfcs, the index metadata) is refused with the `key.branch_refused` row, the driver routes are refused reads included (personal data, no branch column), and the vehicle reads stay stated limits. Every WALLED entry names the column or join its module filters on and a twin sweep drives every WALLED id or list route with the other branch's row (refused, or the missing row answer, nothing written). A shared customer may be read by a bound key but its customer scoped pricing rules and tax exemptions are refused unless the pin holds the customer alone, the refusal naming the other branch; the grant, revoke and home branch writes record their audit rows with the key as the actor; the parsing upload and vision scan stay stated limits and each call records its AI spend audit row with the key that spent it | +| SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | From c05cf0c9df9bae59273f230d9569be8eaa4cd21a Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:21:15 -0700 Subject: [PATCH 39/74] security(keywall): the pos sync holds its replayed sales' customers to the pin Round 10 P1-1: POST /api/v1/pos/sync takes a customer on every replayed sale and no wall held it, so a key pinned to branch A posted a sale (and an ACCOUNT tender's invoice) on a customer held only by branch B. The mount wall.pos composes the key branch wall's customer hold over the body's items[].customer_id on the sync route only; the change lives in the wall layer and the route allowlist, with no pos code touched. Red first: TestKeyBranchPin_PosSyncCustomerWall fails on 4bad569 with the sale synced and the foreign invoice written. Signed-off-by: colton --- core/internal/app/serve/serve.go | 2 +- core/internal/app/serve/wire_branch_wall.go | 12 ++ .../app/serve/wire_customer_branch.go | 24 ++++ .../app/serve/wire_key_branch_pin_test.go | 2 +- .../app/serve/wire_key_branch_round5_test.go | 109 ++++++++++++++++++ core/pkg/middleware/keybranch_allow.go | 2 +- 6 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_round5_test.go diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 0bd3ea90..b6d112e2 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -669,7 +669,7 @@ func Run() { // as externally-captured (the device settles); posSvc.WithGateway is where // the Clover terminal gateway wires in. posHandler := pos.NewHandler(posSvc) - posHandler.RegisterRoutes(mux, scoped("admin", "owner", "cashier")) + wall.pos(mux, posHandler, "admin", "owner", "cashier") // Accounts Payable Module: the vendor invoice routes on the wire contract // (C4-1b, ADR 0008 7.4). The bills carry a branch, so every route runs diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index a5133800..9943ac09 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -20,6 +20,7 @@ import ( "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/order" "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/internal/quote" @@ -239,6 +240,17 @@ func (w *branchWall) draftGuard(db *database.DB, kind string, roles ...string) f "POST /api/v1/drafts/"+kind+"/{id}/promote") } +// pos mounts the POS routes: the repositories hold the by id reads and writes +// to their branch, and the offline sync replays whole sales whose bodies name +// a customer (an ACCOUNT tender invoices that customer at the register's +// branch), so the sync is held to the pin through the customer each replayed +// sale names (round 10 P1-1). The cashier creates refuse a machine key +// outright in the handler (a cashier must be a user). +func (w *branchWall) pos(mux *http.ServeMux, h *pos.Handler, roles ...string) { + h.RegisterRoutes(mux, w.patternWall(w.scoped(roles...), w.keyWall.CustomerBranch(posSyncCustomersOf()), + "POST /api/v1/pos/sync")) +} + func (w *branchWall) purchaseOrders(mux *http.ServeMux, h *purchase_order.Handler) { h.WithBranchGuard(w.guard).RegisterRoutes(mux, w.scoped("admin", "owner", "purchasing")) } diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 198d496e..d45ba5e9 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -129,6 +129,30 @@ func previewCustomerOf(db *database.DB) func(ctx context.Context, r *http.Reques } } +// posSyncCustomersOf resolves the customers an offline sync's body names: one +// customer per replayed sale (items[].customer_id). A sale that names no +// customer (a walk in, paid in cash) names nothing the wall can hold and +// passes; the register's branch still holds the rows the replay writes. +func posSyncCustomersOf() func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + var body struct { + Items []struct { + CustomerID string `json:"customer_id"` + } `json:"items"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + var out []uuid.UUID + for _, item := range body.Items { + if id, err := uuid.Parse(item.CustomerID); err == nil { + out = append(out, id) + } + } + return out, nil + } +} + // queryCustomerOf resolves the customer a query parameter names. A value that // does not parse names nothing the wall can hold and passes to the handler's // own validation; an absent value passes unless required says otherwise (the diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index eea77041..1a0ac850 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -428,7 +428,7 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { posSvc := pos.NewService(db, pos.NewRepository(db), productSvc, inventory.NewService(inventory.NewRepository(db)), invoiceSvc, paymentSvc, slog.Default()). WithAuditLog(audit.NewLogger(db)) - pos.NewHandler(posSvc).RegisterRoutes(mux, wall.scoped("admin", "owner", "cashier")) + wall.pos(mux, pos.NewHandler(posSvc), "admin", "owner", "cashier") // The AI spending routes exactly as serve registers them, each call // audited with whoever spent the credit (the PD-6 ruling). diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go new file mode 100644 index 00000000..47fd4257 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// Fix round 5 of the branch bound key pin: the findings of reviews 10 and 11 +// and the principal's item 48 ruling, every leg through the real serve chain +// with bodies that pass validation, each foreign leg shadowed by an A control +// and measured by the rows that name the twin customer, never by whole table +// counts. + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +// round5Fixture is the pin fixture with the branch tax rates and the walled +// twins seeded, keeping the first mint (the pos and pricing scopes) available +// beside the document family mint. +func round5Fixture(t *testing.T) (*keyPinFixture, *walledTwins, string) { + t.Helper() + f := newKeyPinFixture(t) + firstBound := f.bound + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE locations SET default_tax_rate = 0.05 WHERE id IN ($1, $2)`, f.branchA, f.branchB); err != nil { + t.Fatalf("seed the branch tax rate: %v", err) + } + return f, f.seedWalledTwins(t), firstBound +} + +// TestKeyBranchPin_PosSyncCustomerWall is round 10 P1-1: the offline sync +// replays whole sales whose bodies name a customer, so a key pinned to A +// posting a sale on a customer held only by B (its ACCOUNT tender invoicing +// that customer at branch A) is refused at the wall with nothing written. The +// A control completes the same sale on the pin's own customer, and the user +// keeps the reach. +func TestKeyBranchPin_PosSyncCustomerWall(t *testing.T) { + f, _, firstBound := round5Fixture(t) + // The register's completion deducts stock from the default (null location) + // row; the fixture's teardown removes every inventory row of the product. + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO inventory (product_id, location_id, location, quantity) VALUES ($1, NULL, '', 50)`, f.posProd); err != nil { + t.Fatalf("seed stock: %v", err) + } + client := uuid.New() + freshClient := func() string { client = uuid.New(); return client.String() } + sync := func(register, customer string) string { + return fmt.Sprintf(`{"batch_id":"pin-%s","register_id":%q,"items":[{"client_id":%q,"register_id":%q,"cashier_id":%q,`+ + `"customer_id":%q,"items":[{"product_id":%q,"quantity":1,"uom":"EA"}],`+ + `"tenders":[{"method":"ACCOUNT","amount":1.08}],"client_created_at":%q}]}`, + uuid.NewString()[:8], register, freshClient(), register, uuid.NewString(), customer, + f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) + } + invoicesOn := func(customer uuid.UUID) (n int) { + t.Helper() + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM invoices WHERE customer_id = $1`, customer).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + txnExists := func() bool { + t.Helper() + var ok bool + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT EXISTS(SELECT 1 FROM pos_transactions WHERE id = $1)`, client).Scan(&ok); err != nil { + t.Fatal(err) + } + return ok + } + + before := invoicesOn(f.custB) + rows := f.auditRows(t) + status, buf := f.key(t, firstBound, "POST", "/api/v1/pos/sync", sync(f.regA, f.custB.String()), nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("bound key pos sync naming the foreign customer: %d %s, want the pin's 403", status, buf) + } + if !strings.Contains(string(buf), "customer_id") { + t.Errorf("the pos sync refusal does not name customer_id: %s", buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("pos sync refusal: key.branch_refused rows %d then %d, want one new row", rows, after) + } + if after := invoicesOn(f.custB); after != before { + t.Errorf("the refused pos sync still wrote %d invoices naming the foreign customer", after-before) + } + if txnExists() { + t.Errorf("the refused pos sync still wrote its replayed transaction") + } + + // The A control: the same sale on the pin's own customer completes and its + // ACCOUNT tender invoices that customer, and the user keeps the same reach + // on the foreign customer's sale at the foreign register. + ownBefore := invoicesOn(f.custID) + status, buf = f.key(t, firstBound, "POST", "/api/v1/pos/sync", sync(f.regA, f.custID.String()), nil) + if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { + t.Errorf("bound key pos sync on the pin's own customer: %d %s, want the sale synced", status, buf) + } + if grown := invoicesOn(f.custID) - ownBefore; grown != 1 { + t.Errorf("the control pos sync wrote %d invoices on the pin's customer, want its ACCOUNT tender's one", grown) + } + f.allow(t, "user", "POST", "/api/v1/pos/sync", sync(f.regB, f.custB.String()), nil, http.StatusOK) +} diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 8a359ff6..82d2a6cf 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -398,7 +398,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "pos_registers.branch_id on create; no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "pos_registers.branch_id on every replayed create (CreateTransaction)"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "pos_registers.branch_id on every replayed create, and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the register's branch (pos_registers.branch_id, refused on a mismatch) and no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_registers.branch_id on create, pos_transactions.branch_id after; no machine key cashier"}, From e853ae30d129fefa58dfe012601b705519bed841 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:30:31 -0700 Subject: [PATCH 40/74] security(keywall): a document refuses a job its customer does not own Round 11 P1-1 and round 10 P2-2: an order or a quote took a job_id of another customer's project, and the order was then priced by that customer's job rule. For every caller the order build (and the conversion's buildFromQuote) and the quote priceDraft now refuse a job_id whose project belongs to a different customer than the document's, 400 naming job_id with the credit memo's words; the order repository's OwnedBy carries the job beside the ship-to and the contact. For a branch bound key the wall holds the body's job_id through projects.customer_id on the order and quote creates and updates (bodyDocumentCustomer over documentCustomersOf), the same resolution the pricing calculate read carries. Red first: TestKeyBranchPin_JobOfAnotherCustomer fails on 4bad569 with the foreign job stored and the override's price on the A control. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 24 ++- .../app/serve/wire_customer_branch.go | 39 +++++ .../app/serve/wire_key_branch_round5_test.go | 147 ++++++++++++++++-- core/internal/order/repository.go | 24 ++- core/internal/order/service.go | 23 ++- core/internal/portal/service_repo_test.go | 4 +- core/internal/quote/repository.go | 13 ++ core/internal/quote/service.go | 13 ++ core/internal/quote/service_test.go | 7 + core/pkg/middleware/keybranch_allow.go | 4 +- 10 files changed, 267 insertions(+), 31 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 9943ac09..a25798b0 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -44,11 +44,12 @@ type branchWall struct { guard *middleware.BranchGuard keyWall *middleware.KeyBranchWall scoped func(roles ...string) func(http.Handler) http.Handler + db *database.DB } func newBranchWall(db *database.DB) *branchWall { w := &branchWall{mw: middleware.NewBranchMiddleware(db).Handler, guard: middleware.NewBranchGuard(db), - keyWall: middleware.NewKeyBranchWall(db, audit.NewLogger(db))} + keyWall: middleware.NewKeyBranchWall(db, audit.NewLogger(db)), db: db} w.scoped = func(roles ...string) func(http.Handler) http.Handler { return middleware.Compose(middleware.RequireRole(roles...), w.mw) } @@ -147,20 +148,26 @@ func (w *branchWall) delivery(mux *http.ServeMux, h *delivery.Handler, roles ... // quotes mounts the quote routes: the create and the update take customer_id // from their body, so a branch bound key reaches them only while the pin holds -// that customer (round 9 P1-1). +// that customer (round 9 P1-1), and their job_id is held the same way through +// the job's customer (round 11 P1-1). The conversion rechecks the quote's own +// customer under the same wall (round 11 P2-1): a quote moved onto another +// branch's customer by a caller who may must not become an order at the pin's +// branch. func (w *branchWall) quotes(mux *http.ServeMux, svc *quote.Service) { quote.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, - w.bodyCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/quotes", "PUT /api/v1/quotes/{id}")) + w.bodyDocumentCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/quotes", "PUT /api/v1/quotes/{id}")) } // orders mounts the order routes: the create takes branch_id from its body, // so it is held to the caller's branch context (ADR 0007 section 2.3), and the // create and the update take customer_id from it, so a branch bound key // reaches them only while the pin holds that customer (round 9 P1-1: the -// order would otherwise take another branch's tax exemption). +// order would otherwise take another branch's tax exemption). Their job_id is +// held the same way through the job's customer (round 11 P1-1: the order +// would otherwise be priced by the other customer's job rule). func (w *branchWall) orders(mux *http.ServeMux, svc *order.Service) { order.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, - w.bodyCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/orders", "PUT /api/v1/orders/{id}"), + w.bodyDocumentCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/orders", "PUT /api/v1/orders/{id}"), w.scoped("admin", "owner", "finance", "warehouse")) } @@ -212,6 +219,13 @@ func (w *branchWall) bodyCustomer(base func(http.Handler) http.Handler, patterns return w.patternWall(base, w.keyWall.CustomerBodyBranch(), patterns...) } +// bodyDocumentCustomer is bodyCustomer over a document body, whose job_id +// names a customer through its project as well (round 11 P1-1): the hold is +// on the body's customer_id and the customer of the job its job_id names. +func (w *branchWall) bodyDocumentCustomer(base func(http.Handler) http.Handler, patterns ...string) func(http.Handler) http.Handler { + return w.patternWall(base, w.keyWall.CustomerBranch(documentCustomersOf(w.db)), patterns...) +} + // patternWall runs hold after base on the listed route patterns and base alone // on every other route of the group. func (w *branchWall) patternWall(base, hold func(http.Handler) http.Handler, patterns ...string) func(http.Handler) http.Handler { diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index d45ba5e9..19db236e 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -153,6 +153,45 @@ func posSyncCustomersOf() func(ctx context.Context, r *http.Request) ([]uuid.UUI } } +// documentCustomersOf resolves the customers a document write names: its body +// customer_id, and the customer of the job its job_id names (a projects row +// belongs to exactly one customer, and a document on the job is priced and +// posted through that customer, the same resolution the pricing rule write +// and the calculate read carry). A job that does not exist names nothing the +// wall can hold and the handler's own answer covers it. +func documentCustomersOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("document customer lookup: no database") + } + var body struct { + CustomerID string `json:"customer_id"` + JobID string `json:"job_id"` + } + if err := middleware.DecodeOneJSONBody(r, &body); err != nil { + return nil, err + } + var out []uuid.UUID + if id, err := uuid.Parse(body.CustomerID); err == nil { + out = append(out, id) + } + if raw := body.JobID; raw != "" { + if job, err := uuid.Parse(raw); err == nil { + var customer *uuid.UUID + err := db.GetExecutor(ctx).QueryRow(ctx, + `SELECT customer_id FROM projects WHERE id = $1`, job).Scan(&customer) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if customer != nil { + out = append(out, *customer) + } + } + } + return out, nil + } +} + // queryCustomerOf resolves the customer a query parameter names. A value that // does not parse names nothing the wall can hold and passes to the handler's // own validation; an absent value passes unless required says otherwise (the diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 47fd4257..12a7bddb 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -20,18 +20,145 @@ import ( "github.com/google/uuid" ) +// round5FirstMint is the fixture's first mint (the pos and pricing scopes), +// kept for the legs that need it once the fixture has swapped to the document +// family mint. +type round5FirstMint struct{ raw, id string } + // round5Fixture is the pin fixture with the branch tax rates and the walled -// twins seeded, keeping the first mint (the pos and pricing scopes) available -// beside the document family mint. -func round5Fixture(t *testing.T) (*keyPinFixture, *walledTwins, string) { +// twins seeded, swapped to the document family mint; the first mint stays +// reachable for the pos legs. +func round5Fixture(t *testing.T) (*keyPinFixture, *walledTwins, round5FirstMint) { t.Helper() f := newKeyPinFixture(t) - firstBound := f.bound + first := round5FirstMint{raw: f.bound, id: f.boundKeyID} + f.bound, f.unbound, f.boundKeyID = f.bound2, f.unbound2, f.bound2KeyID if _, err := f.db.Pool.Exec(context.Background(), `UPDATE locations SET default_tax_rate = 0.05 WHERE id IN ($1, $2)`, f.branchA, f.branchB); err != nil { t.Fatalf("seed the branch tax rate: %v", err) } - return f, f.seedWalledTwins(t), firstBound + return f, f.seedWalledTwins(t), first +} + +// auditRowsFor counts the given key's refusal rows. +func (f *keyPinFixture) auditRowsFor(t *testing.T, keyID string) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'key.branch_refused' AND actor_id = $1`, keyID).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// TestKeyBranchPin_JobOfAnotherCustomer is round 11 P1-1 and round 10 P2-2: +// an order or a quote took a job_id of another customer's project, and the +// order was priced by that customer's job rule. Every caller's build now +// refuses a job_id whose project belongs to a different customer than the +// document's (400 naming job_id, the credit memo's words), and a bound key is +// refused at the wall before that, the body's job_id held through +// projects.customer_id on the create and the update alike. +func TestKeyBranchPin_JobOfAnotherCustomer(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + // B's job carries a fixed price JOB_OVERRIDE rule naming no customer, so a + // document that takes the job is priced by it: without the refusal the + // leak shows as the other customer's price. + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO pricing_rules (name, rule_type, product_id, job_id, fixed_price) + VALUES ('pin job override', 'JOB_OVERRIDE', $1, $2, 0.42)`, f.posProd, f.jobB); err != nil { + t.Fatalf("seed the job rule: %v", err) + } + A := f.custID.String() + orderBody := func(customer, job string) string { + return fmt.Sprintf(`{"customer_id":%q,"job_id":%q,"delivery_type":"pickup","lines":[%s]}`, + customer, job, f.productLine()) + } + quoteBody := func(customer, job string) string { + return fmt.Sprintf(`{"customer_id":%q,"job_id":%q,"lines":[%s]}`, customer, job, f.quoteLine()) + } + refuseJob := func(label, method, path, body string) { + t.Helper() + for _, who := range []string{"unbound", "user"} { + status, buf := f.send(t, who, method, path, body, nil) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "job_id") || + !strings.Contains(string(buf), "is not a job of this customer") { + t.Errorf("%s: %s naming the foreign job: %d %s, want the 400 naming job_id", label, who, status, buf) + } + } + before := f.auditRows(t) + status, buf := f.key(t, f.bound, method, path, body, nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("%s: bound key naming the foreign job: %d %s, want the pin's 403", label, status, buf) + } + if after := f.auditRows(t); after != before+1 { + t.Errorf("%s: bound key refusal rows %d then %d, want one new row", label, before, after) + } + } + + // The creates: the foreign job is refused for every caller, the A control + // on the pin's own job completes and is priced by the catalogue, not by + // the override. + refuseJob("order create", "POST", "/api/v1/orders", orderBody(A, f.jobB.String())) + if id := f.made(t, "bound", "orders", "POST", "/api/v1/orders", orderBody(A, f.jobA.String()), http.StatusCreated); id == uuid.Nil { + t.Fatal("the A control order create did not answer its id") + } else { + doc := f.readJSON(t, "/api/v1/orders/"+id.String()) + if doc["job_id"] != f.jobA.String() { + t.Errorf("the A control order carries job %v, want the pin's own", doc["job_id"]) + } + if doc["subtotal_cents"] != float64(100) { + t.Errorf("the A control order's subtotal is %v, want the catalogue's 100 (the override is on the foreign job)", doc["subtotal_cents"]) + } + } + refuseJob("quote create", "POST", "/api/v1/quotes", quoteBody(A, f.jobB.String())) + f.made(t, "bound", "quotes", "POST", "/api/v1/quotes", quoteBody(A, f.jobA.String()), http.StatusCreated) + + // The updates move an existing document onto the foreign job: every caller + // gets the same refusal on its own fresh A document (the revision is read + // per document), and the control PUT keeps the pin's own job. + freshOrder := func() uuid.UUID { + return f.made(t, "bound", "orders", "POST", "/api/v1/orders", orderBody(A, f.jobA.String()), http.StatusCreated) + } + freshQuote := func() uuid.UUID { + return f.made(t, "bound", "quotes", "POST", "/api/v1/quotes", quoteBody(A, f.jobA.String()), http.StatusCreated) + } + docPut := func(kind string, doc uuid.UUID, job string) string { + rev := f.readJSON(t, "/api/v1/"+kind+"/"+doc.String())["revision"] + if kind == "orders" { + return fmt.Sprintf(`{"customer_id":%q,"job_id":%q,"delivery_type":"pickup","revision":%v,"lines":[%s]}`, + A, job, rev, f.productLine()) + } + return fmt.Sprintf(`{"customer_id":%q,"job_id":%q,"revision":%v,"lines":[%s]}`, A, job, rev, f.quoteLine()) + } + for _, who := range []string{"unbound", "user"} { + doc := freshOrder() + if status, buf := f.send(t, who, "PUT", "/api/v1/orders/"+doc.String(), docPut("orders", doc, f.jobB.String()), nil); status != http.StatusBadRequest { + t.Errorf("order update: %s naming the foreign job: %d %s, want the 400 naming job_id", who, status, buf) + } + doc = freshQuote() + if status, buf := f.send(t, who, "PUT", "/api/v1/quotes/"+doc.String(), docPut("quotes", doc, f.jobB.String()), nil); status != http.StatusBadRequest { + t.Errorf("quote update: %s naming the foreign job: %d %s, want the 400 naming job_id", who, status, buf) + } + } + orderDoc, quoteDoc := freshOrder(), freshQuote() + refuseJob("order update", "PUT", "/api/v1/orders/"+orderDoc.String(), docPut("orders", orderDoc, f.jobB.String())) + refuseJob("quote update", "PUT", "/api/v1/quotes/"+quoteDoc.String(), docPut("quotes", quoteDoc, f.jobB.String())) + f.allow(t, "bound", "PUT", "/api/v1/orders/"+orderDoc.String(), docPut("orders", orderDoc, f.jobA.String()), nil, http.StatusOK) + f.allow(t, "bound", "PUT", "/api/v1/quotes/"+quoteDoc.String(), docPut("quotes", quoteDoc, f.jobA.String()), nil, http.StatusOK) + + // Nothing of B's side moved: no document names the foreign job (the wire's + // job_id is the tables' project_id). + for _, table := range []string{"orders", "quotes"} { + var n int + if err := f.db.Pool.QueryRow(ctx, + `SELECT COUNT(*) FROM `+table+` WHERE project_id = $1`, f.jobB).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%s naming the foreign job: %d rows, want none", table, n) + } + } } // TestKeyBranchPin_PosSyncCustomerWall is round 10 P1-1: the offline sync @@ -41,7 +168,7 @@ func round5Fixture(t *testing.T) (*keyPinFixture, *walledTwins, string) { // A control completes the same sale on the pin's own customer, and the user // keeps the reach. func TestKeyBranchPin_PosSyncCustomerWall(t *testing.T) { - f, _, firstBound := round5Fixture(t) + f, _, first := round5Fixture(t) // The register's completion deducts stock from the default (null location) // row; the fixture's teardown removes every inventory row of the product. if _, err := f.db.Pool.Exec(context.Background(), @@ -76,15 +203,15 @@ func TestKeyBranchPin_PosSyncCustomerWall(t *testing.T) { } before := invoicesOn(f.custB) - rows := f.auditRows(t) - status, buf := f.key(t, firstBound, "POST", "/api/v1/pos/sync", sync(f.regA, f.custB.String()), nil) + rows := f.auditRowsFor(t, first.id) + status, buf := f.key(t, first.raw, "POST", "/api/v1/pos/sync", sync(f.regA, f.custB.String()), nil) if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { t.Errorf("bound key pos sync naming the foreign customer: %d %s, want the pin's 403", status, buf) } if !strings.Contains(string(buf), "customer_id") { t.Errorf("the pos sync refusal does not name customer_id: %s", buf) } - if after := f.auditRows(t); after != rows+1 { + if after := f.auditRowsFor(t, first.id); after != rows+1 { t.Errorf("pos sync refusal: key.branch_refused rows %d then %d, want one new row", rows, after) } if after := invoicesOn(f.custB); after != before { @@ -98,7 +225,7 @@ func TestKeyBranchPin_PosSyncCustomerWall(t *testing.T) { // ACCOUNT tender invoices that customer, and the user keeps the same reach // on the foreign customer's sale at the foreign register. ownBefore := invoicesOn(f.custID) - status, buf = f.key(t, firstBound, "POST", "/api/v1/pos/sync", sync(f.regA, f.custID.String()), nil) + status, buf = f.key(t, first.raw, "POST", "/api/v1/pos/sync", sync(f.regA, f.custID.String()), nil) if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { t.Errorf("bound key pos sync on the pin's own customer: %d %s, want the sale synced", status, buf) } diff --git a/core/internal/order/repository.go b/core/internal/order/repository.go index bb890545..2f1c1a51 100644 --- a/core/internal/order/repository.go +++ b/core/internal/order/repository.go @@ -79,9 +79,11 @@ type Repository interface { CustomerFacts(ctx context.Context, customerID uuid.UUID) (CustomerFacts, error) ContactAuthority(ctx context.Context, contactID uuid.UUID) (ContactAuthority, error) DefaultShipToID(ctx context.Context, customerID uuid.UUID) (*uuid.UUID, error) - // OwnedBy checks that the ship-to and the contact a document names belong - // to its customer: the foreign keys alone would accept another customer's. - OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID *uuid.UUID) (shipToOK, contactOK bool, err error) + // OwnedBy checks that the ship-to, the contact and the job a document + // names belong to its customer: the foreign keys alone would accept + // another customer's rows, and a foreign job's pricing rules would price + // the document. + OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID, jobID *uuid.UUID) (shipToOK, contactOK, jobOK bool, err error) ShipTo(ctx context.Context, shipToID uuid.UUID) (snapshot *ShipToSnapshot, rate *string, err error) BranchTaxRate(ctx context.Context, branchID uuid.UUID) (*string, error) CustomerExempt(ctx context.Context, customerID uuid.UUID) (bool, error) @@ -914,21 +916,27 @@ func (r *PostgresRepository) ContactAuthority(ctx context.Context, contactID uui return a, nil } -func (r *PostgresRepository) OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID *uuid.UUID) (shipToOK, contactOK bool, err error) { - shipToOK, contactOK = true, true +func (r *PostgresRepository) OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, contactID, jobID *uuid.UUID) (shipToOK, contactOK, jobOK bool, err error) { + shipToOK, contactOK, jobOK = true, true, true if shipToID != nil { if err = r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_ship_tos WHERE id = $1 AND customer_id = $2)`, *shipToID, customerID).Scan(&shipToOK); err != nil { - return false, false, fmt.Errorf("failed to check the ship-to: %w", err) + return false, false, false, fmt.Errorf("failed to check the ship-to: %w", err) } } if contactID != nil { if err = r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_contacts WHERE id = $1 AND customer_id = $2)`, *contactID, customerID).Scan(&contactOK); err != nil { - return false, false, fmt.Errorf("failed to check the contact: %w", err) + return false, false, false, fmt.Errorf("failed to check the contact: %w", err) } } - return shipToOK, contactOK, nil + if jobID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND customer_id = $2)`, *jobID, customerID).Scan(&jobOK); err != nil { + return false, false, false, fmt.Errorf("failed to check the job: %w", err) + } + } + return shipToOK, contactOK, jobOK, nil } func (r *PostgresRepository) DefaultShipToID(ctx context.Context, customerID uuid.UUID) (*uuid.UUID, error) { diff --git a/core/internal/order/service.go b/core/internal/order/service.go index 35ed3ee0..c5239805 100644 --- a/core/internal/order/service.go +++ b/core/internal/order/service.go @@ -186,10 +186,11 @@ func (s *Service) build(ctx context.Context, d *Draft, branchID uuid.UUID, actor Details: []httpx.FieldError{{Field: "customer_id", Message: "no such customer"}}} } - // A ship-to or a contact the customer does not own is refused for every - // caller, as the credit memo create refuses it (round 9 P1-2): the - // snapshot would carry another customer's address. - shipOK, contactOK, err := s.repo.OwnedBy(ctx, d.CustomerID, d.ShipToID, d.OrderedByContactID) + // A ship-to, a contact or a job the customer does not own is refused for + // every caller, as the credit memo create refuses it (round 9 P1-2, round + // 11 P1-1): the snapshot would carry another customer's address, and a + // foreign job's rules would price the document. + shipOK, contactOK, jobOK, err := s.repo.OwnedBy(ctx, d.CustomerID, d.ShipToID, d.OrderedByContactID, d.JobID) if err != nil { return nil, err } @@ -200,6 +201,9 @@ func (s *Service) build(ctx context.Context, d *Draft, branchID uuid.UUID, actor if !contactOK { notOwned = append(notOwned, httpx.FieldError{Field: "ordered_by_contact_id", Message: "is not a contact of this customer"}) } + if !jobOK { + notOwned = append(notOwned, httpx.FieldError{Field: "job_id", Message: "is not a job of this customer"}) + } if len(notOwned) > 0 { return nil, validationFailed("one or more fields failed validation", notOwned...) } @@ -1744,6 +1748,17 @@ func (s *Service) buildFromQuote(ctx context.Context, src *QuoteSource, branchID Message: "a referenced record does not exist", Details: []httpx.FieldError{{Field: "customer_id", Message: "no such customer"}}} } + // The quote's stored job is rechecked against the quote's customer here: + // a row that predates the job ownership rule (or a cross branch write the + // convert wall refuses a bound key) must not price the converted order. + if src.JobID != nil { + if _, _, jobOK, err := s.repo.OwnedBy(ctx, src.CustomerID, nil, nil, src.JobID); err != nil { + return nil, err + } else if !jobOK { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: "job_id", Message: "is not a job of this customer"}) + } + } o := Order{} o.ID = uuid.New() diff --git a/core/internal/portal/service_repo_test.go b/core/internal/portal/service_repo_test.go index 0a205bd7..eab4b707 100644 --- a/core/internal/portal/service_repo_test.go +++ b/core/internal/portal/service_repo_test.go @@ -664,8 +664,8 @@ func (f *fakeOrderRepo) CustomerFacts(_ context.Context, customerID uuid.UUID) ( func (f *fakeOrderRepo) ContactAuthority(context.Context, uuid.UUID) (order.ContactAuthority, error) { return order.ContactAuthority{Exists: true, CanPlaceOrders: true}, nil } -func (f *fakeOrderRepo) OwnedBy(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID) (bool, bool, error) { - return true, true, nil +func (f *fakeOrderRepo) OwnedBy(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, *uuid.UUID) (bool, bool, bool, error) { + return true, true, true, nil } func (f *fakeOrderRepo) DefaultShipToID(context.Context, uuid.UUID) (*uuid.UUID, error) { return nil, nil diff --git a/core/internal/quote/repository.go b/core/internal/quote/repository.go index f2f79eaa..83b22107 100644 --- a/core/internal/quote/repository.go +++ b/core/internal/quote/repository.go @@ -80,6 +80,10 @@ func (s ProductUnitSet) Row(uom string) (ProductUnitRow, bool) { type Repository interface { NextNumber(ctx context.Context) (string, error) LookupProducts(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]ProductRef, error) + // JobOwnedBy checks that the job a draft names belongs to its customer: + // the foreign key alone would accept another customer's project, whose job + // pricing rules would then price the document (round 11 P1-1). + JobOwnedBy(ctx context.Context, jobID, customerID uuid.UUID) (bool, error) // The unit resolution reads of C3-2A-units (ADR 0006 sections 3.3, 3.4 // and 4): the products' unit sets and the unit catalogue. LookupProductUnits(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]ProductUnitSet, error) @@ -114,6 +118,15 @@ func (r *PostgresRepository) NextNumber(ctx context.Context) (string, error) { return httpx.NextDocumentNumber(ctx, r.db.GetExecutor(ctx), "quote_number_seq", "Q", httpx.DefaultDocNumberWidth) } +func (r *PostgresRepository) JobOwnedBy(ctx context.Context, jobID, customerID uuid.UUID) (bool, error) { + var ok bool + if err := r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND customer_id = $2)`, jobID, customerID).Scan(&ok); err != nil { + return false, fmt.Errorf("failed to check the job: %w", err) + } + return ok, nil +} + func (r *PostgresRepository) LookupProducts(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]ProductRef, error) { out := make(map[uuid.UUID]ProductRef, len(ids)) if len(ids) == 0 { diff --git a/core/internal/quote/service.go b/core/internal/quote/service.go index c21813e6..dffef0aa 100644 --- a/core/internal/quote/service.go +++ b/core/internal/quote/service.go @@ -181,6 +181,19 @@ func notFound(err error) error { func (s *Service) priceDraft(ctx context.Context, d *Draft) (*Quote, error) { v := &httpx.Validator{} + // A job the customer does not own is refused for every caller, as the + // credit memo create refuses it (round 11 P1-1): a foreign job's pricing + // rules would price the document. + if d.JobID != nil { + owned, err := s.repo.JobOwnedBy(ctx, *d.JobID, d.CustomerID) + if err != nil { + return nil, err + } + if !owned { + v.Check(false, "job_id", "is not a job of this customer") + } + } + var productIDs []uuid.UUID for _, l := range d.Lines { if l.ProductID != nil { diff --git a/core/internal/quote/service_test.go b/core/internal/quote/service_test.go index a7994654..f8ee6758 100644 --- a/core/internal/quote/service_test.go +++ b/core/internal/quote/service_test.go @@ -30,6 +30,9 @@ type fakeRepo struct { seq int log *[]string // shared call log with the recorder, to assert ordering insErr error + // jobOwner maps a job to the customer that owns it; a job absent from the + // map is owned by no customer the drafts name. + jobOwner map[uuid.UUID]uuid.UUID } func newFakeRepo() *fakeRepo { @@ -39,6 +42,10 @@ func newFakeRepo() *fakeRepo { func (f *fakeRepo) note(s string) { *f.log = append(*f.log, s) } +func (f *fakeRepo) JobOwnedBy(_ context.Context, jobID, customerID uuid.UUID) (bool, error) { + return f.jobOwner[jobID] == customerID, nil +} + func (f *fakeRepo) NextNumber(context.Context) (string, error) { f.seq++ return fmt.Sprintf("Q-%06d", f.seq), nil diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 82d2a6cf..88050216 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -385,7 +385,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/invoices/{id}/write-offs": {KeyBranchClassWalled, "invoices.branch_id"}, "POST /api/v1/locations": {KeyBranchClassWalled, "locations.branch_id (the guard on the parent the row hangs under)"}, "POST /api/v1/matching/run/{po_id}": {KeyBranchClassWalled, "purchase_orders.branch_id (the run reads the order under the wall)"}, - "POST /api/v1/orders": {KeyBranchClassWalled, "orders.branch_id (the payload branch rule)"}, + "POST /api/v1/orders": {KeyBranchClassWalled, "orders.branch_id (the payload branch rule); the body's customer_id and job_id through the customer branch wall (round 9 P1-1, round 11 P1-1)"}, "POST /api/v1/orders/fulfillment-requests/{delivery_id}/retry": {KeyBranchClassWalled, "deliveries.order_id to orders.branch_id"}, "POST /api/v1/orders/{id}/allocate": {KeyBranchClassWalled, "orders.branch_id"}, "POST /api/v1/orders/{id}/exposure-override": {KeyBranchClassWalled, "orders.branch_id"}, @@ -411,7 +411,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/purchase-orders/{id}/freight/{freightId}/apply": {KeyBranchClassWalled, "purchase_orders.branch_id"}, "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "purchase_orders.branch_id"}, "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "purchase_orders.branch_id"}, - "POST /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id (the payload branch rule)"}, + "POST /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id (the payload branch rule); the body's customer_id and job_id through the customer branch wall (round 9 P1-1, round 11 P1-1)"}, "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "quotes.branch_id (the convert reads the quote under the wall)"}, "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "quotes.branch_id"}, "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, From 071448d83e5af7ec85881bc409d02d782309661d Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:30:58 -0700 Subject: [PATCH 41/74] security(keywall): the quote convert rechecks the quote's customer Round 11 P2-1: a key pinned to A converted a quote at branch A whose customer was held only by B, and the created order took B's tax exemption. The conversion is the one path where an existing document becomes a new one, so the quotes mount walls POST /quotes/{id}/convert through the quote's stored customer (quoteRowCustomerOf), as the draft promotion rechecks its payload: a quote a caller moved onto a foreign customer is refused 403 naming customer_id with nothing written. Red first: TestKeyBranchPin_QuoteConvertCustomerWall fails on the pre-wall mount with the order created and B's exemption on it. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 5 +- .../app/serve/wire_customer_branch.go | 29 ++++++++ .../app/serve/wire_key_branch_round5_test.go | 70 +++++++++++++++++++ core/pkg/middleware/keybranch_allow.go | 2 +- 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index a25798b0..1d519000 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -155,7 +155,10 @@ func (w *branchWall) delivery(mux *http.ServeMux, h *delivery.Handler, roles ... // branch. func (w *branchWall) quotes(mux *http.ServeMux, svc *quote.Service) { quote.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, - w.bodyDocumentCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/quotes", "PUT /api/v1/quotes/{id}")) + w.patternWall( + w.bodyDocumentCustomer(w.scoped("admin", "owner", "sales"), "POST /api/v1/quotes", "PUT /api/v1/quotes/{id}"), + w.keyWall.CustomerBranch(quoteRowCustomerOf(w.db)), + "POST /api/v1/quotes/{id}/convert")) } // orders mounts the order routes: the create takes branch_id from its body, diff --git a/core/internal/app/serve/wire_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go index 19db236e..567d5f1d 100644 --- a/core/internal/app/serve/wire_customer_branch.go +++ b/core/internal/app/serve/wire_customer_branch.go @@ -261,6 +261,35 @@ func pricingRuleCustomersOf(db *database.DB) func(ctx context.Context, r *http.R } } +// quoteRowCustomerOf resolves the customer of the quote the path names, so +// the conversion is held to the quote's own customer, rechecked where the +// quote becomes an order (round 11 P2-1: a quote a caller moved onto another +// branch's customer must not convert at the pin's branch). A quote that does +// not exist names no customer and the handler's own 404 answers. +func quoteRowCustomerOf(db *database.DB) func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + return func(ctx context.Context, r *http.Request) ([]uuid.UUID, error) { + if db == nil { + return nil, errors.New("quote customer lookup: no database") + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + return nil, nil // the handler answers the malformed id + } + var customer *uuid.UUID + err = db.GetExecutor(ctx).QueryRow(ctx, `SELECT customer_id FROM quotes WHERE id = $1`, id).Scan(&customer) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if customer == nil { + return nil, nil + } + return []uuid.UUID{*customer}, nil + } +} + // draftPayloadCustomerOf resolves the customer the stored payload of the draft // the path names carries. A draft that does not exist, or whose payload names // no customer, names nothing the wall can hold and the promotion's own answer diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 12a7bddb..59edfc05 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -11,6 +11,7 @@ package serve import ( "context" + "encoding/json" "fmt" "net/http" "strings" @@ -161,6 +162,75 @@ func TestKeyBranchPin_JobOfAnotherCustomer(t *testing.T) { } } +// TestKeyBranchPin_QuoteConvertCustomerWall is round 11 P2-1: a key pinned +// to A converted a quote at branch A whose customer was held only by B, and +// the order took B's tax exemption. The conversion now rechecks the quote's +// stored customer under the customer wall, as the draft promotion rechecks +// its payload: a quote a caller moved onto a foreign customer is refused +// where it would become an order. The A control converts and is taxed at the +// branch rate. +func TestKeyBranchPin_QuoteConvertCustomerWall(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + ordersOn := func(customer uuid.UUID) (n int) { + t.Helper() + if err := f.db.Pool.QueryRow(ctx, + `SELECT COUNT(*) FROM orders WHERE customer_id = $1`, customer).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + quoteFor := func(customer uuid.UUID) uuid.UUID { + t.Helper() + body := fmt.Sprintf(`{"customer_id":%q,"branch_id":%q,"lines":[%s]}`, + customer.String(), f.branchA.String(), f.quoteLine()) + return f.made(t, "user", "quotes", "POST", "/api/v1/quotes", body, http.StatusCreated) + } + convert := func(id uuid.UUID) (int, []byte) { + t.Helper() + rev := f.readJSON(t, "/api/v1/quotes/"+id.String())["revision"] + return f.key(t, f.bound, "POST", "/api/v1/quotes/"+id.String()+"/convert", "", + map[string]string{"If-Match": fmt.Sprintf(`"%v"`, rev)}) + } + + foreign := quoteFor(f.custB) + before := ordersOn(f.custB) + rows := f.auditRows(t) + status, buf := convert(foreign) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("bound key converting the quote moved onto the foreign customer: %d %s, want the pin's 403", status, buf) + } + if !strings.Contains(string(buf), "customer_id") { + t.Errorf("the convert refusal does not name customer_id: %s", buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("convert refusal: key.branch_refused rows %d then %d, want one new row", rows, after) + } + if after := ordersOn(f.custB); after != before { + t.Errorf("the refused convert still wrote %d orders on the foreign customer", after-before) + } + + // The A control: the same quote shape on the pin's own customer converts, + // and its tax is the branch rate, not an exemption. + own := quoteFor(f.custID) + status, buf = convert(own) + if status != http.StatusCreated { + t.Fatalf("bound key converting its own customer's quote: %d %s, want 201", status, buf) + } + var order struct { + ID uuid.UUID `json:"id"` + TaxSource string `json:"tax_source"` + TaxCents int64 `json:"tax_cents"` + } + if err := json.Unmarshal(buf, &order); err != nil { + t.Fatal(err) + } + f.madeRow(t, "orders", order.ID) + if order.TaxSource != "branch_rate" || order.TaxCents != 5 { + t.Errorf("the control convert's tax is %s %d, want the branch rate and 5 cents", order.TaxSource, order.TaxCents) + } +} + // TestKeyBranchPin_PosSyncCustomerWall is round 10 P1-1: the offline sync // replays whole sales whose bodies name a customer, so a key pinned to A // posting a sale on a customer held only by B (its ACCOUNT tender invoicing diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 88050216..bae4245b 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -412,7 +412,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/purchase-orders/{id}/receive": {KeyBranchClassWalled, "purchase_orders.branch_id"}, "POST /api/v1/purchase-orders/{id}/submit": {KeyBranchClassWalled, "purchase_orders.branch_id"}, "POST /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id (the payload branch rule); the body's customer_id and job_id through the customer branch wall (round 9 P1-1, round 11 P1-1)"}, - "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "quotes.branch_id (the convert reads the quote under the wall)"}, + "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "quotes.branch_id (the convert reads the quote under the wall); the quote's stored customer through the customer branch wall, rechecked where it becomes an order (round 11 P2-1); TestKeyBranchPin_QuoteConvertCustomerWall"}, "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "quotes.branch_id"}, "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, From 04258cc2f3e2659437cca1d3a3f36b3c57adbad3 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:32:39 -0700 Subject: [PATCH 42/74] security(customer): a bound key may not add a default ship to on a shared customer Round 11 P2-2 and round 10 P2-1: naming is_default true on a ship-to add clears the customer's existing default, which on a shared customer is a shared ship to every branch's delivery orders default to, so the add changed a shared record through a route the rule classes as adds only. CreateShipTo runs checkSharedChild on the add that names is_default true before ClearDefaultShipTo, refused in the same words naming the other branch (the refusal now also goes through sharedOutcome so its audit row is written); a customer the pin holds alone, a user and a dealer wide key keep the reach. Red first: the SharedCustomerRecord leg fails on 4bad569 with the add 201 and the seeded default cleared. Signed-off-by: colton --- .../app/serve/wire_key_branch_shared_test.go | 35 ++++++++++++++++++- core/internal/customer/service_children.go | 12 +++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index 00cf8c59..105a6563 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -38,7 +38,7 @@ func seedSharedCustomer(t *testing.T, f *keyPinFixture, branches ...uuid.UUID) * for _, b := range branches { exec(`INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2)`, s.id, b) } - exec(`INSERT INTO customer_ship_tos (id, customer_id, code, name, line1) VALUES ($1, $2, 'shared', 'pin shared', 'pin')`, s.shipTo, s.id) + exec(`INSERT INTO customer_ship_tos (id, customer_id, code, name, line1, is_default) VALUES ($1, $2, 'shared', 'pin shared', 'pin', TRUE)`, s.shipTo, s.id) exec(`INSERT INTO customer_contacts (id, customer_id, first_name, last_name) VALUES ($1, $2, 'pin', 'shared')`, s.contact, s.id) exec(`INSERT INTO sales_team (id, name, email, phone, role) VALUES ($1, 'pin rep', $2, '555-0100', 'sales')`, s.salesperson, "pin-rep-"+s.salesperson.String()[:8]+"@example.com") rows, err := f.db.Pool.Query(ctx, `SELECT id FROM payment_terms ORDER BY code LIMIT 2`) @@ -334,6 +334,39 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { if id := f.made(t, "bound", "", "POST", sh+"/contacts", `{"first_name":"pin","last_name":"new"}`, http.StatusCreated); id != uuid.Nil { t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_contacts WHERE id = $1`, id) }) } + // A default add is a change in disguise (round 11 P2-2, round 10 P2-1): + // naming is_default true clears the existing default, which on a shared + // customer is a shared ship to every branch's delivery orders default to. + // The bound key is refused in the same words; the customer the pin holds + // alone still may, and the unbound key and the user keep the reach. + shipDigest := func(customer uuid.UUID) string { + var d string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT md5(string_agg(row_to_json(s)::text, ',' ORDER BY s.id)) FROM customer_ship_tos s WHERE s.customer_id = $1 AND s.is_default`, + customer).Scan(&d); err != nil { + t.Fatal(err) + } + return d + } + defaultAdd := `{"code":"pindef","name":"pin default","line1":"2 pin","is_default":true}` + before, rows := shipDigest(s.id), f.auditRows(t) + if status, buf := f.key(t, f.bound, "POST", sh+"/ship-tos", defaultAdd, nil); status != http.StatusForbidden || + !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + t.Errorf("bound key adding a default ship-to to the shared customer: %d %s, want a 403 naming branch B", status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("bound key default ship-to add: key.branch_refused rows %d then %d, want one new row", rows, after) + } + if shipDigest(s.id) != before { + t.Errorf("the refused default add still changed the shared customer's default ship-to") + } + if id := f.made(t, "bound", "", "POST", alone+"/ship-tos", defaultAdd, http.StatusCreated); id != uuid.Nil { + t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM customer_ship_tos WHERE id = $1`, id) }) + } + if status, buf := f.send(t, "user", "POST", sh+"/ship-tos", + `{"code":"pindefu","name":"pin default user","line1":"2 pin","is_default":true}`, nil); status != http.StatusCreated { + t.Errorf("user adding a default ship-to to the shared customer: %d %s, want 201", status, buf) + } shipPath, contactPath := "/api/v1/ship-tos/"+s.shipTo.String(), "/api/v1/contacts/"+s.contact.String() childRefusals := []struct { label, method, path string diff --git a/core/internal/customer/service_children.go b/core/internal/customer/service_children.go index 88d64d21..7dfe5ee9 100644 --- a/core/internal/customer/service_children.go +++ b/core/internal/customer/service_children.go @@ -200,7 +200,10 @@ func shipToFrom(d *ShipToDraft, id, customerID uuid.UUID, now httpx.Timestamp) * // CreateShipTo adds a ship-to. The customer's first active ship-to becomes // the default unless the request says otherwise; naming a default moves the -// default from the one that held it. +// default from the one that held it. On a shared customer that move changes a +// shared ship to (every branch's delivery orders default to it), so a bound +// key's add that names is_default true is refused in the shared rule's words; +// a customer the pin holds alone still may. func (s *Service) CreateShipTo(ctx context.Context, customerID uuid.UUID, d *ShipToDraft) (*ShipTo, error) { var out *ShipTo err := s.inTx(ctx, func(ctx context.Context) error { @@ -222,6 +225,11 @@ func (s *Service) CreateShipTo(ctx context.Context, customerID uuid.UUID, d *Shi st.IsDefault = n == 0 } if st.IsDefault { + if d.DefaultGiven { + if err := s.checkSharedChild(ctx, customerID, "is_default"); err != nil { + return err + } + } if err := s.repo.ClearDefaultShipTo(ctx, customerID, st.ID); err != nil { return err } @@ -235,7 +243,7 @@ func (s *Service) CreateShipTo(ctx context.Context, customerID uuid.UUID, d *Shi return s.record(ctx, cust, EventUpdated, PartShipTo, map[string]any{"ship_to_id": out.ID, "action": "created"}) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } From 0771eea530e7a778fb423bd343261f16117e6788 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:34:10 -0700 Subject: [PATCH 43/74] security(crm): an activity refuses a contact its customer does not own Round 11 P2-3: an activity of customer A named customer B's contact on the create and the update, for every caller, so one branch's record pointed at another's contact. The service checks the contact against the activity's customer (ContactBelongsTo) and refuses with the order's words, 400 naming contact_id. Red first: TestKeyBranchPin_ActivityContactOwnership fails on 4bad569 with the foreign contact written on the create and the update. Signed-off-by: colton --- .../app/serve/wire_key_branch_round5_test.go | 43 +++++++++++++++++++ core/internal/crm/repository.go | 15 +++++++ core/internal/crm/service.go | 32 +++++++++++++- core/internal/crm/service_test.go | 4 ++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 59edfc05..56d792e3 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -231,6 +231,49 @@ func TestKeyBranchPin_QuoteConvertCustomerWall(t *testing.T) { } } +// TestKeyBranchPin_ActivityContactOwnership is round 11 P2-3: a CRM activity +// of customer A took a contact of customer B, for every caller. The create and +// the update now refuse a contact_id the activity's customer does not own, 400 +// naming contact_id with the order's words, so one branch's record can never +// point at another's contact. +func TestKeyBranchPin_ActivityContactOwnership(t *testing.T) { + f, w, _ := round5Fixture(t) + A, conA, conB := f.custID.String(), w.conA.String(), w.conB.String() + create := func(contact string) string { + return fmt.Sprintf(`{"activity_type":"call","description":"pin","contact_id":%q}`, contact) + } + for _, who := range []string{"bound", "unbound", "user"} { + status, buf := f.send(t, who, "POST", "/api/v1/customers/"+A+"/activities", create(conB), nil) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "contact_id") || + !strings.Contains(string(buf), "is not a contact of this customer") { + t.Errorf("%s activity naming the foreign contact: %d %s, want the 400 naming contact_id", who, status, buf) + } + } + created := f.made(t, "bound", "crm_activities", "POST", "/api/v1/customers/"+A+"/activities", create(conA), http.StatusCreated) + if created == uuid.Nil { + t.Fatal("the A control activity create did not answer its id") + } + put := func(contact string) string { + return fmt.Sprintf(`{"activity_type":"call","description":"pin","contact_id":%q,"revision":1}`, contact) + } + for _, who := range []string{"bound", "unbound", "user"} { + status, buf := f.send(t, who, "PUT", "/api/v1/activities/"+created.String(), put(conB), nil) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "is not a contact of this customer") { + t.Errorf("%s activity update naming the foreign contact: %d %s, want the 400 naming contact_id", who, status, buf) + } + } + f.allow(t, "bound", "PUT", "/api/v1/activities/"+created.String(), put(conA), nil, http.StatusOK) + // No activity of the pin's customer points at the foreign contact. + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM crm_activities WHERE customer_id = $1 AND contact_id = $2`, f.custID, w.conB).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%d activities of the pin's customer name the foreign contact", n) + } +} + // TestKeyBranchPin_PosSyncCustomerWall is round 10 P1-1: the offline sync // replays whole sales whose bodies name a customer, so a key pinned to A // posting a sale on a customer held only by B (its ACCOUNT tender invoicing diff --git a/core/internal/crm/repository.go b/core/internal/crm/repository.go index 1058bde3..8706f25f 100644 --- a/core/internal/crm/repository.go +++ b/core/internal/crm/repository.go @@ -45,6 +45,10 @@ type Repository interface { // branch wall; a create naming an invisible customer is a 404, the same // answer reading that customer gives. CustomerVisible(ctx context.Context, customerID uuid.UUID) (bool, error) + // ContactBelongsTo reports whether the contact belongs to the customer + // (round 11 P2-3): a foreign key alone would let one customer's activity + // point at another's contact. + ContactBelongsTo(ctx context.Context, contactID, customerID uuid.UUID) (bool, error) } // PostgresRepository implements Repository against Postgres. @@ -228,6 +232,17 @@ func (r *PostgresRepository) Delete(ctx context.Context, id uuid.UUID) error { return nil } +func (r *PostgresRepository) ContactBelongsTo(ctx context.Context, contactID, customerID uuid.UUID) (bool, error) { + var ok bool + err := r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM customer_contacts WHERE id = $1 AND customer_id = $2)`, + contactID, customerID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("failed to check the contact: %w", err) + } + return ok, nil +} + func (r *PostgresRepository) CustomerVisible(ctx context.Context, customerID uuid.UUID) (bool, error) { var ok bool err := r.db.GetExecutor(ctx).QueryRow(ctx, ` diff --git a/core/internal/crm/service.go b/core/internal/crm/service.go index 80c324cf..54867cd9 100644 --- a/core/internal/crm/service.go +++ b/core/internal/crm/service.go @@ -93,9 +93,33 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Activity, error) { return a, nil } +// notOwnedContact is the 400 an activity write gets for a contact its +// customer does not own (round 11 P2-3), the order's words for the same +// shape. +func notOwnedContact() error { + return httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "contact_id", Message: "is not a contact of this customer"}) +} + +// contactOwned refuses a contact the customer does not own; nil names none. +func (s *Service) contactOwned(ctx context.Context, customerID uuid.UUID, contact *uuid.UUID) error { + if contact == nil { + return nil + } + ok, err := s.repo.ContactBelongsTo(ctx, *contact, customerID) + if err != nil { + return err + } + if !ok { + return notOwnedContact() + } + return nil +} + // Create logs one activity for the customer the path names. The customer is // checked behind the caller's branch wall first, so a create on a customer -// the caller cannot see is the same 404 as reading it. +// the caller cannot see is the same 404 as reading it, and the contact must +// be the customer's own. func (s *Service) Create(ctx context.Context, customerID uuid.UUID, d *Draft) (*Activity, error) { var out *Activity err := s.inTx(ctx, func(ctx context.Context) error { @@ -106,6 +130,9 @@ func (s *Service) Create(ctx context.Context, customerID uuid.UUID, d *Draft) (* if !visible { return httpx.NotFound("customer not found") } + if err := s.contactOwned(ctx, customerID, d.ContactID); err != nil { + return err + } now := httpx.TimestampOf(s.now().UTC()) a := &Activity{ ID: uuid.New(), CustomerID: customerID, ContactID: d.ContactID, @@ -152,6 +179,9 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, d *Draft, pre Precon if err := httpx.CheckRevision(cur.Revision, pre.IfMatch, pre.Revision); err != nil { return err } + if err := s.contactOwned(ctx, cur.CustomerID, d.ContactID); err != nil { + return err + } next := *cur next.ContactID, next.ActivityType = d.ContactID, d.ActivityType next.Description, next.LoggedBy = d.Description, d.LoggedBy diff --git a/core/internal/crm/service_test.go b/core/internal/crm/service_test.go index 66880590..1b16e348 100644 --- a/core/internal/crm/service_test.go +++ b/core/internal/crm/service_test.go @@ -32,6 +32,10 @@ type fakeRepo struct { func newFakeRepo() *fakeRepo { return &fakeRepo{visible: true, stored: map[uuid.UUID]*Activity{}} } +func (f *fakeRepo) ContactBelongsTo(_ context.Context, _, _ uuid.UUID) (bool, error) { + return true, nil +} + func (f *fakeRepo) note(call string) { f.calls = append(f.calls, call) if f.orderRef != nil { From 1a168f06d9c5f4af04eb1ec3039729b74d0d48b0 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:43:59 -0700 Subject: [PATCH 44/74] security(keys): customers:restricted, the shared customer restricted field scope Principal ruling item 48: a shared customer's restricted fields change only with the finance or owner role (a user) or an unbound machine key carrying a dedicated customers:restricted scope. The scope joins the grant grammar (ValidScopeGrammar, of no route, so no module scope ever grants it and no default grant carries it); the mint grants it only for an owner (the admin role is refused, fail closed without claims) and the mint's key.created audit row names it. SharedCustomer answers a fourth verdict, RefuseCaller: a user without the finance or owner role and an unbound key without the scope are refused in the PD-4 words naming the lowest numbered branch holding the customer; a bound key is refused even with the scope; the scoped key's change keeps its customer.shared_restricted_changed audit row. The customer routes' guard admits finance so the ruling's role can reach the fields. Red first: TestKeyBranchPin_SharedRestrictedScope fails on 4bad569 with the mint 400 and every caller change open. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 14 ++- .../app/serve/wire_key_branch_pin_test.go | 54 +++++++- .../app/serve/wire_key_branch_round4_test.go | 4 + .../app/serve/wire_key_branch_round5_test.go | 115 ++++++++++++++++++ .../app/serve/wire_key_branch_shared_test.go | 37 ++++-- .../customer/customerguard/customerguard.go | 2 +- core/internal/customer/shared.go | 31 ++++- core/internal/drafts/migration_test.go | 9 +- core/internal/techadmin/handler.go | 36 ++++++ core/pkg/middleware/keybranch_shared.go | 92 ++++++++++++-- core/pkg/middleware/machinekey.go | 17 ++- 11 files changed, 366 insertions(+), 45 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 1d519000..ab93efc7 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -115,15 +115,17 @@ func (w *branchWall) inventory(mux *http.ServeMux, svc *inventory.Service) { // customers mounts the customer routes: the create takes primary_branch_id from // its body, so it is held to the caller's branch context; payment terms writes // take the narrower finance guard. The service also carries the shared -// customer guard (principal rulings 43 to 45): a key bound to one branch -// changes only the branch local fields of a customer another branch holds, -// and adds but never changes or deletes its ship-tos and contacts. The routes -// note their method and path so a refusal the service serves can name them in -// its key.branch_refused row. +// customer guard (principal rulings 43 to 45 and item 48): a key bound to one +// branch changes only the branch local fields of a customer another branch +// holds, and adds but never changes or deletes its ship-tos and contacts; a +// shared customer's restricted fields change only for the finance or owner +// role (a user) or an unbound key carrying customers:restricted, which is why +// the guard admits finance here. The routes note their method and path so a +// refusal the service serves can name them in its key.branch_refused row. func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { svc.WithBranchGuard(customerguard.New(w.guard)).WithSharedGuard(customerguard.NewShared(w.keyWall)) customer.NewHandler(svc).RegisterRoutes(mux, - middleware.Compose(w.scoped("admin", "owner", "sales"), w.keyWall.RecordRequest()), + middleware.Compose(w.scoped("admin", "owner", "sales", "finance"), w.keyWall.RecordRequest()), w.scoped("admin", "owner", "finance")) } diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 1a0ac850..f47a750f 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -124,6 +124,25 @@ type keyPinFixture struct { bound2KeyID string // the second bound key's id boundFirst string // the first mint's bound key, kept when a test swaps in the second recording bool // mark every bound key call for the walled route coverage test + keys *techadmin.Service // the mint the fixture's keys validate against +} + +// mintWithScopes mints a key with the scopes given, through the same service +// the fixture's machine key core validates against, and removes it when the +// test ends. +func (f *keyPinFixture) mintWithScopes(t *testing.T, name string, scopes []string, branch *uuid.UUID) string { + t.Helper() + raw, key, err := f.keys.GenerateKey(context.Background(), name, scopes, branch) + if err != nil { + t.Fatalf("mint %s: %v", name, err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'api_key' AND entity_id = $1::text`, key.ID) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM audit_log WHERE action = 'key.created' AND entity_id = $1`, key.ID) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM api_keys WHERE id = $1`, key.ID) + }) + return raw } // pinKeyScopes opens every route under test: the location writes, the branch @@ -508,7 +527,9 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { if derr != nil { t.Fatalf("draft kind registration: %v", derr) } - keys := techadmin.NewService(techadmin.NewRepository(db)).WithTxRunner(db) + keys := techadmin.NewService(techadmin.NewRepository(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAuditLog(audit.NewLogger(db)) + f.keys = keys draftsRepo := drafts.NewRepository(db) draftHub := drafts.NewHub(draftsRepo, 100*time.Millisecond, slog.Default()) t.Cleanup(draftHub.Stop) @@ -599,8 +620,11 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Fatalf("mint %s: %v", name, err) } // A bound key references its branch, so the fixture's branch rows can - // only be removed once the keys it minted are gone. + // only be removed once the keys it minted are gone, with the mint's + // audit row and outbox event. t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'api_key' AND entity_id = $1::text`, key.ID) + _, _ = db.Pool.Exec(ctx, `DELETE FROM audit_log WHERE action = 'key.created' AND entity_id = $1`, key.ID) _, _ = db.Pool.Exec(ctx, `DELETE FROM api_keys WHERE id = $1`, key.ID) }) return raw, key.ID.String() @@ -728,6 +752,32 @@ func (f *keyPinFixture) key(t *testing.T, raw, method, path, body string, hdr ma return res.StatusCode, buf } +// userAs sends one request as the claims a JWT would carry, with the role +// given (the role decides the shared customer restricted field rule). +func (f *keyPinFixture) userAs(t *testing.T, role, method, path, body string, hdr map[string]string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-Role", role) + req.Header.Set("X-Test-Sub", "pin-"+role) + for k, v := range hdr { + req.Header.Set(k, v) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + buf, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return res.StatusCode, buf +} + // user sends one request as the claims a JWT would carry. func (f *keyPinFixture) user(t *testing.T, method, path, body string, hdr map[string]string) (int, []byte) { t.Helper() diff --git a/core/internal/app/serve/wire_key_branch_round4_test.go b/core/internal/app/serve/wire_key_branch_round4_test.go index b9b2b439..22e2b939 100644 --- a/core/internal/app/serve/wire_key_branch_round4_test.go +++ b/core/internal/app/serve/wire_key_branch_round4_test.go @@ -72,6 +72,10 @@ func (f *keyPinFixture) send(t *testing.T, who, method, path, body string, hdr m return f.key(t, f.bound, method, path, body, hdr) case "unbound": return f.key(t, f.unbound, method, path, body, hdr) + case "finance": + return f.userAs(t, "finance", method, path, body, hdr) + case "sales": + return f.userAs(t, "sales", method, path, body, hdr) } return f.user(t, method, path, body, hdr) } diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 56d792e3..1b948c01 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -274,6 +274,121 @@ func TestKeyBranchPin_ActivityContactOwnership(t *testing.T) { } } +// TestKeyBranchPin_SharedRestrictedScope is the principal's item 48 ruling: a +// shared customer's restricted fields change only with the finance or owner +// role (a user) or an unbound machine key carrying the dedicated +// customers:restricted scope, minted only by an owner and audited when it is +// minted. A sales user, an admin user and every unbound key without the scope +// are refused in the same words naming the other branch; a branch bound key is +// refused even with the scope; the allowed key's change keeps its +// customer.shared_restricted_changed audit row. +func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { + f, _, _ := round5Fixture(t) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + sh := "/api/v1/customers/" + s.id.String() + change := func() string { + return f.customerReplace(t, s.id, map[string]any{"credit_limit_cents": 777000 + int(time.Now().UnixNano()%1000)}) + } + + // The mint: only an owner may grant the scope, and the mint is audited. + mintBody := `{"name":"pin restricted","scopes":["customers:write","customers:restricted"]}` + if status, buf := f.userAs(t, "admin", "POST", "/api/v1/admin/keys", mintBody, nil); status != http.StatusForbidden { + t.Errorf("admin user minting the restricted scope: %d %s, want 403 (only an owner may grant it)", status, buf) + } + var minted struct { + APIKey string `json:"api_key"` + Key struct { + ID string `json:"id"` + } `json:"key"` + } + status, buf := f.userAs(t, "owner", "POST", "/api/v1/admin/keys", mintBody, nil) + if status != http.StatusCreated { + t.Fatalf("owner minting the restricted scope: %d %s, want 201", status, buf) + } + if err := json.Unmarshal(buf, &minted); err != nil || minted.APIKey == "" { + t.Fatalf("the mint answer carries no raw key: %s", buf) + } + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM audit_log WHERE entity_id::text = $1`, minted.Key.ID) + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM api_keys WHERE id::text = $1`, minted.Key.ID) + }) + var mintAudit int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'key.created' AND entity_id::text = $1 AND changes->'scopes' ? 'customers:restricted'`, + minted.Key.ID).Scan(&mintAudit); err != nil { + t.Fatal(err) + } + if mintAudit != 1 { + t.Errorf("the restricted scope mint left %d key.created audit rows naming it, want one", mintAudit) + } + + // The bound key with the scope is still refused (the pin rule comes first). + if status, buf := f.key(t, f.bound, "PUT", sh, change(), nil); status != http.StatusForbidden || + !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("bound key changing a restricted field: %d %s, want the pin's 403", status, buf) + } + + // An unbound key without the scope is refused in the caller words; with + // the scope it changes the field and the change is audited. + // The caller refusals name the lowest numbered branch holding the + // customer (a caller without a pin has no "other" to exclude). + lowest := f.branchA + if f.branchB.String() < f.branchA.String() { + lowest = f.branchB + } + callerRefusal := "the customer is also held by branch " + lowest.String() + if status, buf := f.key(t, f.unbound, "PUT", sh, change(), nil); status != http.StatusForbidden || + !strings.Contains(string(buf), callerRefusal) { + t.Errorf("unbound key without the scope changing a restricted field: %d %s, want the 403 naming branch B", status, buf) + } + before := len(f.sharedChangeRows(t, s.id)) + if status, buf := f.key(t, minted.APIKey, "PUT", sh, change(), nil); status != http.StatusOK { + t.Errorf("unbound key with the scope changing a restricted field: %d %s, want 200", status, buf) + } + if rows := f.sharedChangeRows(t, s.id); len(rows) != before+1 { + t.Errorf("the scoped key's change left %d audit rows, want one new row", len(rows)-before) + } + + // The user legs: sales and admin are refused in the same words, finance + // and owner change the field (no audit row for a user). + for _, role := range []string{"sales", "admin"} { + if status, buf := f.userAs(t, role, "PUT", sh, change(), nil); status != http.StatusForbidden || + !strings.Contains(string(buf), callerRefusal) { + t.Errorf("%s user changing a restricted field: %d %s, want the 403 naming branch B", role, status, buf) + } + } + rowsBefore := len(f.sharedChangeRows(t, s.id)) + for _, role := range []string{"finance", "owner"} { + if status, buf := f.userAs(t, role, "PUT", sh, change(), nil); status != http.StatusOK { + t.Errorf("%s user changing a restricted field: %d %s, want 200", role, status, buf) + } + } + if rows := len(f.sharedChangeRows(t, s.id)); rows != rowsBefore { + t.Errorf("the users' changes left %d shared customer audit rows, want none", rows-rowsBefore) + } + + // The scope is never a default grant: a mint that names no scopes mints a + // key with none, and no route requires the scope. + plain := `{"name":"pin plain"}` + status, buf = f.userAs(t, "owner", "POST", "/api/v1/admin/keys", plain, nil) + if status != http.StatusCreated { + t.Fatalf("owner minting a plain key: %d %s, want 201", status, buf) + } + var plainKey struct { + Key struct { + ID string `json:"id"` + Scopes []string `json:"scopes"` + } `json:"key"` + } + if err := json.Unmarshal(buf, &plainKey); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM api_keys WHERE id::text = $1`, plainKey.Key.ID) }) + if len(plainKey.Key.Scopes) != 0 { + t.Errorf("a mint naming no scopes granted %v, want none", plainKey.Key.Scopes) + } +} + // TestKeyBranchPin_PosSyncCustomerWall is round 10 P1-1: the offline sync // replays whole sales whose bodies name a customer, so a key pinned to A // posting a sale on a customer held only by B (its ACCOUNT tender invoicing diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index 105a6563..f7bd2283 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) @@ -128,15 +129,23 @@ func (f *keyPinFixture) sharedChangeRows(t *testing.T, customer uuid.UUID) []map return out } -// TestKeyBranchPin_SharedCustomerRecord holds principal rulings 43 to 45: on -// a customer held by the pin and another branch, a key bound to the pin reads -// everything and changes only the branch local fields (email, phone), every -// other field of the record being restricted by default; it may add a ship-to -// or a contact but change or delete none. A customer the pin holds alone -// allows all of it. The unbound key changes the restricted fields, each change -// audited with the actor and the before and after values; a user is unchanged. +// TestKeyBranchPin_SharedCustomerRecord holds principal rulings 43 to 45 and +// item 48: on a customer held by the pin and another branch, a key bound to +// the pin reads everything and changes only the branch local fields (email, +// phone), every other field of the record being restricted by default; it may +// add a ship-to or a contact but change or delete none (a default add is a +// change). A customer the pin holds alone allows all of it. An unbound key +// carrying the customers:restricted scope changes the restricted fields, each +// change audited with the actor and the before and after values; a finance +// user changes them with no audit row (the sales and admin refusals and the +// scope legs are TestKeyBranchPin_SharedRestrictedScope). func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { f, _ := round4Fixture(t) + // The item 48 ruling: the dealer wide key that changes a shared + // customer's restricted fields carries the dedicated scope; the unbound + // key without it is refused (TestKeyBranchPin_SharedRestrictedScope). + f.unbound = f.mintWithScopes(t, "pin scoped restricted", []string{"customers:read", "customers:write", + "ship-tos:read", "ship-tos:write", "contacts:read", "contacts:write", middleware.CustomerRestrictedScope}, nil) s := seedSharedCustomer(t, f, f.branchA, f.branchB) own := seedSharedCustomer(t, f, f.branchA) sh := "/api/v1/customers/" + s.id.String() @@ -273,9 +282,9 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { } } - // The dealer wide key and the user change the shared customer's - // restricted fields; only the key leaves the audit row, naming itself - // and the before and after value. + // The scoped dealer wide key and the finance user change the shared + // customer's restricted fields; only the key leaves the audit row, naming + // itself and the before and after value. for _, fld := range fields { before := len(f.sharedChangeRows(t, s.id)) if status, buf := fld.do("unbound", sh); status != http.StatusOK { @@ -300,14 +309,16 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { t.Errorf("unbound key changing %s: the audit row has no after value: %v", fld.name, change) } } + // A finance user changes the fields with no audit row (item 48); the + // sales and admin refusals are the scope test's legs. rowsBefore := len(f.sharedChangeRows(t, s.id)) for _, fld := range fields { - if status, buf := fld.do("user", sh); status != http.StatusOK { - t.Errorf("user changing %s of the shared customer: %d %s, want 200", fld.name, status, buf) + if status, buf := fld.do("finance", sh); status != http.StatusOK { + t.Errorf("finance user changing %s of the shared customer: %d %s, want 200", fld.name, status, buf) } } if rows := len(f.sharedChangeRows(t, s.id)); rows != rowsBefore { - t.Errorf("the user's changes left %d shared customer audit rows, want none", rows-rowsBefore) + t.Errorf("the finance user's changes left %d shared customer audit rows, want none", rows-rowsBefore) } // Ship-tos and contacts: every existing one of a shared customer is diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index 04e2f8cf..6dd8da43 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -43,7 +43,7 @@ func (a *Shared) Shared(ctx context.Context, customerID uuid.UUID) (customer.Sha if err != nil { return customer.SharedVerdict{}, err } - return customer.SharedVerdict{Refuse: v.Refuse, Branch: v.Branch, Audit: v.Audit}, nil + return customer.SharedVerdict{Refuse: v.Refuse, Branch: v.Branch, RefuseCaller: v.RefuseCaller, Audit: v.Audit}, nil } // RecordRefusal writes the refusal's key.branch_refused row. diff --git a/core/internal/customer/shared.go b/core/internal/customer/shared.go index 8f0629d7..7a79f606 100644 --- a/core/internal/customer/shared.go +++ b/core/internal/customer/shared.go @@ -48,7 +48,11 @@ func restrictedFields(changed []string) []string { type SharedVerdict struct { Refuse bool // a branch bound key, and another branch holds the customer Branch uuid.UUID // the other branch the refusal names - Audit bool // a dealer wide key, and several branches hold the customer + // RefuseCaller: a user without the finance or owner role, or an unbound + // key without the customers:restricted scope, and the customer is shared + // (principal ruling item 48). + RefuseCaller bool + Audit bool // a scoped dealer wide key, and several branches hold the customer } // SharedGuard decides how the caller stands to a shared customer. The @@ -78,13 +82,25 @@ type sharedRefusal struct{ err *httpx.Error } func (e *sharedRefusal) Error() string { return e.err.Message } func refusedOn(branch uuid.UUID, fields ...string) *sharedRefusal { + return refusedSharing("a branch bound key may act only on its own branch", branch, fields...) +} + +// refusedCallerOn is the refusal a caller without the finance or owner role +// (a user) or without the customers:restricted scope (an unbound key) gets on +// a shared customer's restricted fields: the same words, naming a branch that +// holds the customer (principal ruling item 48). +func refusedCallerOn(branch uuid.UUID, fields ...string) *sharedRefusal { + return refusedSharing("a shared customer's restricted fields change only with the finance or owner role, or an unbound key carrying customers:restricted", branch, fields...) +} + +func refusedSharing(rule string, branch uuid.UUID, fields ...string) *sharedRefusal { sort.Strings(fields) details := make([]httpx.FieldError, 0, len(fields)) for _, f := range fields { details = append(details, httpx.FieldError{Field: f, Code: httpx.CodeForbidden, Message: "shared with another branch"}) } return &sharedRefusal{err: &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, - Message: "a branch bound key may act only on its own branch: the customer is also held by branch " + branch.String(), + Message: rule + ": the customer is also held by branch " + branch.String(), Details: details}} } @@ -113,13 +129,16 @@ func (s *Service) checkRestricted(ctx context.Context, id uuid.UUID, changes map if err != nil { return err } + fields := make([]string, 0, len(changes)) + for f := range changes { + fields = append(fields, f) + } if verdict.Refuse { - fields := make([]string, 0, len(changes)) - for f := range changes { - fields = append(fields, f) - } return refusedOn(verdict.Branch, fields...) } + if verdict.RefuseCaller { + return refusedCallerOn(verdict.Branch, fields...) + } if verdict.Audit { return s.shared.RecordChange(ctx, id, changes) } diff --git a/core/internal/drafts/migration_test.go b/core/internal/drafts/migration_test.go index b4fb88d6..5cdf6a58 100644 --- a/core/internal/drafts/migration_test.go +++ b/core/internal/drafts/migration_test.go @@ -232,6 +232,13 @@ func TestMigration103_ReportMatchesTheGoGrammar(t *testing.T) { if strings.HasSuffix(scope, ":propose") || strings.HasSuffix(scope, ":commit") { continue } + // The customers:restricted capability scope (ruling item 48) joined + // the Go grammar after migration 103 froze its SQL side, so 103's + // report still names a key carrying it; it rides with the reported + // side below, keeping the pin exact for 103's own list. + if scope == middleware.CustomerRestrictedScope { + continue + } scopes = append(scopes, scope) } sort.Strings(scopes) @@ -243,7 +250,7 @@ func TestMigration103_ReportMatchesTheGoGrammar(t *testing.T) { for i, scope := range scopes { rows = append(rows, fmt.Sprintf(`(gen_random_uuid(), 'grammar %d', 'x$y', 'g_%03d', '{"%s"}')`, i, i, scope)) } - offGrammar := []string{"deposits:read", "deposits:write", "users:write"} + offGrammar := []string{"deposits:read", "deposits:write", "users:write", middleware.CustomerRestrictedScope} for i, scope := range offGrammar { rows = append(rows, fmt.Sprintf(`(gen_random_uuid(), 'off grammar %d', 'x$y', 'x_%03d', '{"%s"}')`, i, i, scope)) } diff --git a/core/internal/techadmin/handler.go b/core/internal/techadmin/handler.go index c921c5d0..b68a6b7f 100644 --- a/core/internal/techadmin/handler.go +++ b/core/internal/techadmin/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) @@ -77,6 +78,37 @@ func notFound(err error) error { return err } +// ownerGrantsRestricted refuses a mint carrying the customers:restricted +// capability scope unless the caller is an owner (principal ruling item 48): +// the admin role mints every other scope, but the admission to a shared +// customer's restricted fields is an owner's deliberate act, audited by the +// mint's own key.created row. Fail closed: no claims, no grant. +func ownerGrantsRestricted(r *http.Request, scopes []string) error { + grants := false + for _, scope := range scopes { + if scope == middleware.CustomerRestrictedScope { + grants = true + break + } + } + if !grants { + return nil + } + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + return httpx.Forbidden("the customers:restricted scope is granted only by an owner") + } + if claims.Role == "owner" { + return nil + } + for _, role := range claims.Roles { + if role == "owner" { + return nil + } + } + return httpx.Forbidden("the customers:restricted scope is granted only by an owner") +} + func (h *Handler) CreateKey(w http.ResponseWriter, r *http.Request) { if err := noQuery(r); err != nil { httpx.WriteError(w, r, err) @@ -92,6 +124,10 @@ func (h *Handler) CreateKey(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } + if err := ownerGrantsRestricted(r, scopes); err != nil { + httpx.WriteError(w, r, err) + return + } raw, key, err := h.service.GenerateKey(r.Context(), name, scopes, branch) if err != nil { httpx.WriteError(w, r, err) diff --git a/core/pkg/middleware/keybranch_shared.go b/core/pkg/middleware/keybranch_shared.go index 8805556c..429c5cc6 100644 --- a/core/pkg/middleware/keybranch_shared.go +++ b/core/pkg/middleware/keybranch_shared.go @@ -40,12 +40,18 @@ func (w *KeyBranchWall) RecordRequest() func(http.Handler) http.Handler { // SharedVerdict is the wall's answer for one customer and one caller. type SharedVerdict struct { // Refuse is set for a branch bound key when another branch holds the - // customer too; Branch is the lowest numbered such branch. + // customer too; Branch is the lowest numbered such branch. The pin rule + // comes first: a bound key is refused even when it carries the dedicated + // scope. Refuse bool Branch uuid.UUID - // Audit is set for an unbound key when two or more branches hold the - // customer: its change goes through, audited with the actor and the - // before and after values. + // RefuseCaller is set for a user without the finance or owner role and + // for an unbound key without the customers:restricted scope, on a + // customer another branch also holds (principal ruling item 48). + RefuseCaller bool + // Audit is set for an unbound key carrying the dedicated scope when two + // or more branches hold the customer: its change goes through, audited + // with the actor and the before and after values. Audit bool } @@ -55,13 +61,47 @@ type SharedAuditor interface { AuditSharedCustomerChange(ctx context.Context, keyID string, customer uuid.UUID, changes map[string]any) error } +// callerHasRole reports whether the user claims hold one of the roles. +func callerHasRole(claims *UserClaims, roles ...string) bool { + if claims == nil { + return false + } + for _, role := range roles { + if claims.Role == role { + return true + } + for _, r := range claims.Roles { + if r == role { + return true + } + } + } + return false +} + // SharedCustomer answers how the caller stands to a customer's restricted -// fields. A user passes unchanged (the zero verdict); a branch bound key is -// refused when any branch besides the pin holds the customer; an unbound key -// is audited when more than one branch holds it. +// fields (principal rulings 43 to 45 and item 48). A user passes with the +// finance or owner role and is refused without it, fail closed when no +// claims at all arrive; an unshared customer stays open to every caller. A +// branch bound key is refused when any branch besides the pin holds the +// customer, whatever its scopes. An unbound key is admitted only while it +// carries the customers:restricted scope, and its change of a shared +// customer is audited. The caller refusals name the lowest numbered branch +// holding the customer; the bound key's names the lowest numbered branch +// that is not the pin. func (w *KeyBranchWall) SharedCustomer(ctx context.Context, customer uuid.UUID) (SharedVerdict, error) { if _, isKey := KeyIDFromContext(ctx); !isKey { - return SharedVerdict{}, nil + if callerHasRole(ClaimsFromContext(ctx), "finance", "owner") { + return SharedVerdict{}, nil + } + other, holders, err := w.sharedHolders(ctx, customer) + if err != nil { + return SharedVerdict{}, err + } + if holders < 2 { + return SharedVerdict{}, nil + } + return SharedVerdict{RefuseCaller: true, Branch: other}, nil } if w.db == nil { return SharedVerdict{}, errors.New("key branch wall: no database for the shared customer lookup") @@ -80,12 +120,38 @@ func (w *KeyBranchWall) SharedCustomer(ctx context.Context, customer uuid.UUID) } return SharedVerdict{Refuse: true, Branch: other}, nil } - var branches int - if err := w.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT COUNT(*) FROM customer_branches WHERE customer_id = $1`, customer).Scan(&branches); err != nil { - return SharedVerdict{}, fmt.Errorf("key branch wall: shared customer lookup: %w", err) + other, holders, err := w.sharedHolders(ctx, customer) + if err != nil { + return SharedVerdict{}, err + } + if holders < 2 { + return SharedVerdict{}, nil + } + scopes, _ := KeyScopesFromContext(ctx) + if !scopeHeld(scopes, CustomerRestrictedScope) { + return SharedVerdict{RefuseCaller: true, Branch: other}, nil + } + return SharedVerdict{Audit: true, Branch: other}, nil +} + +// sharedHolders answers the lowest numbered branch holding the customer and +// how many branches hold it at all. +func (w *KeyBranchWall) sharedHolders(ctx context.Context, customer uuid.UUID) (uuid.UUID, int, error) { + if w.db == nil { + return uuid.Nil, 0, errors.New("key branch wall: no database for the shared customer lookup") + } + var other *uuid.UUID + var holders int + err := w.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT (SELECT branch_id FROM customer_branches WHERE customer_id = $1 ORDER BY branch_id LIMIT 1), COUNT(*) + FROM customer_branches WHERE customer_id = $1`, customer).Scan(&other, &holders) + if err != nil { + return uuid.Nil, 0, fmt.Errorf("key branch wall: shared customer lookup: %w", err) + } + if other != nil { + return *other, holders, nil } - return SharedVerdict{Audit: branches > 1}, nil + return uuid.Nil, holders, nil } // RecordSharedRefusal writes the key.branch_refused row of a refusal the diff --git a/core/pkg/middleware/machinekey.go b/core/pkg/middleware/machinekey.go index db4fee65..41f748f6 100644 --- a/core/pkg/middleware/machinekey.go +++ b/core/pkg/middleware/machinekey.go @@ -485,13 +485,23 @@ func AdmittedScopes(module string, class ScopeClass) []string { return nil } +// CustomerRestrictedScope is the customers module's capability scope +// (principal ruling item 48): no route requires it, so holding the module's +// read and write scopes never grants it and no default grant carries it; it +// is minted on purpose, by an owner only, and the shared customer rule reads +// it to admit an unbound key's change of a shared customer's restricted +// fields. +const CustomerRestrictedScope = "customers:restricted" + // ValidScopeGrammar returns every scope a registered /api/v1 route can // require: the module read and write scopes, with the finer names (admin // areas, users:grants) in place of the coarse ones they replace, and the // propose and commit verbs of the confirm gated modules (ADR 0007 section -// 5.1). The mint's validation (section 5.3) checks granted scopes against -// this set; it is the one source, so the mint and the auth core cannot -// disagree. +// 5.1). The capability scope CustomerRestrictedScope rides with them: it is +// grantable on purpose though no route requires it, so the mint and the +// shared customer rule share the one source. The mint's validation (section +// 5.3) checks granted scopes against this set; it is the one source, so the +// mint and the auth core cannot disagree. func ValidScopeGrammar() []string { var out []string for module := range machineKeyModules { @@ -506,6 +516,7 @@ func ValidScopeGrammar() []string { out = append(out, module+scopeProposeSuffix, module+scopeCommitSuffix) } out = append(out, FinerAdminScopes()...) + out = append(out, CustomerRestrictedScope) sort.Strings(out) return out } From c726f95cfadc4714897512e32ec9ac0d0b22d3c9 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:57:26 -0700 Subject: [PATCH 45/74] fix(pricing): a legacy single target rule update answers the invariant, not a 500 Round 11 P3-1: a PUT of the legacy tier row that also names a customer (the shape migration 107 leaves NOT VALID) revalidated the constraint on the write and answered 500 for the unbound key and the user. UpdateCategoryRule refuses such a row with a 409 naming the single target invariant before the write, for every caller; the bound key is refused at the pin first. Red first: the LegacyTierRowStaysDealerWide PUT legs fail on the unfixed code with the 500. Signed-off-by: colton --- .../app/serve/wire_key_branch_tier_test.go | 16 ++++++++++++++++ core/internal/pricing/category_service.go | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/core/internal/app/serve/wire_key_branch_tier_test.go b/core/internal/app/serve/wire_key_branch_tier_test.go index 87b05464..ae0d8d8d 100644 --- a/core/internal/app/serve/wire_key_branch_tier_test.go +++ b/core/internal/app/serve/wire_key_branch_tier_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "strings" "testing" "github.com/google/uuid" @@ -62,6 +63,21 @@ func TestKeyBranchPin_LegacyTierRowStaysDealerWide(t *testing.T) { if !exists(legacy) { t.Fatal("the pinned key deleted the legacy tier row") } + // An update of the legacy row answers the invariant's own refusal for + // every caller (round 11 P3-1: the NOT VALID constraint revalidates on + // the write, which used to surface as a 500), while the bound key is + // refused at the pin first. + putBody := `{"rule_type":"markup","value_pct":"12"}` + f.refuseForeign(t, "PUT", "/api/v1/pricing/category-rules/"+legacy.String(), putBody, rev(legacy)) + for _, who := range []string{"unbound", "user"} { + status, buf := f.send(t, who, "PUT", "/api/v1/pricing/category-rules/"+legacy.String(), putBody, rev(legacy)) + if status != http.StatusConflict || !strings.Contains(string(buf), "single target") { + t.Errorf("%s updating the legacy tier row: %d %s, want the 409 naming the single target invariant", who, status, buf) + } + } + if !exists(legacy) { + t.Fatal("the legacy tier row was updated away") + } // The unbound key keeps the delete, single and bulk, each on a fresh row. f.allow(t, "unbound", "DELETE", "/api/v1/pricing/category-rules/"+legacy.String(), "", rev(legacy), http.StatusNoContent) again := seed() diff --git a/core/internal/pricing/category_service.go b/core/internal/pricing/category_service.go index 34fca546..995d4289 100644 --- a/core/internal/pricing/category_service.go +++ b/core/internal/pricing/category_service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math/big" + "net/http" "sort" "strings" "time" @@ -322,6 +323,18 @@ func (s *CategoryPricingService) UpdateCategoryRule(ctx context.Context, r *Cate if err := httpx.CheckRevision(old.Revision, pre.IfMatch, pre.Revision); err != nil { return err } + // A legacy row that breaks migration 107's single target invariant + // (the constraint the migration leaves NOT VALID on a database that + // carries one) cannot pass through an update: the write revalidates + // the row, which the database would answer as an internal error. The + // refusal names the invariant for every caller (round 11 P3-1). + if (old.TargetType == TargetTypeTier && old.CustomerID != nil) || + (old.TargetType == TargetTypeAccount && old.Tier != "") { + return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, + Message: "the rule names both a tier and a customer, which the single target check refuses; reconcile the row before editing it", + Details: []httpx.FieldError{httpx.Blocker("single_target", + "an account rule carries no tier, a tier rule no customer")}} + } r.TargetType, r.CustomerID, r.Tier, r.CategoryID = old.TargetType, old.CustomerID, old.Tier, old.CategoryID if err := s.catRepo.UpdateCategoryRule(ctx, r, old.Revision); err != nil { return resolveRuleWrite(err) From 5d800ed5ed58031a16f5c9835a273e27f55c2237 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:57:26 -0700 Subject: [PATCH 46/74] fix(payments): the intent route answers its own 400 for a body naming a customer Round 11 P3-2: the body customer wall held POST /payments/intent too, so a bound key naming any customer in a field the route does not accept got the pin's 403 while every other caller got the route's own 400 unknown field. The intent carries no customer, so its pattern leaves the body customer wall and the route's own 400 answers every caller alike. Red first: the BodyCustomerWall intent leg fails on the walled code with the 403. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 11 +++-- .../app/serve/wire_key_branch_round4_test.go | 42 +++++++++++++++---- core/pkg/middleware/keybranch_allow.go | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index ab93efc7..93162bf0 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -208,13 +208,16 @@ func (w *branchWall) accounts(mux *http.ServeMux, svc *account.Service) { // payments mounts the payment routes: the create takes branch_id from its body // and the writes address a payment by path id, so the service holds both to the // caller's branch context (ADR 0007 section 2.3) beside the wall every read -// carries. The cash, card and intent creates take customer_id from their body, -// so a branch bound key reaches them only while the pin holds that customer -// (round 9 P1-1: a posted payment and its GL entry land on that customer). +// carries. The cash and card creates take customer_id from their body, so a +// branch bound key reaches them only while the pin holds that customer +// (round 9 P1-1: a posted payment and its GL entry land on that customer). The +// intent create takes no customer at all, so it keeps the guard alone and its +// own 400 answers a body that names one, for every caller alike (round 11 +// P3-2). func (w *branchWall) payments(mux *http.ServeMux, svc *payment.Service) { payment.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.bodyCustomer(w.scoped("admin", "owner", "sales", "finance", "cashier"), - "POST /api/v1/payments", "POST /api/v1/payments/card", "POST /api/v1/payments/intent")) + "POST /api/v1/payments", "POST /api/v1/payments/card")) } // bodyCustomer composes a route group's guard with the key branch wall's hold diff --git a/core/internal/app/serve/wire_key_branch_round4_test.go b/core/internal/app/serve/wire_key_branch_round4_test.go index 22e2b939..2cc10a74 100644 --- a/core/internal/app/serve/wire_key_branch_round4_test.go +++ b/core/internal/app/serve/wire_key_branch_round4_test.go @@ -148,6 +148,7 @@ func (f *keyPinFixture) refusedPin(t *testing.T, label, method, path, body strin before := f.heldBy(t, twin) rows := f.auditRows(t) status, buf := f.key(t, f.bound, method, path, body, nil) + f.twinLeg(method, path) if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { t.Errorf("%s: bound key %s %s naming the foreign customer: %d %s, want the pin's 403", label, method, path, status, buf) } @@ -201,18 +202,42 @@ func TestKeyBranchPin_BodyCustomerWall(t *testing.T) { } } - // The card and intent creates name the customer the same way. No gateway - // is configured here, so the A control answers 503 or 400 and never 403. - for _, path := range []string{"/api/v1/payments/card", "/api/v1/payments/intent"} { - body := func(c string) string { - return fmt.Sprintf(`{"customer_id":%q,"amount_cents":100,"token_id":"teststub"}`, c) + // The card create names the customer the same way. No gateway is + // configured here, so the A control answers 503 and never 403. The intent + // route takes no customer at all: its own 400 unknown field answer is what + // every caller gets (round 11 P3-2; the wall used to read the body before + // the route's field check and refuse a bound key for a field the route + // ignores). + cardBody := func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"amount_cents":100,"token_id":"teststub"}`, c) + } + f.refusedPin(t, "/api/v1/payments/card", "POST", "/api/v1/payments/card", cardBody(B), f.custB) + if status, buf := f.key(t, f.bound, "POST", "/api/v1/payments/card", cardBody(A), nil); status == http.StatusForbidden { + t.Errorf("bound key card charge for the pin's own customer: %d %s, want past the wall", status, buf) + } + intentBody := fmt.Sprintf(`{"customer_id":%q,"amount_cents":100}`, B) + for _, who := range []string{"bound", "unbound", "user"} { + status, buf := f.send(t, who, "POST", "/api/v1/payments/intent", intentBody, nil) + if who == "bound" { + f.twinLeg("POST", "/api/v1/payments/intent") } - f.refusedPin(t, path, "POST", path, body(B), f.custB) - if status, buf := f.key(t, f.bound, "POST", path, body(A), nil); status == http.StatusForbidden { - t.Errorf("bound key %s for the pin's own customer: %d %s, want past the wall", path, status, buf) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "customer_id") { + t.Errorf("%s payment intent naming a customer: %d %s, want the route's own 400 naming customer_id", who, status, buf) } } + // A credit memo moved onto another customer by the PUT: the wall holds + // the body's customer there too (round 10 P3-1 put this leg in; without + // it the PUT pattern in the invoices mount had no test). + memoLine := `{"description":"pin adjustment","quantity":"-1","uom":"EA","unit_price_ten_thousandths":10000}` + memoPost := fmt.Sprintf(`{"customer_id":%q,"reason_code":"other","reason":"pin","lines":[%s]}`, A, memoLine) + memo := f.made(t, "bound", "credit_memos", "POST", "/api/v1/credit-memos", memoPost, http.StatusCreated) + memoPut := func(c string) string { + return fmt.Sprintf(`{"customer_id":%q,"reason_code":"other","reason":"pin","revision":1,"lines":[%s]}`, c, memoLine) + } + f.refusedPin(t, "credit memo move", "PUT", "/api/v1/credit-memos/"+memo.String(), memoPut(B), f.custB) + f.allow(t, "bound", "PUT", "/api/v1/credit-memos/"+memo.String(), memoPut(A), nil, http.StatusOK) + // A document moved onto another customer: the quote update and the draft // order update that change customer_id. The foreign leg runs before its // control, which spends the revision. @@ -238,6 +263,7 @@ func TestKeyBranchPin_BodyCustomerWall(t *testing.T) { {"orders", "orders", `{"payload":{"customer_id":%q,"delivery_type":"pickup","lines":[` + line + `]}}`}, } { foreign := f.made(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.body, B), http.StatusCreated) + f.twinLeg("POST", "/api/v1/drafts/"+kind.module) own := f.made(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.body, A), http.StatusCreated) f.refusedPin(t, "promote "+kind.module, "POST", "/api/v1/drafts/"+kind.module+"/"+foreign.String()+"/promote", `{"revision":1}`, f.custB) f.made(t, "bound", kind.table, "POST", "/api/v1/drafts/"+kind.module+"/"+own.String()+"/promote", `{"revision":1}`, http.StatusCreated) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index bae4245b..b2b7611d 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -393,7 +393,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/orders/{id}/transitions": {KeyBranchClassWalled, "orders.branch_id"}, "POST /api/v1/payments": {KeyBranchClassWalled, "payments.branch_id (the create takes branch_id from the caller context)"}, "POST /api/v1/payments/card": {KeyBranchClassWalled, "payments.branch_id (the card charge writes its payment under the caller context)"}, - "POST /api/v1/payments/intent": {KeyBranchClassWalled, "payments.branch_id (the intent writes under the caller context)"}, + "POST /api/v1/payments/intent": {KeyBranchClassWalled, "payments.branch_id (the intent writes under the caller context); the route takes no customer, so its own 400 answers a body naming one (round 11 P3-2)"}, "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, From 224e302721afeedb87dad3f3326868cc3bc228d5 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:57:33 -0700 Subject: [PATCH 47/74] tests(customer): round 10's test gaps, and the escalation audit's signature date Round 10 P3-1, P3-2, P3-3, P3-5 and P3-6, and round 10 P3-4's code half: the credit memo PUT wall gets its refused leg (the pattern dropped from the invoices mount now goes red), the PUT body's salesperson_id gets its refusal leg (classing it BRANCH LOCAL goes red), the shared customer is held by a third branch so every also held by answer must name the lowest numbered other branch (a DESC on either lookup goes red), a service level refusal row names the method and path RecordRequest noted (removing RecordRequest goes red), the walled route coverage counts only explicit twin legs so an A control alone covers nothing (a driven but control only route fails by name), and the escalation audit's before and after values carry agreement_signed_at beside the reference (policyFacts, missing on the unfixed code). Signed-off-by: colton --- .../app/serve/wire_key_branch_legs_test.go | 27 ++-- .../app/serve/wire_key_branch_pin_test.go | 12 +- .../app/serve/wire_key_branch_shared_test.go | 115 +++++++++++++++++- .../app/serve/wire_key_branch_walled_test.go | 8 ++ core/internal/customer/service.go | 3 +- 5 files changed, 147 insertions(+), 18 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_legs_test.go b/core/internal/app/serve/wire_key_branch_legs_test.go index 71a6a755..9219fdc4 100644 --- a/core/internal/app/serve/wire_key_branch_legs_test.go +++ b/core/internal/app/serve/wire_key_branch_legs_test.go @@ -3,13 +3,15 @@ package serve -// The coverage half of the WALLED claim (ADR 0007 section 5.5): the tests that -// drive walled routes with a key bound to branch A mark every call they send -// through the pin fixture's recorder, and TestKeyBranchPin_ZWalledRoutesAllHaveALeg -// holds the route allowlist against the marks, so a WALLED entry no test -// drives with a bound key fails by name. The recorder is per process: the -// coverage test runs after the drivers (file order) and skips when a -run -// filter kept one of them from running. +// The coverage half of the WALLED claim (ADR 0007 section 5.5): the tests +// that drive walled routes with a key bound to branch A mark the legs that +// drive them against the foreign twin (or the reach and list legs that prove +// the pin's confinement), and TestKeyBranchPin_ZWalledRoutesAllHaveALeg holds +// the route allowlist against the marks, so a WALLED entry no foreign twin leg +// drives fails by name. An A control alone covers nothing (round 10 P3-6): +// the marks are explicit, on the driving leg, never on the control. The +// recorder is per process: the coverage test runs after the drivers (file +// order) and skips when a -run filter kept one of them from running. import ( "bytes" @@ -37,12 +39,14 @@ var walledLegs = struct { drivers map[string]bool }{seen: map[string]bool{}, drivers: map[string]bool{}} -// record makes the fixture mark every call its bound keys send, from here on. +// record makes the fixture count the twin legs its drivers mark, from here on. func (f *keyPinFixture) record() { f.recording = true } -// mark notes one bound key call when the fixture records. -func (f *keyPinFixture) mark(raw, method, path string) { - if !f.recording || (raw != f.bound && raw != f.bound2 && raw != f.boundFirst) { +// twinLeg marks one route as driven by a foreign twin leg (or a reach or list +// leg that proves the pin holds), for the coverage test. It is the driver's +// explicit call on its driving leg, never the A control. +func (f *keyPinFixture) twinLeg(method, path string) { + if !f.recording { return } if i := strings.IndexByte(path, '?'); i >= 0 { @@ -170,7 +174,6 @@ func (f *keyPinFixture) stream(t *testing.T, raw, path string, d time.Duration) } defer res.Body.Close() buf, _ := io.ReadAll(res.Body) // the deadline ends the stream; what arrived before it is the answer - f.mark(raw, "GET", path) return res.StatusCode, string(buf) } diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index f47a750f..5e948352 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -748,7 +748,6 @@ func (f *keyPinFixture) key(t *testing.T, raw, method, path, body string, hdr ma if err != nil { t.Fatal(err) } - f.mark(raw, method, path) return res.StatusCode, buf } @@ -824,6 +823,7 @@ func (f *keyPinFixture) refuseForeign(t *testing.T, method, path, body string, h t.Helper() before := f.auditRows(t) status, buf := f.key(t, f.bound, method, path, body, hdr) + f.twinLeg(method, path) if status != http.StatusForbidden { t.Errorf("bound key %s %s: %d %s, want 403", method, path, status, buf) return @@ -1590,6 +1590,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { // The open till of the foreign register: no session answers, while the // pin's own register answers its own. _, buf := f.key(t, f.bound, "GET", "/api/v1/pos/till/current?register_id="+f.regB, "", nil) + f.twinLeg("GET", "/api/v1/pos/till/current") if strings.Contains(string(buf), f.tillB.String()) { t.Errorf("bound key till current of the foreign register answered the foreign session: %s", buf) } @@ -1603,6 +1604,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { // one; the pin's own session answers its report. sMiss, _ := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+missing+"/report", "", nil) sB, bB := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+f.tillB.String()+"/report", "", nil) + f.twinLeg("GET", "/api/v1/pos/till/"+f.tillB.String()+"/report") if sB != sMiss || strings.Contains(string(bB), f.regB) { t.Errorf("bound key foreign till report: %d %s, want the missing row answer (%d, no B data)", sB, bB, sMiss) } @@ -1612,6 +1614,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { // The foreign till cannot be closed: no session write, no Z report. f.key(t, f.bound, "POST", "/api/v1/pos/till/"+f.tillB.String()+"/close", `{"counted_by_method":{"CASH":50}}`, nil) + f.twinLeg("POST", "/api/v1/pos/till/"+f.tillB.String()+"/close") if !tillOpen(f.tillB) { t.Errorf("bound key closed the foreign till session") } @@ -1625,7 +1628,9 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/till/"+f.tillB.String()+"/zreport", "", nil); sB != sMiss { t.Errorf("bound key foreign Z report: %d, want the missing row answer %d", sB, sMiss) } + f.twinLeg("GET", "/api/v1/pos/till/"+f.tillB.String()+"/zreport") _, buf = f.key(t, f.bound, "GET", "/api/v1/pos/zreports", "", nil) + f.twinLeg("GET", "/api/v1/pos/zreports") if strings.Contains(string(buf), f.zrB.String()) { t.Errorf("bound key Z report list carries the foreign Z report: %s", buf) } @@ -1636,6 +1641,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { // The returns list carries the pin's own return and not the foreign one; // the foreign return reads as missing, the own one answers. _, buf = f.key(t, f.bound, "GET", "/api/v1/pos/returns", "", nil) + f.twinLeg("GET", "/api/v1/pos/returns") if strings.Contains(string(buf), f.retB.String()) { t.Errorf("bound key returns list carries the foreign return: %s", buf) } @@ -1645,6 +1651,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/returns/"+f.retB.String(), "", nil); sB != http.StatusNotFound { t.Errorf("bound key foreign return read: %d, want 404", sB) } + f.twinLeg("GET", "/api/v1/pos/returns/"+f.retB.String()) f.allow(t, "unbound", "GET", "/api/v1/pos/returns/"+f.retB.String(), "", nil, http.StatusOK) f.allow(t, "bound", "GET", "/api/v1/pos/returns/"+f.retA.String(), "", nil, http.StatusOK) @@ -1654,13 +1661,16 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { if sB, _ := f.key(t, f.bound, "GET", "/api/v1/pos/transactions/"+f.txnB.String(), "", nil); sB != http.StatusNotFound { t.Errorf("bound key foreign transaction read: %d, want 404", sB) } + f.twinLeg("GET", "/api/v1/pos/transactions/"+f.txnB.String()) before := linesOn(f.txnB) f.key(t, f.bound, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items", fmt.Sprintf(`{"product_id":%q,"quantity":1,"uom":"EA"}`, f.posProd.String()), nil) + f.twinLeg("POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items") if after := linesOn(f.txnB); after != before { t.Errorf("bound key line insert on the foreign transaction wrote: %d rows then %d", before, after) } f.key(t, f.bound, "DELETE", "/api/v1/pos/transactions/"+f.txnB.String()+"/items/"+f.lineB.String(), "", nil) + f.twinLeg("DELETE", "/api/v1/pos/transactions/"+f.txnB.String()+"/items/"+f.lineB.String()) if after := linesOn(f.txnB); after != before { t.Errorf("bound key line delete on the foreign transaction wrote: %d rows then %d", before, after) } diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index f7bd2283..559b1b2e 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -10,6 +10,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" @@ -146,8 +147,17 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { // key without it is refused (TestKeyBranchPin_SharedRestrictedScope). f.unbound = f.mintWithScopes(t, "pin scoped restricted", []string{"customers:read", "customers:write", "ship-tos:read", "ship-tos:write", "contacts:read", "contacts:write", middleware.CustomerRestrictedScope}, nil) - s := seedSharedCustomer(t, f, f.branchA, f.branchB) + // The customer is held by three branches, so the "also held by" refusal + // must name the lowest numbered other branch on every path that answers + // one (round 10 P3-3): the shared record rule here, and the exclusive + // pricing and exemption walls below. + s := seedSharedCustomer(t, f, f.branchA, f.branchB, f.branchC) own := seedSharedCustomer(t, f, f.branchA) + lowestOther := f.branchB + if f.branchC.String() < lowestOther.String() { + lowestOther = f.branchC + } + named := "the customer is also held by branch " + lowestOther.String() sh := "/api/v1/customers/" + s.id.String() alone := "/api/v1/customers/" + own.id.String() @@ -240,6 +250,14 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { raw, _ := json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": fmt.Sprintf("%d.5", 1+n%40), "revision": cur["revision"]}) return f.send(t, who, "PUT", path+"/escalation-policy", string(raw), nil) }}, + {"salesperson_id", func(who, path string) (int, []byte) { + cur := f.readJSON(t, path) + var sp any = s.salesperson.String() + if cur["salesperson_id"] != nil { + sp = nil + } + return put(who, path, func(map[string]any) map[string]any { return map[string]any{"salesperson_id": sp} }) + }}, } // A branch local field passes on the shared customer for the bound key. @@ -264,7 +282,7 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { for _, fld := range fields { digest, rows := f.customerDigest(t, s.id), f.auditRows(t) status, buf := fld.do("bound", sh) - if status != http.StatusForbidden || !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + if status != http.StatusForbidden || !strings.Contains(string(buf), named) { t.Errorf("bound key changing %s of the shared customer: %d %s, want a 403 naming branch B", fld.name, status, buf) } if after := f.auditRows(t); after != rows+1 { @@ -362,7 +380,7 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { defaultAdd := `{"code":"pindef","name":"pin default","line1":"2 pin","is_default":true}` before, rows := shipDigest(s.id), f.auditRows(t) if status, buf := f.key(t, f.bound, "POST", sh+"/ship-tos", defaultAdd, nil); status != http.StatusForbidden || - !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + !strings.Contains(string(buf), named) { t.Errorf("bound key adding a default ship-to to the shared customer: %d %s, want a 403 naming branch B", status, buf) } if after := f.auditRows(t); after != rows+1 { @@ -407,7 +425,7 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { before, rows := childState(), f.auditRows(t) body, hdr := c.body() status, buf := f.key(t, f.bound, c.method, c.path, body, hdr) - if status != http.StatusForbidden || !strings.Contains(string(buf), "the customer is also held by branch "+f.branchB.String()) { + if status != http.StatusForbidden || !strings.Contains(string(buf), named) { t.Errorf("bound key %s of the shared customer: %d %s, want a 403 naming branch B", c.label, status, buf) } if after := f.auditRows(t); after != rows+1 { @@ -418,6 +436,95 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { } } + // The escalation audit carries the agreement's signature date beside its + // reference (round 10 P3-4): the before and after values are incomplete + // without it. + { + cur := f.readJSON(t, sh+"/escalation-policy") + raw, _ := json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": "9.5", + "agreement_ref": "PIN-AGMT", "agreement_signed_at": time.Now().UTC().Format(time.RFC3339), + "revision": cur["revision"]}) + if status, buf := f.send(t, "unbound", "PUT", sh+"/escalation-policy", string(raw), nil); status != http.StatusOK { + t.Fatalf("scoped key setting the escalation agreement: %d %s, want 200", status, buf) + } + rows := f.sharedChangeRows(t, s.id) + last := rows[len(rows)-1] + esc, ok := last["escalation_policy"].(map[string]any) + if !ok { + t.Fatalf("the escalation audit row carries no policy facts: %v", last) + } + for _, side := range []string{"from", "to"} { + facts, ok := esc[side].(map[string]any) + if !ok { + t.Fatalf("the escalation audit's %s facts are not a map: %v", side, esc) + } + if _, has := facts["agreement_signed_at"]; !has { + t.Errorf("the escalation audit's %s carries no agreement_signed_at: %v", side, facts) + } + if _, has := facts["agreement_ref"]; !has { + t.Errorf("the escalation audit's %s carries no agreement_ref: %v", side, facts) + } + } + } + + // The exclusive walls name the same lowest numbered other branch on the + // three branch customer (round 10 P3-3: the second query of the exclusive + // lookup, the shared customer pricing and exemption line). + { + pinned := f.mintWithScopes(t, "pin exclusive walls", []string{"tax:read", "tax:write"}, &f.branchA) + before := f.auditRows(t) + body := fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin three branch","certificate_number":"PIN-EXP-3","issuing_state":"WA"}`, s.id.String()) + status, buf := f.key(t, pinned, "POST", "/api/v1/tax/exemptions", body, nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") || + !strings.Contains(string(buf), named) { + t.Errorf("bound key exemption on the three branch customer: %d %s, want the 403 naming the lowest other branch", status, buf) + } + if after := f.auditRowsFor(t, "no key"); after != 0 { + t.Fatal("unreachable") + } + var exemptions int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM tax_exemptions WHERE customer_id = $1`, s.id).Scan(&exemptions); err != nil { + t.Fatal(err) + } + if exemptions != 0 { + t.Errorf("the refused exemption write left %d rows", exemptions) + } + if rows := f.auditRowsFor(t, "placeholder"); rows != 0 { + t.Fatal("unreachable") + } + _ = before + } + + // A service level refusal names the call it refused: the row's method and + // path come from the mount's RecordRequest (round 10 P3-5). + { + digest, rows := f.customerDigest(t, s.id), f.auditRows(t) + nextTier := "gold" + if f.readJSON(t, sh)["tier"] == "gold" { + nextTier = "silver" + } + if status, buf := f.key(t, f.bound, "PUT", sh, f.customerReplace(t, s.id, map[string]any{"tier": nextTier}), nil); status != http.StatusForbidden { + t.Fatalf("bound key tier change on the shared customer: %d %s, want 403", status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Fatalf("the refusal left no audit row") + } + var method, path string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT changes->>'method', changes->>'path' FROM audit_log + WHERE action = 'key.branch_refused' AND actor_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, + f.boundKeyID).Scan(&method, &path); err != nil { + t.Fatal(err) + } + if method != "PUT" || path != sh { + t.Errorf("the refusal row names %s %s, want PUT %s", method, path, sh) + } + if f.customerDigest(t, s.id) != digest { + t.Errorf("the refused tier change still changed the shared customer's row") + } + } + // The customer the pin holds alone: its ship-to and contact change and // delete freely for the bound key; the unbound key and the user do the // same on the shared customer's, each delete on a contact of its own. diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go index 31169316..e22444a5 100644 --- a/core/internal/app/serve/wire_key_branch_walled_test.go +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -550,6 +550,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { // reach its module past the key's scopes, and (for a create that // takes its branch from the caller) must land at the pin's branch. status, buf := f.key(t, raw(l), l.method, pathFor(l, l.twin, l.twin2), bodyFor(l, l.twin), l.hdr) + f.twinLeg(l.method, pathFor(l, l.twin, l.twin2)) if !controlOK(status) && !(l.tolerate500 != "" && status == http.StatusInternalServerError) { t.Errorf("bound key %s %s: %d %s, want the call to reach its module", l.method, l.path, status, buf) } @@ -574,6 +575,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { bPath := pathFor(l, l.twin, l.twin2) mPath := pathFor(l, uuid.MustParse(missing), uuid.MustParse(missing)) sb, bb := f.key(t, raw(l), l.method, bPath, bodyFor(l, l.twin), l.hdr) + f.twinLeg(l.method, bPath) sm, _ := f.key(t, raw(l), l.method, mPath, bodyFor(l, l.twin), l.hdr) scopeRefusal := sb == http.StatusForbidden && strings.Contains(string(bb), "lacks required scope") held := !scopeRefusal && (sb == http.StatusForbidden || sb == http.StatusNotFound || @@ -660,6 +662,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { key = firstBound } sb, bb := f.key(t, key, "GET", l.path, "", nil) + f.twinLeg("GET", l.path) if sb != http.StatusOK { t.Errorf("bound key GET %s: %d %s, want 200", l.path, sb, bb) continue @@ -696,6 +699,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { map[string]string{"X-Branch-Id": B}, http.StatusCreated) own := f.madeWith(t, "bound", "drafts", "POST", "/api/v1/drafts/"+kind.module, fmt.Sprintf(kind.payload, f.custID.String()), nil, http.StatusCreated) status, body := f.stream(t, f.bound, "/api/v1/drafts/"+kind.module+"/feed?cursor="+cursor0, 1500*time.Millisecond) + f.twinLeg("GET", "/api/v1/drafts/"+kind.module+"/feed") if status != http.StatusOK { t.Errorf("bound key GET /drafts/%s/feed: %d %s, want the stream open", kind.module, status, body) continue @@ -715,10 +719,12 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { if sb, _ := f.key(t, f.bound, "GET", "/api/v1/branches/"+B+"/tree", "", nil); sb != http.StatusForbidden { t.Errorf("bound key foreign branch tree: %d, want 403", sb) } + f.twinLeg("GET", "/api/v1/branches/"+B+"/tree") f.allow(t, "bound", "GET", "/api/v1/branches/"+A+"/tree", "", nil, http.StatusOK) if sb, bb := f.key(t, f.bound, "GET", "/api/v1/locations/"+f.yardB.String(), "", nil); sb != http.StatusForbidden && sb != http.StatusNotFound { t.Errorf("bound key foreign yard read: %d %s, want 403 or 404", sb, bb) } + f.twinLeg("GET", "/api/v1/locations/"+f.yardB.String()) f.allow(t, "bound", "GET", "/api/v1/locations/"+f.yardA.String(), "", nil, http.StatusOK) // The inventory adjust and transfer bodies name locations: the foreign @@ -728,11 +734,13 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { fmt.Sprintf(`{"product_id":%q,"location_id":%q,"quantity":5,"reason":"pin"}`, f.posProd.String(), f.yardB.String()), nil); sb != http.StatusForbidden { t.Errorf("bound key inventory adjust naming the foreign yard: %d %s, want 403", sb, bb) } + f.twinLeg("POST", "/api/v1/inventory/adjust") if sb, bb := f.key(t, f.bound, "POST", "/api/v1/inventory/transfer", fmt.Sprintf(`{"product_id":%q,"from_location_id":%q,"to_location_id":%q,"quantity":1,"reason":"pin"}`, f.posProd.String(), f.yardA.String(), f.yardB.String()), nil); sb != http.StatusForbidden { t.Errorf("bound key inventory transfer into the foreign yard: %d %s, want 403", sb, bb) } + f.twinLeg("POST", "/api/v1/inventory/transfer") if changed := changedTables(beforeInv, f.twinState(t, w)); len(changed) > 0 { t.Errorf("refused inventory writes changed rows that name branch B's twins: %v", changed) } diff --git a/core/internal/customer/service.go b/core/internal/customer/service.go index 4fbbe89e..076d05be 100644 --- a/core/internal/customer/service.go +++ b/core/internal/customer/service.go @@ -673,5 +673,6 @@ func policyFacts(p *EscalationPolicy) map[string]any { if p == nil { return nil } - return map[string]any{"policy": string(p.Policy), "threshold_percent": int64(p.ThresholdPercent), "agreement_ref": p.AgreementRef} + return map[string]any{"policy": string(p.Policy), "threshold_percent": int64(p.ThresholdPercent), + "agreement_signed_at": p.AgreementSignedAt, "agreement_ref": p.AgreementRef} } From 9b10422b6c1c7fe60fac6a446d2deb5b0d469fa2 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 19:59:42 -0700 Subject: [PATCH 48/74] docs: round 5 of the branch bound key pin in the ADRs, contract and module pages ADR 0007 section 5.5 and ADR 0002 state the round 5 rules: the sync's body customer wall, the job_id wall and the every caller job, contact and default ship to ownership, the convert's customer recheck, the intent outside the body customer wall, the customers:restricted capability scope and the finance or owner user rule that resolves the round 4 open question, the legacy row's 409, and the twin only coverage. CONTRACT-CHANGES carries SEC-BOUND-KEY-PIN-5, and the orders, quotes, payments, customers, crm activities and tech admin module pages state each rule where the module states its own. Signed-off-by: colton --- docs/adr/0002-machine-keys.md | 12 ++++ ...7-drafts-links-and-confirm-gated-scopes.md | 72 +++++++++++++------ docs/modules/crm-activities.md | 6 +- docs/modules/customers.md | 33 ++++++--- docs/modules/orders.md | 17 +++-- docs/modules/payments.md | 5 +- docs/modules/quotes.md | 12 +++- docs/modules/tech-admin.md | 6 +- docs/refactor/CONTRACT-CHANGES.md | 1 + 9 files changed, 122 insertions(+), 42 deletions(-) diff --git a/docs/adr/0002-machine-keys.md b/docs/adr/0002-machine-keys.md index 932e7d1c..7f77d556 100644 --- a/docs/adr/0002-machine-keys.md +++ b/docs/adr/0002-machine-keys.md @@ -64,6 +64,18 @@ interpretation), and an exact vocabulary keeps every granted scope auditable as written. Should a migration ever arrive with wildcard-carrying keys, that is a listed contract change decided then. +One capability scope rides with the vocabulary outside the route rule: +`customers:restricted`, which no route requires and which the shared +customer rule of ADR 0007 section 5.5 reads (a shared customer's restricted +fields change only for an unbound key carrying it, beside the finance or +owner role a user needs). Because no route requires it, holding the +customers module's read and write scopes never grants it and no default +grant carries it; it is minted on purpose, by an owner only (the admin role +is refused with a 403 naming the scope), and the mint's `key.created` audit +row names it. Migration 103's report predates it and still names a stored +key carrying it as outside its own grammar, which is why the report's pin +keeps it on the reported side. + ### 3. Where a key is a principal A machine key is a principal on declared `/api/v1` module routes only. diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 0304a3e1..401e6fdb 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -715,7 +715,10 @@ row (a leg whose control cannot finish in the harness says why in the test); the foreign leg must answer 403 or 404 and never a scope refusal; and a change is measured by a digest of every row that names the twin rows by id, never by table counts. A coverage test holds the allowlist against the legs -and fails by name for any WALLED route no bound key leg drives. The routes +and fails by name for any WALLED route no bound key leg drives; the legs it +counts are the drivers' explicit twin marks (the foreign twin, the reach and +list legs), never an A control, so a route driven only by its control +fails by name. The routes that take their branch from the caller, and so name no foreign row to refuse, are driven once and must leave branch B's rows as they were and land the row they make at the pin's branch. @@ -723,16 +726,30 @@ the row they make at the pin's branch. A document write that names a customer in its body is held to the pin the same way a path customer is: the cash and card payment, the credit memo (create and update), the order and the quote (create and update, a document -moved onto another customer included) and the draft promotions, which read -the customer from the draft's stored payload, are refused a bound key whose -pin does not hold that customer (403 naming `customer_id`, the -`key.branch_refused` row, nothing written). Without it the payment and its -GL entry, the credit, or the order with the other customer's tax exemption -landed on a customer the key could not see. An order, for every caller, -refuses a `ship_to_id` or an `ordered_by_contact_id` its customer does not -own (400 naming the field, as the credit memo create does), so an order can -never carry another customer's address. The order exposure gate and override -read the order under the branch wall before they look up its source quote. +moved onto another customer included), the offline POS sync (the customer +each replayed sale names, whose ACCOUNT tender invoices it) and the draft +promotions, which read the customer from the draft's stored payload, are +refused a bound key whose pin does not hold that customer (403 naming +`customer_id`, the `key.branch_refused` row, nothing written). Without it +the payment and its GL entry, the credit, the order with the other +customer's tax exemption, or the replayed register sale and its invoice +landed on a customer the key could not see. The quote conversion rechecks +the quote's stored customer under the same wall, as the draft promotions +recheck their payload: a quote a caller moved onto another branch's +customer must not become an order at the pin's branch. The intent create +takes no customer, so it stands outside the wall and its own 400 unknown +field answer serves every caller alike. An order or a quote names a job as +well, so the body's `job_id` is held through the job's customer +(`projects.customer_id`, the resolution the pricing rule write and the +calculate read carry) on the creates and the updates alike. An order, for +every caller, refuses a `ship_to_id`, an `ordered_by_contact_id` or a +`job_id` its customer does not own (400 naming the field, as the credit +memo create does), so a document can never carry another customer's +address, contact, or job and its pricing rules; the quote's own build +refuses the foreign job the same way, and a CRM activity refuses a +`contact_id` its customer does not own for every caller with the same +words. The order exposure gate and override read the order under the +branch wall before they look up its source quote. The confinement contract the CONFINED routes hold: a body is read whole (a body that is not exactly one complete JSON value is refused, so the wall and @@ -769,7 +786,10 @@ too. Those writes are held to the customers the pin holds alone; the refusal names the other branch (the lowest numbered branch that is not the pin). A category rule whose target is not an account rule is dealer wide whether or not it also names a customer, so the legacy tier row migration 107 keeps NOT -VALID cannot be deleted, singly or in bulk, by a bound key. +VALID cannot be deleted, singly or in bulk, by a bound key, and an update of +such a row is refused for every caller with a 409 naming the single target +invariant, where the database's revalidation on the write would otherwise +surface as an internal error. The customer record follows the same rule, deny by default. On a customer held by the pin's branch and another, a bound key changes only the branch @@ -777,16 +797,27 @@ local fields; every other field of the record, and the salesperson and the escalation policy, is restricted, and a field added to the header write is restricted until it is listed as branch local. A restricted write is refused 403 in the same words naming the other branch, with the `key.branch_refused` -row and nothing changed. A customer the pin holds alone, a user and an -unshared customer are unchanged. A dealer wide (unbound) key changes the -restricted fields of a shared customer and every such change is audited as -`customer.shared_restricted_changed` with the key as the actor and the before -and after value of each field (a machine key carries no role, so the finance -and owner condition of the ruling cannot be evaluated for a key and is -recorded as an open question). Ship-tos and contacts carry no branch column, +row and nothing changed. A customer the pin holds alone and an unshared +customer are unchanged for every caller. Who may change a shared customer's +restricted fields (the principal's ruling on the open question below): a +user only with the finance or owner role, a machine key only when unbound +and carrying the dedicated `customers:restricted` scope. A sales or admin +user, and every unbound key without the scope, are refused in the same +words naming the lowest numbered branch holding the customer; a caller with +no claims at all is refused fail closed; a bound key is refused even with +the scope. The scope is in the grant grammar (ADR 0002 section 2) though no +route requires it, so no module scope and no default grant carries it; only +an owner may mint it, and the mint's `key.created` audit row names it. An +allowed key's change is audited as `customer.shared_restricted_changed` +with the key as the actor and the before and after value of each field; a +user's change writes no row. Ship-tos and contacts carry no branch column, so for a shared customer every existing one is shared: a bound key reads them and may add new ones but may not change or delete any (the same -refusal); for a customer the pin holds alone all of it is allowed. The +refusal), and an add that names `is_default: true` is refused like a +change, because naming a default clears the existing default, a shared +ship to every branch's delivery orders default to; for a customer the pin +holds alone all of it is allowed. The escalation audit's before and after +values carry the agreement's signature date beside its reference. The fields: | Route | Field | Class | Reason | @@ -808,6 +839,7 @@ fields: | `PATCH /customers/{id}/salesperson` | `salesperson_id` | RESTRICTED | as above | | `PUT /customers/{id}/escalation-policy` | `policy`, `threshold_percent`, `agreement_signed_at`, `agreement_ref` | RESTRICTED | governs the repricing of every branch's quotes | | `POST /customers/{id}/ship-tos`, `/contacts` | a new record | allowed | adds; changes nothing shared | +| `POST /customers/{id}/ship-tos` with `is_default: true` | the existing default | shared record | the add clears the shared default, so it is refused like a change (an add naming no default stays allowed) | | `PUT /ship-tos/{id}`, `PUT /contacts/{id}`, `DELETE /contacts/{id}` | an existing record | shared records | refused on a shared customer, allowed on a customer the pin holds alone | A branch integration that must price shared customers holds an unbound key an diff --git a/docs/modules/crm-activities.md b/docs/modules/crm-activities.md index c6c60dc8..d3fac3f6 100644 --- a/docs/modules/crm-activities.md +++ b/docs/modules/crm-activities.md @@ -99,7 +99,11 @@ under `/api/v1/`). The by-customer routes need `customers:read` and sales wall; the exact guard is composed in `core/internal/app/serve/wire_branch_wall.go` at `wall.crm`. Every route carries the branch wall through the activity's customer: a -second branch's request is a 404. A key without the scope is `403 +second branch's request is a 404. For every caller, a create or an update +refuses a `contact_id` the activity's customer does not own with a `400 +validation_failed` naming `contact_id` ("is not a contact of this +customer", the order's words), so one branch's record can never point at +another's contact. A key without the scope is `403 forbidden`; the audit row carries the refused scope. ## ADRs that govern this module diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 7335a057..82517e32 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -166,9 +166,10 @@ segment is the first path segment under `/api/v1/`). Ship-tos need `contacts:read` or `contacts:write`; payment terms need `payment-terms:read` or `payment-terms:write`; price levels need `price_levels:read`. The user guard at the serve layer is -`admin`, `owner`, `sales` for customer reads and writes, with -`admin`, `owner`, `finance` taking the narrower payment terms writes; -the exact guard is composed in +`admin`, `owner`, `sales` and `finance` for customer reads and writes +(finance reaches the record so the shared customer rule below can hear +its role), with `admin`, `owner`, `finance` taking the narrower payment +terms writes; the exact guard is composed in `core/internal/app/serve/wire_branch_wall.go` at `wall.customers`. A key without the scope is `403 forbidden`; the audit row carries the refused scope. @@ -181,10 +182,19 @@ until it is listed in `branchLocalFields` (`core/internal/customer/shared.go`). A restricted write is `403 forbidden` in the words "a branch bound key may act only on its own branch: the customer is also held by branch " (the lowest numbered other branch), with the `key.branch_refused` row, and the record is -untouched. A customer the key's branch holds alone, a user and an unshared -customer are unchanged. A dealer wide key changes the restricted fields and -each change writes a `customer.shared_restricted_changed` audit row naming -the key and the before and after value of every field. +untouched. A customer the key's branch holds alone and an unshared customer +are unchanged for every caller. Who else may change a shared customer's +restricted fields: a user only with the `finance` or `owner` role (a sales +or admin user is refused in the same words naming a branch that holds the +customer, and a caller with no claims at all is refused fail closed), and a +dealer wide key only while it carries the dedicated `customers:restricted` +scope; every other unbound key is refused with the same words, and a branch +bound key is refused even with the scope. The scope is in the grant grammar +but no route requires it, so no module scope and no default grant carries +it; only an owner may mint it, and the mint's `key.created` audit row names +it. An allowed key's change writes a `customer.shared_restricted_changed` +audit row naming the key and the before and after value of every field; a +user's change writes none. | Route | Field | Class | Reason | |---|---|---|---| @@ -196,12 +206,17 @@ the key and the before and after value of every field. | | `salesperson_id` | RESTRICTED | one salesperson per customer across branches | | `PATCH /customers/{id}/salesperson` | `salesperson_id` | RESTRICTED | as above | | `PUT /customers/{id}/escalation-policy` | policy, threshold, agreement | RESTRICTED | governs the repricing of every branch's quotes | +| `POST /customers/{id}/ship-tos` with `is_default: true` | the existing default | shared record | the add clears the shared default ship to, so it is refused like a change; an add naming no default is allowed | Ship-tos and contacts carry no branch column, so on a shared customer every existing one is shared: a bound key reads them and may add a new one with `POST`, and is refused `PUT /ship-tos/{id}`, `PUT /contacts/{id}` and -`DELETE /contacts/{id}` in the same words. On a customer the key's branch -holds alone, all of them are allowed. +`DELETE /contacts/{id}` in the same words. A ship-to add that names +`is_default: true` is refused the same way, because naming a default +clears the customer's existing default, which on a shared customer is a +shared ship to every branch's delivery orders default to; an add that +names no default stays allowed. On a customer the key's branch +holds alone, all of it is allowed. ## ADRs that govern this module diff --git a/docs/modules/orders.md b/docs/modules/orders.md index a4898fa0..953a4d07 100644 --- a/docs/modules/orders.md +++ b/docs/modules/orders.md @@ -236,13 +236,16 @@ Releasing a hold needs `admin`, `owner` or `finance`. A key without the scope is `403 forbidden`; the audit row carries the refused scope. A key bound to one branch (ADR 0007 section 5.5) may create or update an -order only for a customer its branch holds (`POST /orders`, `PUT -/orders/{id}`: `403 forbidden` naming `customer_id`, nothing written), and -the exposure gate and override read the order under the branch wall before -they look up its source quote. For every caller, an order refuses a -`ship_to_id` or `ordered_by_contact_id` its customer does not own, with a -`400 validation_failed` naming the field ("is not a ship-to of this -customer", "is not a contact of this customer"). +order only for a customer its branch holds, and only for a job that +customer owns (`POST /orders`, `PUT /orders/{id}`: `403 forbidden` naming +`customer_id`, nothing written), and the exposure gate and override read +the order under the branch wall before they look up its source quote. For +every caller, an order refuses a `ship_to_id`, an `ordered_by_contact_id` +or a `job_id` its customer does not own, with a `400 validation_failed` +naming the field ("is not a ship-to of this customer", "is not a contact +of this customer", "is not a job of this customer"); a foreign job's +pricing rules would otherwise price the document. The conversion rechecks +the quote's stored job and customer the same way. ## ADRs that govern this module diff --git a/docs/modules/payments.md b/docs/modules/payments.md index 14d5cb9b..32ced166 100644 --- a/docs/modules/payments.md +++ b/docs/modules/payments.md @@ -426,7 +426,10 @@ branch the request carries through `X-Branch-Id` (ADR 0007). A key bound to one branch (ADR 0007 section 5.5) may post a payment (`POST /payments`, `/payments/card`) only for a customer its branch holds: `403 forbidden` naming `customer_id`, nothing written, so no payment or GL entry -lands on a customer the key cannot see. +lands on a customer the key cannot see. The intent create takes no +customer, so it stands outside that wall: a body naming `customer_id` gets +the route's own `400` unknown field answer, for a bound key exactly as for +every other caller. ## ADRs that govern this module diff --git a/docs/modules/quotes.md b/docs/modules/quotes.md index bd6c2ba5..fa19e999 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -236,9 +236,15 @@ A key without the scope is `403 forbidden`; the route names the path segment of the scope so the audit row carries it. A key bound to one branch (ADR 0007 section 5.5) may create or update a -quote only for a customer its branch holds (`POST /quotes`, `PUT -/quotes/{id}`: `403 forbidden` naming `customer_id`, nothing written), -which also keeps a quote from being moved onto another branch's customer. +quote only for a customer its branch holds, and only for a job that +customer owns (`POST /quotes`, `PUT /quotes/{id}`: `403 forbidden` naming +`customer_id`, nothing written), which also keeps a quote from being moved +onto another branch's customer. The conversion rechecks the quote's stored +customer under the same wall (`POST /quotes/{id}/convert`: the 403 naming +`customer_id`), so a quote a caller moved onto a foreign customer cannot +become an order at the pin's branch. For every caller, a quote refuses a +`job_id` its customer does not own with the order's words ("is not a job +of this customer"). ## ADRs that govern this module diff --git a/docs/modules/tech-admin.md b/docs/modules/tech-admin.md index ea77b2e4..627b5dd8 100644 --- a/docs/modules/tech-admin.md +++ b/docs/modules/tech-admin.md @@ -106,7 +106,11 @@ the `api_key` field of the `AdminCreateKeyResponse` envelope and only there, at mint. `AdminCreateKeyRequest` carries `name` (required) and `scopes` -(optional, array of text). `AdminCreateKeyResponse` carries `api_key` +(optional, array of text; a mint naming none grants none). One scope has +its own gate: `customers:restricted` (the shared customer restricted +fields admission, ADR 0007 section 5.5) is granted only by an owner, the +admin role getting a `403 forbidden` that names the scope; the mint's +`key.created` audit row names every granted scope, that one included. `AdminCreateKeyResponse` carries `api_key` (the raw key, `sk_live_` plus 43 URL safe base64 characters) and `key` (the saved `ApiKey` row). diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 3ac36e8e..666ef91d 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -294,3 +294,4 @@ get an entry. | SEC-BOUND-KEY-PIN | the whole `/api/v1` surface a machine key can reach (345 census routes, method plus pattern), held by one allowlist, `core/pkg/middleware/keybranch_allow.go` | ADR 0007 section 5.5 pinned a branch bound machine key to its branch, but only the branch middleware and the payload guard honoured the pin: a scope checked key passes the role guards on scope alone, and the fix that walled each route the sweep found was opt in, so every route a round missed failed open. Rounds 3 and 4 proved it by probe: the market index refresh preview returned other branches' customers and exposure; the customer scoped pricing and tax reads answered about branch B; a pricing rule naming a branch B customer's job set that customer's price with no audit row; dealer wide pricing writes (a rule naming neither customer nor job, a tier category rule) passed; and the GL, AP and bank reconciliation writes, the staff roster and the unfiltered pricing lists were open | Deny by default. One allowlist classifies every machine key reachable route as exactly one of WALLED (183, behind the branch middleware), CONFINED (28, held by the key branch wall or the customer branch wall, each entry naming its test), STATED LIMIT (69, reachable unconfined by design, each entry with its one line reason) or REFUSED (65, answered 403 `forbidden` with the `key.branch_refused` row). The one runtime check is `KeyBranchRouteGate`, mounted once in serve's chain inside auth: it resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, a route the router resolves to no pattern included, so a missed or new route fails closed. A census test (untagged, no database) fails when a machine key reachable census route is unclassified or a table entry is stale. The confinements are completed: `job_id` resolves to its projects row's customer beside `customer_id` (both are checked), the customer scoped reads that name a customer in a path, query or body are confined like the writes (the exemption read, the category rule list by `customer_id` with the unfiltered list refused, the rule audit, resolve, calculate, the tax preview), and a write that would be dealer wide is refused: a pricing rule naming neither a customer nor a job, a category rule or bulk element targeting a tier, and a bulk delete naming a tier row. REFUSED outright beside the round 3 list: the GL, AP and bank reconciliation writes (reads already refused), the staff roster and its module grants, the index refresh preview, `GET /pricing/rules`, `GET /pricing/matrix` and the unfiltered category rule list. Stated limits stay reachable with their reasons in the table, and ADR 0007 section 5.5 names them and advises which scopes not to mint on a bound key. Users and unbound keys keep exactly their reach on every route | | SEC-BOUND-KEY-PIN-3 | the branch bound key's classes and walls, fix round 3 (the same 345 census routes as SEC-BOUND-KEY-PIN) | Rounds 5 and 6 proved by probe that three classes overstated: a tier category rule that also named a customer passed the customer wall and priced every branch's customers of the tier (201, no audit row); the POS till, Z report, return and line routes and the account summary, AR statement and reconciliation loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), added a line to B's transaction that stayed written behind the 500, and read B's till report, Z reports, returns, account, statement and reconciliation; `GET /pricing/calculate` confined its `customer_id` but not its `job_id`; a tax preview naming only B's `ship_to_id` passed; and the 18 dealer wide catalogue writes labelled WALLED (products, PIM, charge codes, payment terms, the reorder refresh) and every other stated limit write were reachable by a bound key | The category rule wall, handler and table now keep one invariant (an account rule carries no tier, a tier rule no customer), and migration 107 puts it on the table checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when one exists, validated on add otherwise. The pos repositories carry the branch predicate the transaction reads already carried (`till_sessions.branch_id`, `till_z_reports.branch_id`, `pos_returns.branch_id`, the line writes through their transaction's branch, the insert never happening on a foreign transaction), the account summary, subledger and statement mount the customer branch wall on the path customer, and the AR reconciliation is refused outright (its ledger half carries no branch fact). The calculation resolves `job_id` to `projects.customer_id` like the write; a tax preview naming a `ship_to_id` is confined through that ship to's customer. The classes are split per the principal's rulings: a stated limit is a read, never a write (WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118), so every dealer wide write (the catalogue and PIM writes above, the settings, module and app switches, the match tolerances, vendors, EDI partners and the catalog import, units, pricing categories, rebates, millwork options, governance rfcs, the index metadata) is refused with the `key.branch_refused` row, the driver routes are refused reads included (personal data, no branch column), and the vehicle reads stay stated limits. Every WALLED entry names the column or join its module filters on and a twin sweep drives every WALLED id or list route with the other branch's row (refused, or the missing row answer, nothing written). A shared customer may be read by a bound key but its customer scoped pricing rules and tax exemptions are refused unless the pin holds the customer alone, the refusal naming the other branch; the grant, revoke and home branch writes record their audit rows with the key as the actor; the parsing upload and vision scan stay stated limits and each call records its AI spend audit row with the key that spent it | | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | +| SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | From 88ca77322d15060902fdbb3f637bf946dd35e3e7 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 20:10:37 -0700 Subject: [PATCH 49/74] style: gofmt the round 5 test files Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 18 +++++++++--------- .../app/serve/wire_key_branch_round5_test.go | 4 +++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 5e948352..5f540ac6 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -115,15 +115,15 @@ type keyPinFixture struct { retB uuid.UUID zrA uuid.UUID // a frozen Z report per branch's session zrB uuid.UUID - posProd uuid.UUID // the product the POS lines carry - bound string // the key pinned to branch A - unbound string // the same scopes, no pin - bound2 string // a second bound key holding the document family scopes the mint cap keeps off the first - unbound2 string // the same scopes, no pin - boundKeyID string // the bound key's id, the actor its refusal audit rows name - bound2KeyID string // the second bound key's id - boundFirst string // the first mint's bound key, kept when a test swaps in the second - recording bool // mark every bound key call for the walled route coverage test + posProd uuid.UUID // the product the POS lines carry + bound string // the key pinned to branch A + unbound string // the same scopes, no pin + bound2 string // a second bound key holding the document family scopes the mint cap keeps off the first + unbound2 string // the same scopes, no pin + boundKeyID string // the bound key's id, the actor its refusal audit rows name + bound2KeyID string // the second bound key's id + boundFirst string // the first mint's bound key, kept when a test swaps in the second + recording bool // mark every bound key call for the walled route coverage test keys *techadmin.Service // the mint the fixture's keys validate against } diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 1b948c01..87606fef 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -383,7 +383,9 @@ func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { if err := json.Unmarshal(buf, &plainKey); err != nil { t.Fatal(err) } - t.Cleanup(func() { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM api_keys WHERE id::text = $1`, plainKey.Key.ID) }) + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM api_keys WHERE id::text = $1`, plainKey.Key.ID) + }) if len(plainKey.Key.Scopes) != 0 { t.Errorf("a mint naming no scopes granted %v, want none", plainKey.Key.Scopes) } From 30d64122f4545c78b1ce02694d9f0c6b85d1b2af Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 20:31:04 -0700 Subject: [PATCH 50/74] ci: retrigger the workflow for the round 5 head The synchronize event of the round 5 push was not delivered: no run, check suite or check run exists for 88ca773 while the workflow kept serving every other pull request. An empty commit re-fires it; the tree is unchanged. Signed-off-by: colton From dd7275c9a2abd74facc3e144395bf62c9e1dbaef Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:50:37 -0700 Subject: [PATCH 51/74] security: a register's branch falls back to pos_registers.branch_id (PR 83 r13 P2-1) CreateTransaction, CreateTillSession and GetRegisterBranch derived the register's branch only through locations, so a register whose location names no branch skipped the pin check and wrote at the default branch. COALESCE(l.branch_id, r.branch_id) makes the NOT NULL column the fallback. Tests per register branch derivation plus r13's reproducing pos sync test; the allowlist notes now say what the code reads. Signed-off-by: colton --- .../app/serve/wire_key_branch_round6_test.go | 37 ++++++ core/internal/pos/register_branch_test.go | 105 ++++++++++++++++++ core/internal/pos/repository.go | 4 +- core/internal/pos/returns_repository.go | 6 +- core/internal/pos/till_repository.go | 2 +- core/pkg/middleware/keybranch_allow.go | 8 +- 6 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_round6_test.go create mode 100644 core/internal/pos/register_branch_test.go diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go new file mode 100644 index 00000000..e9d10502 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" +) + +// Fix round 6 (r13 P2-1): a register whose location_id names no branched +// location belongs to the branch its own pos_registers.branch_id names (the +// seeded REG-01 is one). A bound key's pos sync on it must be held to the pin, +// not written at the default branch. +func TestKeyBranchPin_PosSyncRegisterWithoutBranchedLocation(t *testing.T) { + f, _, first := round5Fixture(t) + ctx := context.Background() + client := uuid.New() + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, client) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, client) + }) + body := fmt.Sprintf(`{"batch_id":"pin-reg01","register_id":"REG-01","items":[{"client_id":%q,"register_id":"REG-01",`+ + `"cashier_id":%q,"customer_id":%q,"items":[],"tenders":[],"client_created_at":%q}]}`, + client, uuid.NewString(), f.custID, time.Now().UTC().Format(time.RFC3339)) + status, buf := f.key(t, first.raw, "POST", "/api/v1/pos/sync", body, nil) + var branch *uuid.UUID + err := f.db.Pool.QueryRow(ctx, `SELECT branch_id FROM pos_transactions WHERE id = $1`, client).Scan(&branch) + if err == nil && branch != nil && *branch != f.branchA { + t.Errorf("bound key sync on REG-01: %d %s; the replayed transaction landed at branch %s, not the pin %s", + status, buf, branch, f.branchA) + } +} diff --git a/core/internal/pos/register_branch_test.go b/core/internal/pos/register_branch_test.go new file mode 100644 index 00000000..023f81ff --- /dev/null +++ b/core/internal/pos/register_branch_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// registerBranchFixture seeds a register whose location_id names no location +// (the shape migration 027 gives the seeded REG-01), so only the NOT NULL +// pos_registers.branch_id says which branch it belongs to, and a second +// branch the caller can be pinned to. +func registerBranchFixture(t *testing.T) (repo *pos.PostgresRepository, register string, own, other uuid.UUID) { + t.Helper() + db := testutil.RequireDB(t) + ctx := context.Background() + repo = pos.NewRepository(db) + if err := db.Pool.QueryRow(ctx, + `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&own); err != nil { + t.Fatalf("read the default branch: %v", err) + } + other = uuid.New() + if _, err := db.Pool.Exec(ctx, + `INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, other, "RB-"+other.String()[:8]); err != nil { + t.Fatalf("insert the other branch: %v", err) + } + register = "RB-REG-" + other.String()[:8] + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_registers (id, location_id, branch_id, name) VALUES ($1, $2, $3, 'register branch test')`, + register, uuid.New(), own); err != nil { + t.Fatalf("insert the register: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE register_id = $1`, register) + _, _ = db.Pool.Exec(ctx, `DELETE FROM till_sessions WHERE register_id = $1`, register) + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_registers WHERE id = $1`, register) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, other) + }) + return repo, register, own, other +} + +func pinnedTo(branch uuid.UUID) context.Context { + return branchctx.With(context.Background(), &branchctx.Context{BranchID: &branch}) +} + +// A register with no branched location still belongs to the branch its own +// pos_registers.branch_id names: a caller pinned to another branch is refused +// and nothing is written, and the register's own branch writes there. +func TestCreateTransactionUsesTheRegistersOwnBranch(t *testing.T) { + repo, register, own, other := registerBranchFixture(t) + now := time.Now() + mk := func() *pos.POSTransaction { + return &pos.POSTransaction{RegisterID: register, CashierID: uuid.New(), Status: pos.TransactionStatusOpen, ClientCreatedAt: &now} + } + + refused := mk() + err := repo.CreateTransaction(pinnedTo(other), refused) + if err == nil || !strings.Contains(err.Error(), "different branch") { + t.Fatalf("a caller pinned to another branch: err = %v, want the different branch refusal", err) + } + var rows int + if e := testutil.RequireDB(t).Pool.QueryRow(context.Background(), + `SELECT count(*) FROM pos_transactions WHERE register_id = $1`, register).Scan(&rows); e != nil || rows != 0 { + t.Fatalf("the refused create left %d rows (err %v), want 0", rows, e) + } + + ok := mk() + if err := repo.CreateTransaction(pinnedTo(own), ok); err != nil { + t.Fatalf("a caller on the register's own branch: %v", err) + } + if ok.BranchID != own { + t.Errorf("the transaction landed at %s, want the register's branch %s", ok.BranchID, own) + } +} + +func TestGetRegisterBranchFallsBackToTheRegistersOwnBranch(t *testing.T) { + repo, register, own, _ := registerBranchFixture(t) + got, err := repo.GetRegisterBranch(context.Background(), register) + if err != nil { + t.Fatalf("GetRegisterBranch: %v", err) + } + if got == nil || *got != own { + t.Errorf("GetRegisterBranch = %v, want %s (the returns path stamps and taxes by it)", got, own) + } +} + +func TestCreateTillSessionStampsTheRegistersOwnBranch(t *testing.T) { + repo, register, own, _ := registerBranchFixture(t) + s := &pos.TillSession{RegisterID: register, CashierID: uuid.New(), Status: pos.TillSessionOpen, OpeningFloat: 5000} + if err := repo.CreateTillSession(context.Background(), s); err != nil { + t.Fatalf("CreateTillSession: %v", err) + } + if s.BranchID == nil || *s.BranchID != own { + t.Errorf("the till session branch = %v, want the register's branch %s", s.BranchID, own) + } +} diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 48775837..088c01cb 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -72,10 +72,10 @@ func (r *PostgresRepository) CreateTransaction(ctx context.Context, tx *POSTrans } tx.CreatedAt = time.Now() - // Derive the register's branch_id (via its assigned location). + // Derive the register's branch_id (its location's branch, else its own pos_registers.branch_id). var registerBranch *uuid.UUID err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id + `SELECT COALESCE(l.branch_id, r.branch_id) FROM pos_registers r LEFT JOIN locations l ON l.id = r.location_id WHERE r.id = $1`, tx.RegisterID).Scan(®isterBranch) diff --git a/core/internal/pos/returns_repository.go b/core/internal/pos/returns_repository.go index f94359af..def20ef4 100644 --- a/core/internal/pos/returns_repository.go +++ b/core/internal/pos/returns_repository.go @@ -14,12 +14,12 @@ import ( "github.com/jackc/pgx/v5" ) -// GetRegisterBranch resolves a register's branch (via its assigned location). -// Returns (nil, nil) when the register has no location/branch mapping. +// GetRegisterBranch resolves a register's branch (its location's branch, else its own pos_registers.branch_id). +// Returns (nil, nil) when the register does not exist. func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { var branchID *uuid.UUID err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id + `SELECT COALESCE(l.branch_id, r.branch_id) FROM pos_registers r LEFT JOIN locations l ON l.id = r.location_id WHERE r.id = $1`, registerID).Scan(&branchID) diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index be6fee5d..f52fb167 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -34,7 +34,7 @@ func (r *PostgresRepository) CreateTillSession(ctx context.Context, s *TillSessi if s.BranchID == nil { var registerBranch *uuid.UUID err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id + `SELECT COALESCE(l.branch_id, r.branch_id) FROM pos_registers r LEFT JOIN locations l ON l.id = r.location_id WHERE r.id = $1`, s.RegisterID).Scan(®isterBranch) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index b2b7611d..92676a57 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -397,11 +397,11 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, - "POST /api/v1/pos/returns": {KeyBranchClassWalled, "pos_registers.branch_id on create; no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "pos_registers.branch_id on every replayed create, and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the register's branch (pos_registers.branch_id, refused on a mismatch) and no machine key cashier"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id); no machine key cashier"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create, and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, - "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_registers.branch_id on create, pos_transactions.branch_id after; no machine key cashier"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after; no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, From 7f7ec90b6f12184617c709dee5b5c14ca557fbfe Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:52:20 -0700 Subject: [PATCH 52/74] security: shared customer caller refusals write a row and use the bound key's words (PR 83 r13 P3-1) A user without the finance or owner role and an unbound key without customers:restricted were refused with different words from the bound key's and left no audit row. They now carry the bound key's words, and a customer.shared_restricted_refused row names the actor, the fields, the lowest numbered holding branch and the method and path. Signed-off-by: colton --- .../app/serve/wire_key_branch_round6_test.go | 71 +++++++++++++++++++ .../customer/customerguard/customerguard.go | 5 ++ core/internal/customer/shared.go | 43 ++++++++--- core/pkg/audit/audit.go | 30 ++++++++ core/pkg/middleware/keybranch_shared.go | 26 +++++++ 5 files changed, 165 insertions(+), 10 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index e9d10502..9a6f6642 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -6,6 +6,8 @@ package serve import ( "context" "fmt" + "net/http" + "strings" "testing" "time" @@ -35,3 +37,72 @@ func TestKeyBranchPin_PosSyncRegisterWithoutBranchedLocation(t *testing.T) { status, buf, branch, f.branchA) } } + +// sharedRefusalRows counts the customer.shared_restricted_refused rows of a +// customer, the audit row of a user's or an unbound key's refusal. +func (f *keyPinFixture) sharedRefusalRows(t *testing.T, customer uuid.UUID) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'customer.shared_restricted_refused' AND entity_id = $1`, + customer).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// Fix round 6 (r13 P3-1): the unbound key and the user refused on a shared +// customer's restricted fields get the bound key's words and leave a refusal +// row of their own; the callers the rule admits leave none. +func TestKeyBranchPin_SharedCallerRefusalRow(t *testing.T) { + f, _, _ := round5Fixture(t) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + sh := "/api/v1/customers/" + s.id.String() + change := func() string { + return f.customerReplace(t, s.id, map[string]any{"credit_limit_cents": 555000 + int(time.Now().UnixNano()%1000)}) + } + lowest := f.branchA + if f.branchB.String() < f.branchA.String() { + lowest = f.branchB + } + words := "a branch bound key may act only on its own branch: the customer is also held by branch " + lowest.String() + + check := func(who string, status int, buf []byte, rowsBefore int) { + t.Helper() + if status != http.StatusForbidden || !strings.Contains(string(buf), words) { + t.Errorf("%s changing a restricted field: %d %s, want the 403 in the bound key's words naming branch %s", who, status, buf, lowest) + } + if got := f.sharedRefusalRows(t, s.id) - rowsBefore; got != 1 { + t.Errorf("%s refusal left %d refusal rows, want one", who, got) + } + } + before := f.sharedRefusalRows(t, s.id) + status, buf := f.key(t, f.unbound, "PUT", sh, change(), nil) + check("unbound key without the scope", status, buf, before) + for _, role := range []string{"sales", "admin"} { + before = f.sharedRefusalRows(t, s.id) + status, buf = f.userAs(t, role, "PUT", sh, change(), nil) + check(role+" user", status, buf, before) + } + // The row names the actor, the fields and the method. + var kind, raw string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT changes->>'actor_kind', changes::text FROM audit_log + WHERE action = 'customer.shared_restricted_refused' AND entity_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, + s.id).Scan(&kind, &raw); err != nil { + t.Fatal(err) + } + if kind != "user" || !strings.Contains(raw, "credit_limit_cents") || !strings.Contains(raw, "PUT") { + t.Errorf("the refusal row reads %s, want a user actor, the refused field and the method", raw) + } + // Admitted callers leave no refusal row. + before = f.sharedRefusalRows(t, s.id) + for _, role := range []string{"finance", "owner"} { + if status, buf := f.userAs(t, role, "PUT", sh, change(), nil); status != http.StatusOK { + t.Errorf("%s user: %d %s, want 200", role, status, buf) + } + } + if got := f.sharedRefusalRows(t, s.id) - before; got != 0 { + t.Errorf("admitted callers left %d refusal rows, want none", got) + } +} diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index 6dd8da43..2938d5e0 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -49,6 +49,11 @@ func (a *Shared) Shared(ctx context.Context, customerID uuid.UUID) (customer.Sha // RecordRefusal writes the refusal's key.branch_refused row. func (a *Shared) RecordRefusal(ctx context.Context) { a.w.RecordSharedRefusal(ctx) } +// RecordCallerRefusal writes the refusal row of a user or an unbound key. +func (a *Shared) RecordCallerRefusal(ctx context.Context, customerID, branch uuid.UUID, fields []string) { + a.w.RecordSharedCallerRefusal(ctx, customerID, branch, fields) +} + // RecordChange writes the dealer wide key's audit row. func (a *Shared) RecordChange(ctx context.Context, customerID uuid.UUID, changes map[string]any) error { return a.w.RecordSharedChange(ctx, customerID, changes) diff --git a/core/internal/customer/shared.go b/core/internal/customer/shared.go index 7a79f606..2eb60133 100644 --- a/core/internal/customer/shared.go +++ b/core/internal/customer/shared.go @@ -64,6 +64,10 @@ type SharedGuard interface { // RecordRefusal writes the refusal's audit row; it runs after the refused // write's transaction has rolled back. RecordRefusal(ctx context.Context) + // RecordCallerRefusal writes the audit row of a refusal served to a user + // or an unbound key (no pin, so no key.branch_refused row); it runs after + // the refused write's transaction has rolled back. + RecordCallerRefusal(ctx context.Context, customerID, branch uuid.UUID, fields []string) // RecordChange writes the audit row of a dealer wide key's restricted // change, inside the write's transaction. RecordChange(ctx context.Context, customerID uuid.UUID, changes map[string]any) error @@ -77,30 +81,45 @@ func (s *Service) WithSharedGuard(g SharedGuard) *Service { // sharedRefusal is the 403 a bound key gets on a restricted write; it carries // the marker the outer method turns into the refusal's audit row. -type sharedRefusal struct{ err *httpx.Error } +type sharedRefusal struct { + err *httpx.Error + // caller marks a refusal served to a user or an unbound key; the rest + // is what its audit row names. + caller bool + customer uuid.UUID + branch uuid.UUID + fields []string +} func (e *sharedRefusal) Error() string { return e.err.Message } func refusedOn(branch uuid.UUID, fields ...string) *sharedRefusal { - return refusedSharing("a branch bound key may act only on its own branch", branch, fields...) + return refusedSharing(branch, fields...) } // refusedCallerOn is the refusal a caller without the finance or owner role // (a user) or without the customers:restricted scope (an unbound key) gets on -// a shared customer's restricted fields: the same words, naming a branch that -// holds the customer (principal ruling item 48). -func refusedCallerOn(branch uuid.UUID, fields ...string) *sharedRefusal { - return refusedSharing("a shared customer's restricted fields change only with the finance or owner role, or an unbound key carrying customers:restricted", branch, fields...) +// a shared customer's restricted fields: the bound key's words, naming the +// lowest numbered branch that holds the customer (principal ruling item 48, +// ADR 0007 section 5.5). Its audit row is the caller refusal row. +func refusedCallerOn(customer, branch uuid.UUID, fields ...string) *sharedRefusal { + ref := refusedSharing(branch, fields...) + ref.caller, ref.customer, ref.branch, ref.fields = true, customer, branch, fields + return ref } -func refusedSharing(rule string, branch uuid.UUID, fields ...string) *sharedRefusal { +// sharedRefusalWords is the one rule sentence every shared customer refusal +// carries, whoever the caller is. +const sharedRefusalWords = "a branch bound key may act only on its own branch" + +func refusedSharing(branch uuid.UUID, fields ...string) *sharedRefusal { sort.Strings(fields) details := make([]httpx.FieldError, 0, len(fields)) for _, f := range fields { details = append(details, httpx.FieldError{Field: f, Code: httpx.CodeForbidden, Message: "shared with another branch"}) } return &sharedRefusal{err: &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, - Message: rule + ": the customer is also held by branch " + branch.String(), + Message: sharedRefusalWords + ": the customer is also held by branch " + branch.String(), Details: details}} } @@ -110,7 +129,11 @@ func (s *Service) sharedOutcome(ctx context.Context, err error) error { var ref *sharedRefusal if errors.As(err, &ref) { if s.shared != nil { - s.shared.RecordRefusal(ctx) + if ref.caller { + s.shared.RecordCallerRefusal(ctx, ref.customer, ref.branch, ref.fields) + } else { + s.shared.RecordRefusal(ctx) + } } return ref.err } @@ -137,7 +160,7 @@ func (s *Service) checkRestricted(ctx context.Context, id uuid.UUID, changes map return refusedOn(verdict.Branch, fields...) } if verdict.RefuseCaller { - return refusedCallerOn(verdict.Branch, fields...) + return refusedCallerOn(id, verdict.Branch, fields...) } if verdict.Audit { return s.shared.RecordChange(ctx, id, changes) diff --git a/core/pkg/audit/audit.go b/core/pkg/audit/audit.go index 2fd23a79..0d60b32e 100644 --- a/core/pkg/audit/audit.go +++ b/core/pkg/audit/audit.go @@ -450,6 +450,36 @@ func (l *Logger) AuditSharedCustomerChange(ctx context.Context, keyID string, cu }) } +// AuditSharedCustomerRefusal records a user's or an unbound key's refused +// change to the restricted fields of a customer held by more than one branch +// (ADR 0007 section 5.5): the row's entity is the customer, its changes name +// the actor, the refused fields, the lowest numbered branch holding the +// customer and the bounded method and path. It implements the middleware +// package's SharedRefusalAuditor seam. A failure to write is logged and +// swallowed: the refusal has already been served. +func (l *Logger) AuditSharedCustomerRefusal(ctx context.Context, actorKind, actorID string, customer, branch uuid.UUID, fields []string, method, path string) { + storedPath, truncated := cutRunes(path, maxRefusalPathBytes) + changes := map[string]interface{}{ + "actor_kind": actorKind, + "actor_id": actorID, + "branch_id": branch.String(), + "fields": fields, + "method": method, + "path": storedPath, + } + if truncated { + changes["path_truncated"] = true + } + if err := l.Log(ctx, Entry{ + Action: "customer.shared_restricted_refused", + EntityType: "customer", + EntityID: customer, + Changes: changes, + }); err != nil { + slog.Error("audit: failed to write shared customer refusal", "customer_id", customer, "error", err) + } +} + // Drain is retained for graceful-shutdown callers: writes are synchronous // now, so there is never anything in flight to wait for. func (l *Logger) Drain() {} diff --git a/core/pkg/middleware/keybranch_shared.go b/core/pkg/middleware/keybranch_shared.go index 429c5cc6..9fa15f49 100644 --- a/core/pkg/middleware/keybranch_shared.go +++ b/core/pkg/middleware/keybranch_shared.go @@ -61,6 +61,14 @@ type SharedAuditor interface { AuditSharedCustomerChange(ctx context.Context, keyID string, customer uuid.UUID, changes map[string]any) error } +// SharedRefusalAuditor is the optional audit seam for the refusal of a user or +// an unbound key on a shared customer's restricted fields: those callers have +// no pin, so the key.branch_refused row does not fit them. The row's entity is +// the customer. +type SharedRefusalAuditor interface { + AuditSharedCustomerRefusal(ctx context.Context, actorKind, actorID string, customer, branch uuid.UUID, fields []string, method, path string) +} + // callerHasRole reports whether the user claims hold one of the roles. func callerHasRole(claims *UserClaims, roles ...string) bool { if claims == nil { @@ -167,6 +175,24 @@ func (w *KeyBranchWall) RecordSharedRefusal(ctx context.Context) { w.auditor.AuditKeyBranchRefusal(ctx, keyID, pin, note.method, note.path) } +// RecordSharedCallerRefusal writes the audit row of a refusal served to a user +// or an unbound key, outside the refused write's transaction. A bound key's +// refusal has its own row (RecordSharedRefusal). +func (w *KeyBranchWall) RecordSharedCallerRefusal(ctx context.Context, customer, branch uuid.UUID, fields []string) { + a, ok := w.auditor.(SharedRefusalAuditor) + if !ok { + return + } + kind, actor := "user", "" + if keyID, isKey := KeyIDFromContext(ctx); isKey { + kind, actor = "key", keyID + } else if claims := ClaimsFromContext(ctx); claims != nil { + actor = claims.Subject + } + note, _ := ctx.Value(requestNoteKey{}).(requestNote) + a.AuditSharedCustomerRefusal(ctx, kind, actor, customer, branch, fields, note.method, note.path) +} + // RecordSharedChange writes the audit row of an unbound key's change to a // shared customer's restricted fields, inside the caller's transaction. A // wall with no audit writer fails the change rather than let it go unrecorded. From dc8947fc248f547a7b8d4d004edcfc72bf5724c1 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:53:15 -0700 Subject: [PATCH 53/74] test: the stored job recheck, the claims-less mint refusal and a truly scoped bound key (PR 83 r12 P3s) Each test goes red when its guard is removed: the conversion's stored job recheck, ownerGrantsRestricted with no claims, and a bound key that carries customers:restricted on a shared customer. Signed-off-by: colton --- .../app/serve/wire_key_branch_round6_test.go | 65 +++++++++++++++++++ .../techadmin/restricted_scope_test.go | 25 +++++++ 2 files changed, 90 insertions(+) create mode 100644 core/internal/techadmin/restricted_scope_test.go diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index 9a6f6642..9bbcef88 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -106,3 +106,68 @@ func TestKeyBranchPin_SharedCallerRefusalRow(t *testing.T) { t.Errorf("admitted callers left %d refusal rows, want none", got) } } + +// Fix round 6 (r12 P3-1): the conversion rechecks the quote's stored job +// against the quote's customer, so a legacy row whose job belongs to another +// customer cannot price the converted order. No order is written. +func TestKeyBranchPin_QuoteConvertRechecksStoredJob(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + body := fmt.Sprintf(`{"customer_id":%q,"branch_id":%q,"lines":[%s]}`, + f.custID.String(), f.branchA.String(), f.quoteLine()) + quote := f.made(t, "user", "quotes", "POST", "/api/v1/quotes", body, http.StatusCreated) + if _, err := f.db.Pool.Exec(ctx, `UPDATE quotes SET project_id = $2 WHERE id = $1`, quote, f.jobB); err != nil { + t.Fatalf("store the foreign job on the quote: %v", err) + } + ordersNaming := func() (n int) { + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM orders WHERE project_id = $1`, f.jobB).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + before := ordersNaming() + rev := f.readJSON(t, "/api/v1/quotes/"+quote.String())["revision"] + status, buf := f.send(t, "user", "POST", "/api/v1/quotes/"+quote.String()+"/convert", "", + map[string]string{"If-Match": fmt.Sprintf(`"%v"`, rev)}) + if status != http.StatusBadRequest || !strings.Contains(string(buf), "job_id") { + t.Errorf("converting a quote whose stored job is another customer's: %d %s, want the 400 naming job_id", status, buf) + } + if after := ordersNaming(); after != before { + t.Errorf("the refused convert wrote %d orders naming the foreign job", after-before) + } +} + +// Fix round 6 (r12 P3-3): a branch bound key that truly carries +// customers:restricted is still refused on a shared customer's restricted +// field (the pin rule comes before the scope), with one refusal row; on a +// customer the pin holds alone it changes the field. +func TestKeyBranchPin_BoundKeyWithTheScopeIsRefused(t *testing.T) { + f, _, _ := round5Fixture(t) + scoped := f.mintWithScopes(t, "pin bound restricted", + []string{"customers:read", "customers:write", "customers:restricted"}, &f.branchA) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + change := func(id uuid.UUID) string { + return f.customerReplace(t, id, map[string]any{"credit_limit_cents": 444000 + int(time.Now().UnixNano()%1000)}) + } + // The refusal row names the path, which is this customer's alone. + refusals := func() (n int) { + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'key.branch_refused' AND changes->>'path' = $1`, + "/api/v1/customers/"+s.id.String()).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + rows := refusals() + status, buf := f.key(t, scoped, "PUT", "/api/v1/customers/"+s.id.String(), change(s.id), nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + t.Errorf("scoped bound key changing a shared customer's restricted field: %d %s, want the pin's 403", status, buf) + } + if after := refusals(); after != rows+1 { + t.Errorf("scoped bound key refusal: key.branch_refused rows %d then %d, want one new row", rows, after) + } + // The control: a customer the pin holds alone. + if status, buf := f.key(t, scoped, "PUT", "/api/v1/customers/"+f.custID.String(), change(f.custID), nil); status != http.StatusOK { + t.Errorf("scoped bound key changing its own customer's restricted field: %d %s, want 200", status, buf) + } +} diff --git a/core/internal/techadmin/restricted_scope_test.go b/core/internal/techadmin/restricted_scope_test.go new file mode 100644 index 00000000..d8953f9d --- /dev/null +++ b/core/internal/techadmin/restricted_scope_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package techadmin + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gablelbm/gable/pkg/middleware" +) + +// A mint that names customers:restricted and carries no claims at all is +// refused, fail closed: with no caller there is no owner to grant it. A mint +// naming other scopes needs no claims here (the route's role guard speaks). +func TestOwnerGrantsRestrictedFailsClosedWithoutClaims(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/v1/admin/keys", nil) + if err := ownerGrantsRestricted(r, []string{"customers:read", middleware.CustomerRestrictedScope}); err == nil { + t.Error("a claims-less mint of customers:restricted was allowed, want the refusal") + } + if err := ownerGrantsRestricted(r, []string{"customers:read"}); err != nil { + t.Errorf("a claims-less mint of another scope: %v, want no restriction from this check", err) + } +} From 40aadbd8139e1e6b07fc0077aa00404927be3d3b Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:55:10 -0700 Subject: [PATCH 54/74] security: finance reaches only the restricted customer field routes (PR 83, item 54 question 1) The customer group admitted finance on every route. A third role guard now admits finance on the header write, the salesperson and the escalation policy only; customer and contact create, the reads and the other writes answer finance what refactor/v1 answered. No ADR or role catalog row grants finance more. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 9 ++-- .../app/serve/wire_key_branch_round6_test.go | 47 +++++++++++++++++++ core/internal/customer/handler.go | 20 +++++--- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 93162bf0..8e0501a1 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -120,13 +120,16 @@ func (w *branchWall) inventory(mux *http.ServeMux, svc *inventory.Service) { // holds, and adds but never changes or deletes its ship-tos and contacts; a // shared customer's restricted fields change only for the finance or owner // role (a user) or an unbound key carrying customers:restricted, which is why -// the guard admits finance here. The routes note their method and path so a +// the third guard admits finance on exactly the three restricted field routes +// (the header write, the salesperson and the escalation policy) and nowhere +// else: every other customer route keeps the roles it had. The routes note their method and path so a // refusal the service serves can name them in its key.branch_refused row. func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { svc.WithBranchGuard(customerguard.New(w.guard)).WithSharedGuard(customerguard.NewShared(w.keyWall)) customer.NewHandler(svc).RegisterRoutes(mux, - middleware.Compose(w.scoped("admin", "owner", "sales", "finance"), w.keyWall.RecordRequest()), - w.scoped("admin", "owner", "finance")) + middleware.Compose(w.scoped("admin", "owner", "sales"), w.keyWall.RecordRequest()), + w.scoped("admin", "owner", "finance"), + middleware.Compose(w.scoped("admin", "owner", "sales", "finance"), w.keyWall.RecordRequest())) } // crm mounts the activity routes behind the branch middleware: every route diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index 9bbcef88..3beef7f8 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -5,6 +5,7 @@ package serve import ( "context" + "encoding/json" "fmt" "net/http" "strings" @@ -171,3 +172,49 @@ func TestKeyBranchPin_BoundKeyWithTheScopeIsRefused(t *testing.T) { t.Errorf("scoped bound key changing its own customer's restricted field: %d %s, want 200", status, buf) } } + +// Fix round 6 (item 54 question 1): the finance role reaches only the three +// restricted field routes of a customer, not every customer route. Customer +// and contact create, the reads and the other writes answer finance what +// refactor/v1 answered (403 insufficient role); the restricted changes pass. +func TestKeyBranchPin_FinanceReachesOnlyTheRestrictedRoutes(t *testing.T) { + f, _, _ := round5Fixture(t) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + sh := "/api/v1/customers/" + s.id.String() + + refused := []struct{ method, path, body string }{ + {"POST", "/api/v1/customers", `{"name":"finance create"}`}, + {"POST", sh + "/contacts", `{"name":"finance contact"}`}, + {"POST", sh + "/ship-tos", `{"name":"finance ship to"}`}, + {"GET", sh, ""}, + {"GET", "/api/v1/customers", ""}, + {"GET", sh + "/contacts", ""}, + {"GET", sh + "/escalation-policy", ""}, + } + for _, r := range refused { + status, buf := f.userAs(t, "finance", r.method, r.path, r.body, nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "insufficient role") { + t.Errorf("finance %s %s: %d %s, want the 403 insufficient role refactor/v1 answers", r.method, r.path, status, buf) + } + } + // The sales user keeps the reads. + if status, buf := f.userAs(t, "sales", "GET", sh, "", nil); status != http.StatusOK { + t.Errorf("sales GET customer: %d %s, want 200", status, buf) + } + + // The three restricted field routes pass for finance on the shared customer. + if status, buf := f.userAs(t, "finance", "PUT", sh, + f.customerReplace(t, s.id, map[string]any{"credit_limit_cents": 333000}), nil); status != http.StatusOK { + t.Errorf("finance changing the credit limit: %d %s, want 200", status, buf) + } + cur := f.readJSON(t, sh) + raw, _ := json.Marshal(map[string]any{"salesperson_id": s.salesperson.String(), "revision": cur["revision"]}) + if status, buf := f.userAs(t, "finance", "PATCH", sh+"/salesperson", string(raw), nil); status != http.StatusOK { + t.Errorf("finance changing the salesperson: %d %s, want 200", status, buf) + } + esc := f.readJSON(t, sh+"/escalation-policy") + raw, _ = json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": "7.5", "revision": esc["revision"]}) + if status, buf := f.userAs(t, "finance", "PUT", sh+"/escalation-policy", string(raw), nil); status != http.StatusOK { + t.Errorf("finance setting the escalation policy: %d %s, want 200", status, buf) + } +} diff --git a/core/internal/customer/handler.go b/core/internal/customer/handler.go index 68d65a90..bbabe40d 100644 --- a/core/internal/customer/handler.go +++ b/core/internal/customer/handler.go @@ -33,7 +33,10 @@ func NewHandler(service *Service) *Handler { // RegisterRoutes registers every route behind the first guard. A second guard, // when given, replaces it on the writes of the payment terms master (creating -// and editing terms is a finance act, choosing them is a sales one). +// and editing terms is a finance act, choosing them is a sales one). A third +// guard, when given, replaces it on the three routes that change a customer's +// restricted fields (the header write, the salesperson and the escalation +// policy), so a role can reach those without reaching the rest. func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guardWith := func(g func(http.Handler) http.Handler) func(http.HandlerFunc) http.HandlerFunc { return func(handler http.HandlerFunc) http.HandlerFunc { @@ -45,22 +48,25 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand } } } - var first, second func(http.Handler) http.Handler + var first, second, third func(http.Handler) http.Handler if len(roleGuard) > 0 { - first, second = roleGuard[0], roleGuard[0] + first, second, third = roleGuard[0], roleGuard[0], roleGuard[0] } if len(roleGuard) > 1 && roleGuard[1] != nil { second = roleGuard[1] } - guard, termsWrite := guardWith(first), guardWith(second) + if len(roleGuard) > 2 && roleGuard[2] != nil { + third = roleGuard[2] + } + guard, termsWrite, restricted := guardWith(first), guardWith(second), guardWith(third) mux.HandleFunc("GET /api/v1/customers", guard(h.HandleListCustomers)) mux.HandleFunc("POST /api/v1/customers", guard(h.HandleCreateCustomer)) mux.HandleFunc("GET /api/v1/customers/{id}", guard(h.HandleGetCustomer)) - mux.HandleFunc("PUT /api/v1/customers/{id}", guard(h.HandleUpdateCustomer)) - mux.HandleFunc("PATCH /api/v1/customers/{id}/salesperson", guard(h.HandleUpdateSalesperson)) + mux.HandleFunc("PUT /api/v1/customers/{id}", restricted(h.HandleUpdateCustomer)) + mux.HandleFunc("PATCH /api/v1/customers/{id}/salesperson", restricted(h.HandleUpdateSalesperson)) mux.HandleFunc("GET /api/v1/customers/{id}/escalation-policy", guard(h.HandleGetEscalationPolicy)) - mux.HandleFunc("PUT /api/v1/customers/{id}/escalation-policy", guard(h.HandleSetEscalationPolicy)) + mux.HandleFunc("PUT /api/v1/customers/{id}/escalation-policy", restricted(h.HandleSetEscalationPolicy)) mux.HandleFunc("GET /api/v1/price_levels", guard(h.HandleListPriceLevels)) mux.HandleFunc("GET /api/v1/customers/{id}/ship-tos", guard(h.HandleListShipTos)) From 8f06f7155f2cfc0b1105da1edd9b29ca21847236 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:56:29 -0700 Subject: [PATCH 55/74] security: activities follow the contact rule on a shared customer (PR 83, item 54 question 2) An activity has no branch tag, so on a customer another branch also holds a bound key adds one and never changes or deletes one: 403 in the contact rule's words with a key.branch_refused row, the activity untouched. The crm service takes the shared guard through the customer guard adapter. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 9 ++- .../app/serve/wire_key_branch_round6_test.go | 81 +++++++++++++++++++ core/internal/crm/service.go | 61 +++++++++++++- .../customer/customerguard/customerguard.go | 14 ++++ core/internal/customer/shared.go | 6 ++ 5 files changed, 166 insertions(+), 5 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 8e0501a1..3223f976 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -135,9 +135,14 @@ func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { // crm mounts the activity routes behind the branch middleware: every route // addresses a customer (by path) or an activity of one, and the repositories // hold each read and write to the customer's branches, so a caller held to -// branch A finds branch B's activity a 404. +// branch A finds branch B's activity a 404. An activity carries no branch tag, +// so on a customer another branch also holds a branch bound key adds but never +// changes or deletes one (the contact rule, principal item 54); the routes note +// their method and path for the refusal's key.branch_refused row. func (w *branchWall) crm(mux *http.ServeMux, svc *crm.Service) { - crm.NewHandler(svc).RegisterRoutes(mux, w.scoped("admin", "owner", "sales")) + svc.WithSharedGuard(customerguard.NewShared(w.keyWall)) + crm.NewHandler(svc).RegisterRoutes(mux, + middleware.Compose(w.scoped("admin", "owner", "sales"), w.keyWall.RecordRequest())) } // delivery mounts the delivery routes behind the branch middleware: a stop diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index 3beef7f8..5f7638c6 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -218,3 +218,84 @@ func TestKeyBranchPin_FinanceReachesOnlyTheRestrictedRoutes(t *testing.T) { t.Errorf("finance setting the escalation policy: %d %s, want 200", status, buf) } } + +// Fix round 6 (item 54 question 2): activities follow the contact rule. An +// activity carries no branch tag, so on a customer another branch also holds +// every existing one is shared: a bound key adds one and never changes or +// deletes one, refused in the contact rule's words with a refusal row and the +// activity untouched. On a customer the pin holds alone, and for an unbound +// key and a user, nothing changes. +func TestKeyBranchPin_ActivityFollowsTheContactRule(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + shared := "/api/v1/customers/" + s.id.String() + "/activities" + create := `{"activity_type":"call","description":"logged by a user"}` + + // A bound key may add one on the shared customer (the A control). + added := f.made(t, "bound", "crm_activities", "POST", shared, create, http.StatusCreated) + // An activity the user logged at the other branch's request. + logged := f.made(t, "user", "crm_activities", "POST", shared, create, http.StatusCreated) + path := "/api/v1/activities/" + logged.String() + state := func(id uuid.UUID) (desc string, rev int64, n int) { + t.Helper() + if err := f.db.Pool.QueryRow(ctx, + `SELECT COALESCE((SELECT description FROM crm_activities WHERE id = $1), ''), + COALESCE((SELECT revision FROM crm_activities WHERE id = $1), 0), + (SELECT COUNT(*) FROM crm_activities WHERE id = $1)`, id).Scan(&desc, &rev, &n); err != nil { + t.Fatal(err) + } + return desc, rev, n + } + _, rev, _ := state(logged) + ifMatch := map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)} + words := "a branch bound key may act only on its own branch" + + rows := f.auditRows(t) + status, buf := f.key(t, f.bound, "PUT", path, + fmt.Sprintf(`{"activity_type":"call","description":"rewritten by A","revision":%d}`, rev), nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), words) || !strings.Contains(string(buf), "activity") { + t.Errorf("bound key changing an activity on a shared customer: %d %s, want the 403 in the contact rule's words", status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("activity change refusal: key.branch_refused rows %d then %d, want one new row", rows, after) + } + rows = f.auditRows(t) + status, buf = f.key(t, f.bound, "DELETE", path, "", ifMatch) + if status != http.StatusForbidden || !strings.Contains(string(buf), words) { + t.Errorf("bound key deleting an activity on a shared customer: %d %s, want the 403", status, buf) + } + if after := f.auditRows(t); after != rows+1 { + t.Errorf("activity delete refusal: key.branch_refused rows %d then %d, want one new row", rows, after) + } + if desc, r, n := state(logged); n != 1 || desc != "logged by a user" || r != rev { + t.Errorf("the refused writes changed the activity: %d rows, %q, revision %d (was %d)", n, desc, r, rev) + } + // A bound key also cannot change or delete the one it added: no tag says + // it was logged at its branch. + if status, _ := f.key(t, f.bound, "DELETE", "/api/v1/activities/"+added.String(), "", + map[string]string{"If-Match": `"1"`}); status != http.StatusForbidden { + t.Errorf("bound key deleting the activity it added: %d, want 403 (an activity has no branch tag)", status) + } + + // The unbound key and the user keep the reach. + if status, buf := f.send(t, "unbound", "PUT", path, + fmt.Sprintf(`{"activity_type":"call","description":"changed by a dealer wide key","revision":%d}`, rev), nil); status != http.StatusOK { + t.Errorf("unbound key changing the activity: %d %s, want 200", status, buf) + } + if status, buf := f.userAs(t, "sales", "DELETE", "/api/v1/activities/"+added.String(), "", + map[string]string{"If-Match": `"1"`}); status != http.StatusNoContent { + t.Errorf("sales user deleting an activity: %d %s, want 204", status, buf) + } + + // The A control: a customer the pin holds alone. + own := f.made(t, "bound", "crm_activities", "POST", "/api/v1/customers/"+f.custID.String()+"/activities", create, http.StatusCreated) + if status, buf := f.key(t, f.bound, "PUT", "/api/v1/activities/"+own.String(), + `{"activity_type":"call","description":"own change","revision":1}`, nil); status != http.StatusOK { + t.Errorf("bound key changing an activity on its own customer: %d %s, want 200", status, buf) + } + if status, buf := f.key(t, f.bound, "DELETE", "/api/v1/activities/"+own.String(), "", + map[string]string{"If-Match": `"2"`}); status != http.StatusNoContent { + t.Errorf("bound key deleting an activity on its own customer: %d %s, want 204", status, buf) + } +} diff --git a/core/internal/crm/service.go b/core/internal/crm/service.go index 54867cd9..a6bb6c77 100644 --- a/core/internal/crm/service.go +++ b/core/internal/crm/service.go @@ -50,6 +50,27 @@ type Precondition struct { Revision *int64 } +// SharedGuard applies the shared customer rule to an activity (principal +// item 54). An activity carries no branch tag, so on a customer another branch +// also holds every existing one is shared: a branch bound key may add but not +// change or delete. The customer module owns the rule's words and its audit +// row; serve adapts it here. A nil guard (unit tests) leaves every write open. +type SharedGuard interface { + // CheckChild returns the refusal (a 403) for a bound key on a shared + // customer, nil for every other caller and customer. + CheckChild(ctx context.Context, customerID uuid.UUID, field string) error + // RecordRefusal writes the refusal's audit row, after the refused write's + // transaction has rolled back. + RecordRefusal(ctx context.Context) +} + +// sharedRefused marks a refusal raised inside a write's transaction so the +// outer method can write its audit row once the transaction is gone. +type sharedRefused struct{ err error } + +func (e *sharedRefused) Error() string { return e.err.Error() } +func (e *sharedRefused) Unwrap() error { return e.err } + // Service is the activity module's behaviour: every mutation is one // transaction that writes the row, its audit row and its event, in that // order, with the event last (ADR 0003 section 2). @@ -59,6 +80,7 @@ type Service struct { events EventRecorder tx TxRunner audit AuditLogger + shared SharedGuard } func NewService(repo Repository) *Service { @@ -74,6 +96,33 @@ func (s *Service) WithTxRunner(tx TxRunner) *Service { s.tx = tx; return s } // WithAudit wires the audit rows. func (s *Service) WithAudit(a AuditLogger) *Service { s.audit = a; return s } +// WithSharedGuard wires the shared customer rule. +func (s *Service) WithSharedGuard(g SharedGuard) *Service { s.shared = g; return s } + +// checkShared refuses a change or delete of an activity of a shared customer +// for a branch bound key. +func (s *Service) checkShared(ctx context.Context, customerID uuid.UUID) error { + if s.shared == nil { + return nil + } + if err := s.shared.CheckChild(ctx, customerID, "activity"); err != nil { + return &sharedRefused{err: err} + } + return nil +} + +// sharedOutcome writes the refusal's audit row and unwraps the 403. +func (s *Service) sharedOutcome(ctx context.Context, err error) error { + var ref *sharedRefused + if errors.As(err, &ref) { + if s.shared != nil { + s.shared.RecordRefusal(ctx) + } + return ref.err + } + return err +} + func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { if s.tx == nil { return fn(ctx) @@ -179,6 +228,9 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, d *Draft, pre Precon if err := httpx.CheckRevision(cur.Revision, pre.IfMatch, pre.Revision); err != nil { return err } + if err := s.checkShared(ctx, cur.CustomerID); err != nil { + return err + } if err := s.contactOwned(ctx, cur.CustomerID, d.ContactID); err != nil { return err } @@ -204,7 +256,7 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, d *Draft, pre Precon return s.record(ctx, out, EventUpdated) }) if err != nil { - return nil, err + return nil, s.sharedOutcome(ctx, err) } return out, nil } @@ -215,7 +267,7 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID, pre Precondition) er if pre.IfMatch == "" && pre.Revision == nil { return httpx.PreconditionRequired("this write needs If-Match") } - return s.inTx(ctx, func(ctx context.Context) error { + return s.sharedOutcome(ctx, s.inTx(ctx, func(ctx context.Context) error { if err := s.repo.Lock(ctx, id); err != nil { return notFound(err) } @@ -226,6 +278,9 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID, pre Precondition) er if err := httpx.CheckRevision(cur.Revision, pre.IfMatch, pre.Revision); err != nil { return err } + if err := s.checkShared(ctx, cur.CustomerID); err != nil { + return err + } if err := s.repo.Delete(ctx, id); err != nil { return err } @@ -236,7 +291,7 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID, pre Precondition) er } } return s.record(ctx, cur, EventDeleted) - }) + })) } // record writes the module's event as the transaction's last statement. diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index 2938d5e0..ce79c271 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -49,6 +49,20 @@ func (a *Shared) Shared(ctx context.Context, customerID uuid.UUID) (customer.Sha // RecordRefusal writes the refusal's key.branch_refused row. func (a *Shared) RecordRefusal(ctx context.Context) { a.w.RecordSharedRefusal(ctx) } +// CheckChild answers the shared customer rule for a child record of a customer +// that another module owns: the bound key's 403 on a shared customer, nil +// otherwise. A caller that is not a branch bound key is never refused. +func (a *Shared) CheckChild(ctx context.Context, customerID uuid.UUID, field string) error { + v, err := a.w.SharedCustomer(ctx, customerID) + if err != nil { + return err + } + if !v.Refuse { + return nil + } + return customer.RefusedOnChild(v.Branch, field) +} + // RecordCallerRefusal writes the refusal row of a user or an unbound key. func (a *Shared) RecordCallerRefusal(ctx context.Context, customerID, branch uuid.UUID, fields []string) { a.w.RecordSharedCallerRefusal(ctx, customerID, branch, fields) diff --git a/core/internal/customer/shared.go b/core/internal/customer/shared.go index 2eb60133..9e425b66 100644 --- a/core/internal/customer/shared.go +++ b/core/internal/customer/shared.go @@ -168,6 +168,12 @@ func (s *Service) checkRestricted(ctx context.Context, id uuid.UUID, changes map return nil } +// RefusedOnChild is the bound key's 403 on a shared customer's child record, +// for a module outside this package that serves the refusal itself. +func RefusedOnChild(branch uuid.UUID, field string) error { + return refusedOn(branch, field).err +} + // checkSharedChild applies the ship-to and contact rule: a bound key may not // change or delete one on a shared customer. No audit row for anyone else. func (s *Service) checkSharedChild(ctx context.Context, customerID uuid.UUID, field string) error { From 8ce0fc2ef44a99ecfa240a4e8fac260f2d7fcd1e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 21:56:56 -0700 Subject: [PATCH 56/74] docs: ADR 0007 section 5.5, the allowlist notes, SEC-BOUND-KEY-PIN-6 and the module pages state fix round 6 Signed-off-by: colton --- core/pkg/middleware/keybranch_allow.go | 4 ++-- ...7-drafts-links-and-confirm-gated-scopes.md | 21 +++++++++++++------ docs/modules/crm-activities.md | 8 ++++++- docs/modules/customers.md | 21 ++++++++++++------- docs/refactor/CONTRACT-CHANGES.md | 1 + 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 92676a57..6ad98916 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -280,7 +280,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/pricing/calculate-escalation": {KeyBranchClassStatedLimit, "a calculator over supplied inputs, no branch fact"}, "POST /api/v1/pricing/rebates/programs/{id}/claims/calculate": {KeyBranchClassStatedLimit, "programs are dealer wide and a claim uses supplied volumes"}, "POST /api/v1/vision/scan": {KeyBranchClassStatedLimit, "the blueprint scan prototype carries no branch fact"}, - "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "DELETE /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches; on a shared customer a bound key never deletes one (no branch tag; TestKeyBranchPin_ActivityFollowsTheContactRule)"}, "DELETE /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, "DELETE /api/v1/pos/transactions/{id}/items/{itemId}": {KeyBranchClassWalled, "pos_transactions.branch_id (the delete joins it); TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, @@ -414,7 +414,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/quotes": {KeyBranchClassWalled, "quotes.branch_id (the payload branch rule); the body's customer_id and job_id through the customer branch wall (round 9 P1-1, round 11 P1-1)"}, "POST /api/v1/quotes/{id}/convert": {KeyBranchClassWalled, "quotes.branch_id (the convert reads the quote under the wall); the quote's stored customer through the customer branch wall, rechecked where it becomes an order (round 11 P2-1); TestKeyBranchPin_QuoteConvertCustomerWall"}, "POST /api/v1/quotes/{id}/transitions": {KeyBranchClassWalled, "quotes.branch_id"}, - "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches"}, + "PUT /api/v1/activities/{id}": {KeyBranchClassWalled, "crm_activities.customer_id to customer_branches; on a shared customer a bound key never changes one (no branch tag; TestKeyBranchPin_ActivityFollowsTheContactRule)"}, "PUT /api/v1/contacts/{id}": {KeyBranchClassWalled, "contacts.customer_id to customer_branches"}, "PUT /api/v1/credit-memos/{id}": {KeyBranchClassWalled, "credit_memos.branch_id"}, "PUT /api/v1/customers/{id}": {KeyBranchClassWalled, "customer_branches (a customer of another branch reads as missing)"}, diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 401e6fdb..449400d8 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -801,11 +801,18 @@ row and nothing changed. A customer the pin holds alone and an unshared customer are unchanged for every caller. Who may change a shared customer's restricted fields (the principal's ruling on the open question below): a user only with the finance or owner role, a machine key only when unbound -and carrying the dedicated `customers:restricted` scope. A sales or admin -user, and every unbound key without the scope, are refused in the same -words naming the lowest numbered branch holding the customer; a caller with -no claims at all is refused fail closed; a bound key is refused even with -the scope. The scope is in the grant grammar (ADR 0002 section 2) though no +and carrying the dedicated `customers:restricted` scope. The finance role +reaches exactly the three routes that change those fields (the header +write, the salesperson and the escalation policy) and no other customer +route: customer and contact create, the reads and the other writes keep the +roles they had before this section. A sales or admin user, and every +unbound key without the scope, are refused in the same words as the bound +key (the words of the rule above, naming the lowest numbered branch holding +the customer) and the refusal writes a `customer.shared_restricted_refused` +row for the customer, naming the actor, the refused fields, that branch and +the method and path (the bound key's refusal keeps its `key.branch_refused` +row); a caller with no claims at all is refused fail closed; a bound key is +refused even with the scope. The scope is in the grant grammar (ADR 0002 section 2) though no route requires it, so no module scope and no default grant carries it; only an owner may mint it, and the mint's `key.created` audit row names it. An allowed key's change is audited as `customer.shared_restricted_changed` @@ -813,7 +820,9 @@ with the key as the actor and the before and after value of each field; a user's change writes no row. Ship-tos and contacts carry no branch column, so for a shared customer every existing one is shared: a bound key reads them and may add new ones but may not change or delete any (the same -refusal), and an add that names `is_default: true` is refused like a +refusal; CRM activities follow the same rule, because an activity carries no +branch tag, so on a shared customer a bound key adds one and never changes +or deletes one), and an add that names `is_default: true` is refused like a change, because naming a default clears the existing default, a shared ship to every branch's delivery orders default to; for a customer the pin holds alone all of it is allowed. The escalation audit's before and after diff --git a/docs/modules/crm-activities.md b/docs/modules/crm-activities.md index 078f6a30..9ce89fb3 100644 --- a/docs/modules/crm-activities.md +++ b/docs/modules/crm-activities.md @@ -124,7 +124,13 @@ second branch's request is a 404. For every caller, a create or an update refuses a `contact_id` the activity's customer does not own with a `400 validation_failed` naming `contact_id` ("is not a contact of this customer", the order's words), so one branch's record can never point at -another's contact. A key without the scope is `403 +another's contact. On a customer another branch also holds, a branch bound +key adds an activity but never changes or deletes one: an activity carries +no branch tag, so every existing one is shared. The refusal is `403 +forbidden` in the customer contact rule's words ("a branch bound key may act +only on its own branch: the customer is also held by branch "), with the +`key.branch_refused` row and the activity untouched; a customer the pin holds +alone, an unbound key and a user are unchanged. A key without the scope is `403 forbidden`; the audit row carries the refused scope. ## ADRs that govern this module diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 82517e32..80c57643 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -166,10 +166,12 @@ segment is the first path segment under `/api/v1/`). Ship-tos need `contacts:read` or `contacts:write`; payment terms need `payment-terms:read` or `payment-terms:write`; price levels need `price_levels:read`. The user guard at the serve layer is -`admin`, `owner`, `sales` and `finance` for customer reads and writes -(finance reaches the record so the shared customer rule below can hear -its role), with `admin`, `owner`, `finance` taking the narrower payment -terms writes; the exact guard is composed in +`admin`, `owner` and `sales` for customer reads and writes, with +`admin`, `owner`, `finance` taking the narrower payment terms writes and +`finance` added on exactly the three routes that change a customer's +restricted fields (`PUT /customers/{id}`, `PATCH /customers/{id}/salesperson` +and `PUT /customers/{id}/escalation-policy`; the shared customer rule below +hears its role there) and on no other customer route; the exact guard is composed in `core/internal/app/serve/wire_branch_wall.go` at `wall.customers`. A key without the scope is `403 forbidden`; the audit row carries the refused scope. @@ -185,10 +187,13 @@ numbered other branch), with the `key.branch_refused` row, and the record is untouched. A customer the key's branch holds alone and an unshared customer are unchanged for every caller. Who else may change a shared customer's restricted fields: a user only with the `finance` or `owner` role (a sales -or admin user is refused in the same words naming a branch that holds the -customer, and a caller with no claims at all is refused fail closed), and a -dealer wide key only while it carries the dedicated `customers:restricted` -scope; every other unbound key is refused with the same words, and a branch +or admin user is refused in the bound key's words naming the lowest +numbered branch that holds the customer, and a caller with no claims at all +is refused fail closed), and a dealer wide key only while it carries the +dedicated `customers:restricted` scope; every other unbound key is refused +in the same words. A refusal of a user or an unbound key writes a +`customer.shared_restricted_refused` audit row for the customer naming the +actor, the refused fields, that branch and the method and path. A branch bound key is refused even with the scope. The scope is in the grant grammar but no route requires it, so no module scope and no default grant carries it; only an owner may mint it, and the mint's `key.created` audit row names diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 666ef91d..3bf83ab3 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -295,3 +295,4 @@ get an entry. | SEC-BOUND-KEY-PIN-3 | the branch bound key's classes and walls, fix round 3 (the same 345 census routes as SEC-BOUND-KEY-PIN) | Rounds 5 and 6 proved by probe that three classes overstated: a tier category rule that also named a customer passed the customer wall and priced every branch's customers of the tier (201, no audit row); the POS till, Z report, return and line routes and the account summary, AR statement and reconciliation loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), added a line to B's transaction that stayed written behind the 500, and read B's till report, Z reports, returns, account, statement and reconciliation; `GET /pricing/calculate` confined its `customer_id` but not its `job_id`; a tax preview naming only B's `ship_to_id` passed; and the 18 dealer wide catalogue writes labelled WALLED (products, PIM, charge codes, payment terms, the reorder refresh) and every other stated limit write were reachable by a bound key | The category rule wall, handler and table now keep one invariant (an account rule carries no tier, a tier rule no customer), and migration 107 puts it on the table checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when one exists, validated on add otherwise. The pos repositories carry the branch predicate the transaction reads already carried (`till_sessions.branch_id`, `till_z_reports.branch_id`, `pos_returns.branch_id`, the line writes through their transaction's branch, the insert never happening on a foreign transaction), the account summary, subledger and statement mount the customer branch wall on the path customer, and the AR reconciliation is refused outright (its ledger half carries no branch fact). The calculation resolves `job_id` to `projects.customer_id` like the write; a tax preview naming a `ship_to_id` is confined through that ship to's customer. The classes are split per the principal's rulings: a stated limit is a read, never a write (WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118), so every dealer wide write (the catalogue and PIM writes above, the settings, module and app switches, the match tolerances, vendors, EDI partners and the catalog import, units, pricing categories, rebates, millwork options, governance rfcs, the index metadata) is refused with the `key.branch_refused` row, the driver routes are refused reads included (personal data, no branch column), and the vehicle reads stay stated limits. Every WALLED entry names the column or join its module filters on and a twin sweep drives every WALLED id or list route with the other branch's row (refused, or the missing row answer, nothing written). A shared customer may be read by a bound key but its customer scoped pricing rules and tax exemptions are refused unless the pin holds the customer alone, the refusal naming the other branch; the grant, revoke and home branch writes record their audit rows with the key as the actor; the parsing upload and vision scan stay stated limits and each call records its AI spend audit row with the key that spent it | | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | | SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | +| SEC-BOUND-KEY-PIN-6 | the branch bound key's walls, fix round 6 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 13 and the principal's item 54 rulings found: a bound key's pos sync on a register whose location row names no branch wrote an open transaction at the default branch, because the register's branch was read only through `locations` and not from `pos_registers.branch_id` (the till open and the return derived it the same way); the refusal of a user without the finance or owner role, or of an unbound key without `customers:restricted`, on a shared customer's restricted fields used other words than the bound key's and wrote no row; the customer routes admitted finance on every route; and a bound key changed and deleted a CRM activity of a shared customer | A register's branch is `COALESCE(l.branch_id, r.branch_id)` in the transaction create, the till open and the return branch lookup, so the pin check always runs. The caller refusals use the bound key's words and write a `customer.shared_restricted_refused` row (actor, fields, branch, method, path). Finance reaches only the header write, the salesperson and the escalation policy; every other customer route is back to `admin`, `owner`, `sales`. On a shared customer a bound key adds an activity and never changes or deletes one (403 in the contact rule's words, with its `key.branch_refused` row). New tests: the register branch legs, the stored job recheck on the conversion, the claims-less mint refusal and a bound key that truly carries the scope | From a4f5bfdf98be8cec2689aa45e22828e33cd2edfb Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 22:05:32 -0700 Subject: [PATCH 57/74] test: re-record the pos goldens for the till session's branch; seed REG-01 onto Kelowna The till session opened on a register with no branched location now carries the register's branch, so branch_id appears in its body (the four pos goldens, ids renumbered). The seed moves REG-01 onto Kelowna so a seeded register belongs to the default branch the seed makes, which leaves every sale and return amount unchanged. Listed in CONTRACT-CHANGES. Signed-off-by: colton --- core/internal/app/seed/seed.go | 6 +- .../testdata/goldens/pos.json | 18 ++- .../testdata/goldens/pos_returns.json | 85 ++++++----- .../testdata/goldens/pos_till_close.json | 58 ++++--- .../testdata/goldens/pos_transactions.json | 143 +++++++++--------- docs/refactor/CONTRACT-CHANGES.md | 1 + 6 files changed, 165 insertions(+), 146 deletions(-) diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index e3bc54ff..0e9829a0 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -1314,10 +1314,12 @@ func Run() { // ========================================================================= // POSTerminal.ts hardcodes the register id "REG-01"; without a row in // pos_registers, /api/v1/pos/transactions fails with "failed to resolve - // register branch". + // register branch". Migration 027 seeds REG-01 on a location that does not + // exist and migration 066 gives it the migration-time default branch, so + // the row is moved to Kelowna, the branch the seed makes the default. db.Exec(`INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) - ON CONFLICT (id) DO NOTHING`, kelMainID.String()) + ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1, name = 'Main Counter - Kelowna'`, kelMainID.String()) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) VALUES ('sync-batch-001', 'REG-01', 42, 0, 0, NOW() - interval '2 hours')`) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) diff --git a/core/internal/characterization/testdata/goldens/pos.json b/core/internal/characterization/testdata/goldens/pos.json index f2f835e0..9fde58ea 100644 --- a/core/internal/characterization/testdata/goldens/pos.json +++ b/core/internal/characterization/testdata/goldens/pos.json @@ -13,8 +13,9 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "id": "", "notes": "", "opened_at": "", "opening_float": 10000, @@ -34,8 +35,9 @@ "content_type": "application/json", "body": { "session": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "id": "", "notes": "", "opened_at": "", "opening_float": 10000, @@ -51,7 +53,7 @@ "method": "POST", "path": "/api/v1/pos/transactions", "body": { - "customer_id": "", + "customer_id": "", "register_id": "REG-01" } }, @@ -59,17 +61,17 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "register_id": "REG-01", "status": "OPEN", "subtotal": 0, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 0 } } diff --git a/core/internal/characterization/testdata/goldens/pos_returns.json b/core/internal/characterization/testdata/goldens/pos_returns.json index a00b746d..2051d99b 100644 --- a/core/internal/characterization/testdata/goldens/pos_returns.json +++ b/core/internal/characterization/testdata/goldens/pos_returns.json @@ -22,19 +22,20 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "gl_entry_id": "", - "id": "", + "gl_entry_id": "", + "id": "", "lines": [ { "description": "returned lumber", - "id": "", + "id": "", "line_total": 2500, "product_id": "", "quantity": 2, "restock": true, - "return_id": "", + "return_id": "", "unit_price": 1250, "uom": "EA" } @@ -45,7 +46,7 @@ "status": "COMPLETED", "subtotal": 2500, "tax_amount": 300, - "till_session_id": "", + "till_session_id": "", "total": 2800 } } @@ -56,10 +57,10 @@ "method": "POST", "path": "/api/v1/pos/returns", "body": { - "customer_id": "", + "customer_id": "", "lines": [ { - "product_id": "", + "product_id": "", "quantity": 1, "restock": false, "unit_price": 30 @@ -73,20 +74,21 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "customer_id": "", - "gl_entry_id": "", - "id": "", + "customer_id": "", + "gl_entry_id": "", + "id": "", "lines": [ { "description": "", - "id": "", + "id": "", "line_total": 3000, - "product_id": "", + "product_id": "", "quantity": 1, "restock": false, - "return_id": "", + "return_id": "", "unit_price": 3000, "uom": "EA" } @@ -97,7 +99,7 @@ "status": "COMPLETED", "subtotal": 3000, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 3000 } } @@ -128,7 +130,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -153,7 +155,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -184,7 +186,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -215,7 +217,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -236,7 +238,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -245,25 +247,26 @@ "name": "pos.return.get", "request": { "method": "GET", - "path": "/api/v1/pos/returns/" + "path": "/api/v1/pos/returns/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "gl_entry_id": "", - "id": "", + "gl_entry_id": "", + "id": "", "lines": [ { "description": "returned lumber", - "id": "", + "id": "", "line_total": 2500, "product_id": "", "quantity": 2, "restock": true, - "return_id": "", + "return_id": "", "unit_price": 1250, "uom": "EA" } @@ -274,7 +277,7 @@ "status": "COMPLETED", "subtotal": 2500, "tax_amount": 300, - "till_session_id": "", + "till_session_id": "", "total": 2800 } } @@ -283,7 +286,7 @@ "name": "pos.return.get.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/returns/" + "path": "/api/v1/pos/returns/" }, "response": { "status": 404, @@ -294,7 +297,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -314,7 +317,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -332,32 +335,34 @@ "body": { "returns": [ { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "customer_id": "", - "gl_entry_id": "", - "id": "", + "customer_id": "", + "gl_entry_id": "", + "id": "", "reason": "", "refund_method": "ACCOUNT", "register_id": "REG-01", "status": "COMPLETED", "subtotal": 3000, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 3000 }, { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "gl_entry_id": "", - "id": "", + "gl_entry_id": "", + "id": "", "reason": "r1b c wrong size", "refund_method": "CASH", "register_id": "REG-01", "status": "COMPLETED", "subtotal": 2500, "tax_amount": 300, - "till_session_id": "", + "till_session_id": "", "total": 2800 } ] diff --git a/core/internal/characterization/testdata/goldens/pos_till_close.json b/core/internal/characterization/testdata/goldens/pos_till_close.json index 9f1962d2..2e6284e5 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -16,7 +16,8 @@ "sale_count": 1, "sales_total": 1872, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "id": "", "notes": "", "opened_at": "", @@ -46,7 +47,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -55,7 +56,7 @@ "name": "pos.till.report.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/till//report" + "path": "/api/v1/pos/till//report" }, "response": { "status": 500, @@ -66,7 +67,7 @@ "message": "Internal Server Error" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -86,7 +87,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -106,7 +107,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -131,7 +132,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -152,7 +153,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -161,7 +162,7 @@ "name": "pos.till.close.not_found", "request": { "method": "POST", - "path": "/api/v1/pos/till//close", + "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { "CASH": 1 @@ -177,7 +178,7 @@ "message": "Conflict" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -205,7 +206,8 @@ "sale_count": 1, "sales_total": 1872, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 @@ -213,7 +215,7 @@ "expected_by_method": { "CASH": 11404 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", @@ -249,7 +251,7 @@ "message": "Conflict" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -282,8 +284,9 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "id": "", "notes": "", "opened_at": "", "opening_float": 10000, @@ -309,7 +312,8 @@ "sale_count": 1, "sales_total": 1872, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 @@ -317,7 +321,7 @@ "expected_by_method": { "CASH": 11404 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", @@ -343,8 +347,9 @@ "status": 200, "content_type": "application/json", "body": { + "branch_id": "", "generated_at": "", - "id": "", + "id": "", "over_short": 8596, "payload": { "change_given": 48596, @@ -354,7 +359,8 @@ "sale_count": 1, "sales_total": 1872, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 @@ -362,7 +368,7 @@ "expected_by_method": { "CASH": 11404 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", @@ -385,7 +391,7 @@ "name": "pos.till.zreport.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/till//zreport" + "path": "/api/v1/pos/till//zreport" }, "response": { "status": 404, @@ -396,7 +402,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -413,8 +419,9 @@ "body": { "z_reports": [ { + "branch_id": "", "generated_at": "", - "id": "", + "id": "", "over_short": 8596, "payload": { "change_given": 48596, @@ -424,7 +431,8 @@ "sale_count": 1, "sales_total": 1872, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 @@ -432,7 +440,7 @@ "expected_by_method": { "CASH": 11404 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", diff --git a/core/internal/characterization/testdata/goldens/pos_transactions.json b/core/internal/characterization/testdata/goldens/pos_transactions.json index f832aaad..906f2a03 100644 --- a/core/internal/characterization/testdata/goldens/pos_transactions.json +++ b/core/internal/characterization/testdata/goldens/pos_transactions.json @@ -10,8 +10,9 @@ "content_type": "application/json", "body": { "session": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "id": "", "notes": "", "opened_at": "", "opening_float": 10000, @@ -28,7 +29,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, - "product_id": "", + "product_id": "", "quantity": 50, "reason": "r1b c pos setup" } @@ -48,7 +49,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, - "product_id": "", + "product_id": "", "quantity": 10, "reason": "r1b c pos setup" } @@ -80,7 +81,7 @@ "message": "Conflict" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -101,7 +102,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -112,7 +113,7 @@ "method": "POST", "path": "/api/v1/pos/transactions", "body": { - "customer_id": "", + "customer_id": "", "register_id": "REG-01" } }, @@ -120,17 +121,17 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "register_id": "REG-01", "status": "OPEN", "subtotal": 0, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 0 } } @@ -148,7 +149,7 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -157,7 +158,7 @@ "status": "OPEN", "subtotal": 0, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 0 } } @@ -175,7 +176,7 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -184,7 +185,7 @@ "status": "OPEN", "subtotal": 0, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 0 } } @@ -263,7 +264,7 @@ { "description": "2x4x8 SPF Premium", "in_stock": 429, - "product_id": "", + "product_id": "", "sku": "LUM-248-PREM", "unit_price": 5.5, "uom": "PCS" @@ -405,7 +406,7 @@ "method": "POST", "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", + "product_id": "", "quantity": 3 } }, @@ -413,11 +414,11 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -425,7 +426,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -436,7 +437,7 @@ "status": "OPEN", "subtotal": 1404, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 1404 } } @@ -447,7 +448,7 @@ "method": "POST", "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", + "product_id": "", "quantity": 2, "uom": "EA" } @@ -456,11 +457,11 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -468,7 +469,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -479,7 +480,7 @@ "description": "3/4 Plywood G1S 4x8 (Fir)", "id": "", "line_total": 6460, - "product_id": "", + "product_id": "", "quantity": 2, "transaction_id": "", "unit_price": 3230, @@ -490,7 +491,7 @@ "status": "OPEN", "subtotal": 7864, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 7864 } } @@ -501,7 +502,7 @@ "method": "POST", "path": "/api/v1/pos/transactions/not-a-uuid/items", "body": { - "product_id": "", + "product_id": "", "quantity": 1 } }, @@ -574,11 +575,11 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -586,7 +587,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -597,7 +598,7 @@ "status": "OPEN", "subtotal": 1404, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 1404 } } @@ -652,11 +653,11 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -664,7 +665,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -675,7 +676,7 @@ "status": "OPEN", "subtotal": 1404, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 1404 } } @@ -690,11 +691,11 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -702,7 +703,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -713,7 +714,7 @@ "status": "OPEN", "subtotal": 1404, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 1404 } } @@ -962,12 +963,12 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 48596, "completed_at": "", "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -975,7 +976,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -995,7 +996,7 @@ "transaction_id": "" } ], - "till_session_id": "", + "till_session_id": "", "total": 1404 } } @@ -1034,7 +1035,7 @@ "method": "POST", "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", + "product_id": "", "quantity": 1 } }, @@ -1042,12 +1043,12 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 48596, "completed_at": "", "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -1055,7 +1056,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -1066,7 +1067,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 468, - "product_id": "", + "product_id": "", "quantity": 1, "transaction_id": "", "unit_price": 468, @@ -1077,7 +1078,7 @@ "status": "COMPLETED", "subtotal": 1872, "tax_amount": 0, - "till_session_id": "", + "till_session_id": "", "total": 1872 } } @@ -1088,7 +1089,7 @@ "method": "POST", "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", + "product_id": "", "quantity": 1 } }, @@ -1096,7 +1097,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -1107,7 +1108,7 @@ "description": "3/4 Plywood G1S 4x8 (Fir)", "id": "", "line_total": 3800, - "product_id": "", + "product_id": "", "quantity": 1, "transaction_id": "", "unit_price": 3800, @@ -1118,7 +1119,7 @@ "status": "OPEN", "subtotal": 3800, "tax_amount": 456, - "till_session_id": "", + "till_session_id": "", "total": 4256 } } @@ -1133,7 +1134,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -1142,7 +1143,7 @@ "status": "VOIDED", "subtotal": 3800, "tax_amount": 456, - "till_session_id": "", + "till_session_id": "", "total": 4256 } } @@ -1157,7 +1158,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -1166,7 +1167,7 @@ "status": "VOIDED", "subtotal": 3800, "tax_amount": 456, - "till_session_id": "", + "till_session_id": "", "total": 4256 } } @@ -1217,7 +1218,7 @@ "method": "POST", "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", + "product_id": "", "quantity": 1 } }, @@ -1225,7 +1226,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 0, "created_at": "", @@ -1236,7 +1237,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 550, - "product_id": "", + "product_id": "", "quantity": 1, "transaction_id": "", "unit_price": 550, @@ -1247,7 +1248,7 @@ "status": "OPEN", "subtotal": 550, "tax_amount": 66, - "till_session_id": "", + "till_session_id": "", "total": 616 } } @@ -1270,7 +1271,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 9384, "completed_at": "", @@ -1282,7 +1283,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 550, - "product_id": "", + "product_id": "", "quantity": 1, "transaction_id": "", "unit_price": 550, @@ -1302,7 +1303,7 @@ "transaction_id": "" } ], - "till_session_id": "", + "till_session_id": "", "total": 616 } } @@ -1317,7 +1318,7 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 9384, "completed_at": "", @@ -1327,7 +1328,7 @@ "status": "VOIDED", "subtotal": 550, "tax_amount": 66, - "till_session_id": "", + "till_session_id": "", "total": 616 } } @@ -1342,12 +1343,12 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", + "branch_id": "", "cashier_id": "", "change_due": 48596, "completed_at": "", "created_at": "", - "customer_id": "", + "customer_id": "", "id": "", "line_items": [ { @@ -1355,7 +1356,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 1404, - "product_id": "", + "product_id": "", "quantity": 3, "transaction_id": "", "unit_price": 468, @@ -1366,7 +1367,7 @@ "description": "2x4x8 SPF Premium", "id": "", "line_total": 468, - "product_id": "", + "product_id": "", "quantity": 1, "transaction_id": "", "unit_price": 468, @@ -1386,7 +1387,7 @@ "transaction_id": "" } ], - "till_session_id": "", + "till_session_id": "", "total": 1872 } } diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 3bf83ab3..6c3bc45c 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -296,3 +296,4 @@ get an entry. | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | | SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | | SEC-BOUND-KEY-PIN-6 | the branch bound key's walls, fix round 6 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 13 and the principal's item 54 rulings found: a bound key's pos sync on a register whose location row names no branch wrote an open transaction at the default branch, because the register's branch was read only through `locations` and not from `pos_registers.branch_id` (the till open and the return derived it the same way); the refusal of a user without the finance or owner role, or of an unbound key without `customers:restricted`, on a shared customer's restricted fields used other words than the bound key's and wrote no row; the customer routes admitted finance on every route; and a bound key changed and deleted a CRM activity of a shared customer | A register's branch is `COALESCE(l.branch_id, r.branch_id)` in the transaction create, the till open and the return branch lookup, so the pin check always runs. The caller refusals use the bound key's words and write a `customer.shared_restricted_refused` row (actor, fields, branch, method, path). Finance reaches only the header write, the salesperson and the escalation policy; every other customer route is back to `admin`, `owner`, `sales`. On a shared customer a bound key adds an activity and never changes or deletes one (403 in the contact rule's words, with its `key.branch_refused` row). New tests: the register branch legs, the stored job recheck on the conversion, the claims-less mint refusal and a bound key that truly carries the scope | +| SEC-BOUND-KEY-PIN-6 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT DO UPDATE`) so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | From a62f5635415f2f231823d3024457a69bc3b8d859 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 14:45:47 -0700 Subject: [PATCH 58/74] pos: migration 108 backfills the branch on legacy till, Z report and return rows (r15 P2-1) The till session, Z report and return reads filter on their branch_id, so every row written before the column was stamped (a register whose location names no branch, which includes the seeded REG-01) vanished from every caller with a branch context: an open drawer left at upgrade read as no drawer and could be neither closed nor replaced, and the legacy Z reports and returns disappeared from the branch scoped reads. Migration 108 backfills the three tables from each row's own register (the round 6 rule, COALESCE(the register's location's branch, pos_registers.branch_id)). It touches only rows whose branch_id IS NULL, sets nothing else, moves no money and deletes nothing, so it is idempotent. A row whose register names no branch is left exactly as it is and reported by a NOTICE naming the count and the first ids. The down file leaves the data: the branch was read from the row's own register and nulling it again would put the rows back outside the branch scoped reads. Tests: the migration runs in a scratch database seeded with legacy rows on every table it touches (a locationless register, a register on a yard whose branch beats its own, rows already set, rows whose register was never there), asserting the NOTICE counts, the resolved branches, the untouched rows and money digests, and a second run that changes nothing; the serve legs plant the legacy rows, apply the migration and read the drawer, its Z reports and the returns back through the real chain with the register's branch selected. Signed-off-by: colton --- .../app/serve/wire_key_branch_round7_test.go | 150 ++++++++ core/internal/pos/migration108_test.go | 325 ++++++++++++++++++ core/migrations/108_pos_branch_backfill.sql | 43 +++ .../down/108_pos_branch_backfill_down.sql | 20 ++ 4 files changed, 538 insertions(+) create mode 100644 core/internal/app/serve/wire_key_branch_round7_test.go create mode 100644 core/internal/pos/migration108_test.go create mode 100644 core/migrations/108_pos_branch_backfill.sql create mode 100644 core/migrations/down/108_pos_branch_backfill_down.sql diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go new file mode 100644 index 00000000..37829696 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// Fix round 7 of the branch bound key pin: the r15 P2 and P3s, the r14 notes +// and the principal's item 57 rulings, every leg through the real serve chain. + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + + "github.com/google/uuid" +) + +// applyMigration108 runs the migration file's SQL on the fixture's database: +// the legacy rows a test plants after the migrations ran are the rows the +// migration backfills at upgrade, so the test applies the same statements. +func (f *keyPinFixture) applyMigration108(t *testing.T) { + t.Helper() + sql, err := os.ReadFile("../../../migrations/108_pos_branch_backfill.sql") + if err != nil { + t.Fatal(err) + } + if _, err := f.db.Pool.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("apply migration 108: %v", err) + } +} + +// r15reg seeds a pos_registers row with the location and branch given and +// removes it when the test ends. +func (f *keyPinFixture) r15reg(t *testing.T, name string, location, branch uuid.UUID) string { + t.Helper() + reg := "R15-" + name + "-" + uuid.NewString()[:8] + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) VALUES ($1, $2, $3, $4, true)`, + reg, location, branch, reg); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM pos_registers WHERE id = $1`, reg) + }) + return reg +} + +// Fix round 7 (r15 P2-1): a till session written before the branch filter was +// added, on a register whose location names no branch, carried branch NULL and +// so vanished from every caller with a branch context: the drawer read as none +// and could be neither closed nor replaced. Migration 108 backfills those rows +// from their register, and the drawer is reachable and closable again, with +// the register's own branch selected in the desk. +func TestKeyBranchPin_Migration108LegacyTillSessionStaysReachable(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + var def uuid.UUID + if err := f.db.Pool.QueryRow(ctx, `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&def); err != nil { + t.Fatal(err) + } + reg := f.r15reg(t, "repro", uuid.New(), def) // location_id names no location + sess := uuid.New() + t.Cleanup(func() { _, _ = f.db.Pool.Exec(ctx, `DELETE FROM till_sessions WHERE id = $1`, sess) }) + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO till_sessions (id, register_id, branch_id, cashier_id, status, opening_float) + VALUES ($1, $2, NULL, $3, 'OPEN', 50)`, sess, reg, uuid.New()); err != nil { + t.Fatal(err) + } + f.applyMigration108(t) + hdr := map[string]string{"X-Branch-Id": def.String()} + status, buf := f.userAs(t, "admin", "GET", "/api/v1/pos/till/current?register_id="+reg, "", hdr) + if status != http.StatusOK || !strings.Contains(string(buf), sess.String()) { + t.Errorf("current till with X-Branch-Id the register's branch: %d %s, want the open session", status, buf) + } + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/"+sess.String()+"/close", `{"counted_by_method":{"CASH":0.5}}`, hdr) + if status != http.StatusOK { + t.Errorf("closing the open drawer with X-Branch-Id the register's branch: %d %s, want 200", status, buf) + } +} + +// Fix round 7 (r15 P2-1), the Z report half: the legacy closed sessions' Z +// reports vanished with them from the branch scoped reads. The backfill brings +// the history back: the session's Z report reads by session and the list finds +// it under the register, for the branch the register belongs to. +func TestKeyBranchPin_Migration108LegacyZReportStaysReachable(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + var def uuid.UUID + if err := f.db.Pool.QueryRow(ctx, `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&def); err != nil { + t.Fatal(err) + } + reg := f.r15reg(t, "zrep", uuid.New(), def) + sess, zr := uuid.New(), uuid.New() + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM till_z_reports WHERE id = $1`, zr) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM till_sessions WHERE id = $1`, sess) + }) + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO till_sessions (id, register_id, branch_id, cashier_id, status, opening_float, closed_at) + VALUES ($1, $2, NULL, $3, 'CLOSED', 50, NOW())`, sess, reg, uuid.New()); err != nil { + t.Fatal(err) + } + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload) + VALUES ($1, $2, $3, NULL, 0, '{"legacy":true}')`, zr, sess, reg); err != nil { + t.Fatal(err) + } + f.applyMigration108(t) + hdr := map[string]string{"X-Branch-Id": def.String()} + status, buf := f.userAs(t, "admin", "GET", "/api/v1/pos/till/"+sess.String()+"/zreport", "", hdr) + if status != http.StatusOK || !strings.Contains(string(buf), zr.String()) { + t.Errorf("the legacy Z report by session with X-Branch-Id the register's branch: %d %s, want the report", status, buf) + } + status, buf = f.userAs(t, "admin", "GET", "/api/v1/pos/zreports?register_id="+reg, "", hdr) + if status != http.StatusOK || !strings.Contains(string(buf), zr.String()) { + t.Errorf("the legacy Z report list with X-Branch-Id the register's branch: %d %s, want the report listed", status, buf) + } +} + +// Fix round 7 (r15 P2-1), the return half: the legacy returns vanished from +// the branch scoped reads the same way. The backfill brings them back. +func TestKeyBranchPin_Migration108LegacyReturnStaysReachable(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + var def uuid.UUID + if err := f.db.Pool.QueryRow(ctx, `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&def); err != nil { + t.Fatal(err) + } + reg := f.r15reg(t, "ret", uuid.New(), def) + ret := uuid.New() + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_return_lines WHERE return_id = $1`, ret) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_returns WHERE id = $1`, ret) + }) + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status) + VALUES ($1, $2, NULL, $3, 10.11, 0.99, 11.10, 'CASH', 'COMPLETED')`, ret, reg, uuid.New()); err != nil { + t.Fatal(err) + } + f.applyMigration108(t) + hdr := map[string]string{"X-Branch-Id": def.String()} + status, buf := f.userAs(t, "admin", "GET", "/api/v1/pos/returns/"+ret.String(), "", hdr) + if status != http.StatusOK || !strings.Contains(string(buf), ret.String()) { + t.Errorf("the legacy return with X-Branch-Id the register's branch: %d %s, want the return", status, buf) + } + status, buf = f.userAs(t, "admin", "GET", "/api/v1/pos/returns?register_id="+reg, "", hdr) + if status != http.StatusOK || !strings.Contains(string(buf), ret.String()) { + t.Errorf("the legacy return list with X-Branch-Id the register's branch: %d %s, want the return listed", status, buf) + } +} diff --git a/core/internal/pos/migration108_test.go b/core/internal/pos/migration108_test.go new file mode 100644 index 00000000..e6eb0dbf --- /dev/null +++ b/core/internal/pos/migration108_test.go @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// Migration 108 (r15 P2-1): the till session, Z report and return reads filter +// on their branch_id, which hid every row written before the column was +// stamped. The migration backfills each row from its own register (the round 6 +// rule, COALESCE(the register's location's branch, pos_registers.branch_id)). +// The schema up to 107 is built in a scratch database of its own, legacy rows +// are planted the way refactor/v1 wrote them (branch NULL, on registers whose +// location names no branch), and 108 is applied: it must set each row from its +// register, count what it set per table in a NOTICE, report by count and first +// ids the rows whose register names no branch and leave them NULL, never touch +// a row whose branch is already set, move no money, and change nothing at all +// on a second run. Replacing the file with SELECT 1 fails every assertion here. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func scratch108(t *testing.T) (*pgx.Conn, *[]string) { + t.Helper() + db := testutil.RequireDB(t) + base, err := url.Parse(db.Pool.Config().ConnString()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, base.String()) + if err != nil { + t.Fatal(err) + } + suffix := make([]byte, 6) + if _, err := rand.Read(suffix); err != nil { + t.Fatal(err) + } + name := "gv1_m108_" + hex.EncodeToString(suffix) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, name)); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE %q WITH (FORCE)`, name)) + admin.Close(context.Background()) + }) + scratch := *base + scratch.Path = "/" + name + cfg, err := pgx.ParseConfig(scratch.String()) + if err != nil { + t.Fatal(err) + } + var notices []string + cfg.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) { notices = append(notices, n.Message) } + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn, ¬ices +} + +func applyFile108(t *testing.T, conn *pgx.Conn, file string) { + t.Helper() + sql, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("applying %s: %v", filepath.Base(file), err) + } +} + +func scalar108[T any](t *testing.T, conn *pgx.Conn, sql string, args ...any) T { + t.Helper() + var v T + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +// digest108 hashes a table's planted rows without their branch_id, so a +// migration that touched anything but the branch fails the comparison. +func digest108(t *testing.T, conn *pgx.Conn, table string, ids []uuid.UUID) string { + t.Helper() + return scalar108[string](t, conn, fmt.Sprintf( + `SELECT md5(string_agg((row_to_json(x)::jsonb - 'branch_id')::text, ',' ORDER BY x.id)) + FROM %s x WHERE x.id = ANY($1)`, table), ids) +} + +func branch108(t *testing.T, conn *pgx.Conn, table string, id uuid.UUID) *uuid.UUID { + t.Helper() + var branch *uuid.UUID + if err := conn.QueryRow(context.Background(), + fmt.Sprintf(`SELECT branch_id FROM %s WHERE id = $1`, table), id).Scan(&branch); err != nil { + t.Fatal(err) + } + return branch +} + +// notice108 finds the 108 NOTICE holding the fragment. +func notice108(notices *[]string, fragment string) string { + for _, n := range *notices { + if strings.HasPrefix(n, "108:") && strings.Contains(n, fragment) { + return n + } + } + return "" +} + +func TestMigration108_BackfillsTheBranchFromTheRegister(t *testing.T) { + conn, notices := scratch108(t) + all, err := filepath.Glob("../../migrations/*.sql") + if err != nil || len(all) == 0 { + t.Fatalf("no migrations found: %v", err) + } + sort.Strings(all) + var target string + for _, f := range all { + base := filepath.Base(f) + switch { + case strings.HasSuffix(base, "_down.sql"): + case strings.HasPrefix(base, "108_"): + target = f + case base < "108_": + applyFile108(t, conn, f) + } + } + if target == "" { + t.Fatal("migration 108 not found") + } + ctx := context.Background() + + // Two branches, a yard of the first, and the registers: one whose + // location_id names no location (resolved through its own + // pos_registers.branch_id), one on the yard (the location's branch wins + // over its own). A row whose register was never there (the foreign keys + // would refuse one today, so they come off in this scratch database) is + // the shape the stuck NOTICE reports. + branchX, branchY, yardX := uuid.New(), uuid.New(), uuid.New() + if _, err := conn.Exec(ctx, `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'M108X', 'm108 X'), ($2, 'BRANCH', 'M108Y', 'm108 Y')`, + branchX, branchY); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, `INSERT INTO locations (id, type, code, name, parent_id) VALUES ($1, 'YARD', 'M108XY', 'm108 yard', $2)`, + yardX, branchX); err != nil { + t.Fatal(err) + } + regNoLoc, regYard, gone := "M108-NOLOC", "M108-YARD", "M108-GONE" + if _, err := conn.Exec(ctx, `INSERT INTO pos_registers (id, location_id, branch_id, name) VALUES + ($1, $2, $3, 'm108 no location'), ($4, $5, $6, 'm108 on the yard')`, + regNoLoc, uuid.New(), branchX, regYard, yardX, branchY); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, `ALTER TABLE till_sessions DROP CONSTRAINT till_sessions_register_id_fkey`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, `ALTER TABLE pos_returns DROP CONSTRAINT pos_returns_register_id_fkey`); err != nil { + t.Fatal(err) + } + + // The legacy rows: branch NULL on both registers, a row whose branch is + // already set (never to be touched), and a row whose register was never + // there (left NULL and reported). Money columns are planted once and + // hashed: the migration may not move any of it. + cashier := uuid.New() + openSess, closedSess, setSess, stuckSess := uuid.New(), uuid.New(), uuid.New(), uuid.New() + zrLegacy, zrSet, zrStuck := uuid.New(), uuid.New(), uuid.New() + retNoLoc, retYard, retSet, retStuck := uuid.New(), uuid.New(), uuid.New(), uuid.New() + plant := fmt.Sprintf(` + INSERT INTO till_sessions (id, register_id, branch_id, cashier_id, status, opening_float) VALUES + ('%[1]s', '%[10]s', NULL, '%[14]s', 'OPEN', 50.25), + ('%[2]s', '%[11]s', NULL, '%[14]s', 'CLOSED', 10.10), + ('%[3]s', '%[10]s', '%[13]s', '%[14]s', 'CLOSED', 77.77), + ('%[4]s', '%[12]s', NULL, '%[14]s', 'OPEN', 1.00); + INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload) VALUES + ('%[5]s', '%[2]s', '%[11]s', NULL, -1.25, '{"legacy":true}'), + ('%[6]s', '%[3]s', '%[10]s', '%[13]s', 0, '{"set":true}'), + ('%[7]s', '%[1]s', '%[12]s', NULL, 2.50, '{"stuck":true}'); + INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status) VALUES + ('%[8]s', '%[10]s', NULL, '%[14]s', 10.11, 0.99, 11.10, 'CASH', 'COMPLETED'), + ('%[9]s', '%[11]s', NULL, '%[14]s', 20.22, 1.98, 22.20, 'CARD', 'COMPLETED'), + ('%[15]s', '%[10]s', '%[13]s', '%[14]s', 5.00, 0.45, 5.45, 'CASH', 'COMPLETED'), + ('%[16]s', '%[12]s', NULL, '%[14]s', 1.00, 0.09, 1.09, 'CASH', 'COMPLETED'); + `, openSess, closedSess, setSess, stuckSess, zrLegacy, zrSet, zrStuck, + retNoLoc, retYard, regNoLoc, regYard, gone, branchY, cashier, retSet, retStuck) + if _, err := conn.Exec(ctx, plant); err != nil { + t.Fatalf("plant the legacy rows: %v", err) + } + tills := []uuid.UUID{openSess, closedSess, setSess, stuckSess} + zreports := []uuid.UUID{zrLegacy, zrSet, zrStuck} + returns := []uuid.UUID{retNoLoc, retYard, retSet, retStuck} + digests := map[string]string{ + "till_sessions": digest108(t, conn, "till_sessions", tills), + "till_z_reports": digest108(t, conn, "till_z_reports", zreports), + "pos_returns": digest108(t, conn, "pos_returns", returns), + } + + // 1. The first run: one NOTICE per table counts what it set, one NOTICE + // per table with a stuck row reports the count and its first ids, the + // register's location's branch wins over the register's own, an already + // set row never moves, a stuck row stays NULL, and no money column moves. + *notices = nil + applyFile108(t, conn, target) + for table, want := range map[string]string{ + "till_sessions": "till_sessions: set the branch from its register on 2 row(s)", + "till_z_reports": "till_z_reports: set the branch from its register on 1 row(s)", + "pos_returns": "pos_returns: set the branch from its register on 2 row(s)", + } { + if n := notice108(notices, want); n == "" { + t.Errorf("the NOTICE does not count what it set (%q): %v", want, *notices) + } + stuck := notice108(notices, table+": 1 row(s) name a register with no branch") + if stuck == "" || !strings.Contains(stuck, "first ids") { + t.Errorf("the %s stuck NOTICE does not report the count and the first ids: %v", table, *notices) + continue + } + var id uuid.UUID + switch table { + case "till_sessions": + id = stuckSess + case "till_z_reports": + id = zrStuck + case "pos_returns": + id = retStuck + } + if !strings.Contains(stuck, id.String()) { + t.Errorf("the %s stuck NOTICE does not name %s: %q", table, id, stuck) + } + } + if got := branch108(t, conn, "till_sessions", openSess); got == nil || *got != branchX { + t.Errorf("the open session's branch is %v, want the register's own %s", got, branchX) + } + if got := branch108(t, conn, "till_sessions", closedSess); got == nil || *got != branchX { + t.Errorf("the closed session's branch is %v, want its yard's branch %s (the location's branch wins over the register's own)", got, branchX) + } + if got := branch108(t, conn, "till_z_reports", zrLegacy); got == nil || *got != branchX { + t.Errorf("the legacy Z report's branch is %v, want its register's yard's %s", got, branchX) + } + if got := branch108(t, conn, "pos_returns", retNoLoc); got == nil || *got != branchX { + t.Errorf("the return on the locationless register has branch %v, want %s", got, branchX) + } + if got := branch108(t, conn, "pos_returns", retYard); got == nil || *got != branchX { + t.Errorf("the return on the yard register has branch %v, want the yard's %s (the location's branch wins over the register's own)", got, branchX) + } + if got := branch108(t, conn, "till_sessions", setSess); got == nil || *got != branchY { + t.Errorf("the already set session's branch is %v, want it left at %s", got, branchY) + } + if got := branch108(t, conn, "till_z_reports", zrSet); got == nil || *got != branchY { + t.Errorf("the already set Z report's branch is %v, want it left at %s", got, branchY) + } + if got := branch108(t, conn, "pos_returns", retSet); got == nil || *got != branchY { + t.Errorf("the already set return's branch is %v, want it left at %s", got, branchY) + } + for _, s := range []struct { + table string + id uuid.UUID + }{{"till_sessions", stuckSess}, {"till_z_reports", zrStuck}, {"pos_returns", retStuck}} { + if got := branch108(t, conn, s.table, s.id); got != nil { + t.Errorf("the stuck %s row %s was set to %v, want it left NULL", s.table, s.id, got) + } + } + for table, ids := range map[string][]uuid.UUID{"till_sessions": tills, "till_z_reports": zreports, "pos_returns": returns} { + if after := digest108(t, conn, table, ids); after != digests[table] { + t.Errorf("migration 108 moved more than the branch on %s", table) + } + } + + // 2. The second run changes nothing: every table sets 0 rows, the stuck + // rows are still NULL and still reported, and no digest moved. + *notices = nil + applyFile108(t, conn, target) + for _, table := range []string{"till_sessions", "till_z_reports", "pos_returns"} { + if n := notice108(notices, table+": set the branch from its register on 0 row(s)"); n == "" { + t.Errorf("the second run's %s NOTICE does not count 0: %v", table, *notices) + } + if notice108(notices, table+": 1 row(s) name a register with no branch") == "" { + t.Errorf("the second run no longer reports the stuck %s row: %v", table, *notices) + } + } + for table, ids := range map[string][]uuid.UUID{"till_sessions": tills, "till_z_reports": zreports, "pos_returns": returns} { + if after := digest108(t, conn, table, ids); after != digests[table] { + t.Errorf("the second run moved more than the branch on %s", table) + } + } + if got := branch108(t, conn, "till_sessions", openSess); got == nil || *got != branchX { + t.Errorf("the second run moved the open session's branch to %v", got) + } + + // 3. The down file leaves the data: every branch the backfill set is + // still there, the stuck rows are still NULL, and no digest moved. + down, err := os.ReadFile("../../migrations/down/108_pos_branch_backfill_down.sql") + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, string(down)); err != nil { + t.Fatalf("applying the down file: %v", err) + } + if got := branch108(t, conn, "till_sessions", openSess); got == nil || *got != branchX { + t.Errorf("the down file nulled the open session's branch to %v, want it kept", got) + } + if got := branch108(t, conn, "till_sessions", stuckSess); got != nil { + t.Errorf("the down file set the stuck session's branch to %v", got) + } + for table, ids := range map[string][]uuid.UUID{"till_sessions": tills, "till_z_reports": zreports, "pos_returns": returns} { + if after := digest108(t, conn, table, ids); after != digests[table] { + t.Errorf("the down file moved more than the branch on %s", table) + } + } +} diff --git a/core/migrations/108_pos_branch_backfill.sql b/core/migrations/108_pos_branch_backfill.sql new file mode 100644 index 00000000..ba6d9d54 --- /dev/null +++ b/core/migrations/108_pos_branch_backfill.sql @@ -0,0 +1,43 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Migration: 108_pos_branch_backfill +-- Description: the till session, Z report and return reads filter on their +-- branch_id, which hides every row written before that column was stamped +-- from any caller with a branch context: an open drawer left at upgrade reads +-- as no drawer (it can be neither closed nor replaced) and the legacy Z +-- reports and returns vanish from the branch scoped reads. This backfills the +-- branch on the legacy rows of the three tables the filter reads, each row +-- from its own register the way the writes already resolve it (the round 6 +-- rule: COALESCE(the register's location's branch, pos_registers.branch_id)). +-- It touches only rows whose branch_id IS NULL, sets nothing else, moves no +-- money and deletes nothing, so it is idempotent: a row it has set is never +-- visited again. A row whose register names no branch is left exactly as it +-- is rather than guessed at, and reported by a NOTICE naming the count and +-- the first ids, so an operator can reconcile it by hand. + +DO $$ +DECLARE + tbl TEXT; + resolved TEXT; + set_count INTEGER; + stuck INTEGER; + stuck_ids TEXT; +BEGIN + FOREACH tbl IN ARRAY ARRAY['till_sessions', 'till_z_reports', 'pos_returns'] LOOP + resolved := 'SELECT COALESCE(l.branch_id, r.branch_id) FROM pos_registers r ' + || 'LEFT JOIN locations l ON l.id = r.location_id WHERE r.id = x.register_id'; + EXECUTE format('UPDATE %I x SET branch_id = (%s) WHERE x.branch_id IS NULL AND (%s) IS NOT NULL', + tbl, resolved, resolved); + GET DIAGNOSTICS set_count = ROW_COUNT; + EXECUTE format('SELECT COUNT(*), COALESCE((SELECT string_agg(s.id, '', '') + FROM (SELECT x.id::text AS id FROM %I x WHERE x.branch_id IS NULL AND (%s) IS NULL + ORDER BY x.id LIMIT 5) s), '''') + FROM %I x WHERE x.branch_id IS NULL AND (%s) IS NULL', + tbl, resolved, tbl, resolved) INTO stuck, stuck_ids; + RAISE NOTICE '108: %: set the branch from its register on % row(s)', tbl, set_count; + IF stuck > 0 THEN + RAISE NOTICE '108: %: % row(s) name a register with no branch and were left as they are, first ids: %', tbl, stuck, stuck_ids; + END IF; + END LOOP; +END $$; diff --git a/core/migrations/down/108_pos_branch_backfill_down.sql b/core/migrations/down/108_pos_branch_backfill_down.sql new file mode 100644 index 00000000..3be92c02 --- /dev/null +++ b/core/migrations/down/108_pos_branch_backfill_down.sql @@ -0,0 +1,20 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Rollback for 108_pos_branch_backfill.sql. +-- +-- Lives in migrations/down/ so cmd/migrate's `migrations/*.sql` glob cannot +-- pick it up and apply it as a forward migration. Apply by hand. +-- +-- What does not come back: the branch each row was given. The down leaves the +-- data on purpose. The branch was read from each row's own register, so it is +-- a fact of the row's history, not a guess the rollback could undo; and +-- nulling it again would put every legacy till session, Z report and return +-- back outside the branch scoped reads, which is the regression the forward +-- migration repairs (an open drawer reading as no drawer, history invisible +-- to a caller with a branch selected). No money moves either way. + +DO $$ +BEGIN + RAISE NOTICE '108 down: the backfilled branch_id values stay on the till session, Z report and return rows'; +END $$; From 0c7e145cb667b38e95e68c2e65e645ac045d5356 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 14:48:47 -0700 Subject: [PATCH 59/74] pos: a user's register branch refusal on the sale answers 403, not 500 (r15 P3-1) The transaction create refuses a request whose branch context disagrees with the register's own branch (the round 6 rule), but the handler mapped every service error to 500, so the refusal read as an internal error. CreateTransaction now returns a typed RegisterBranchRefusedError and StartTransaction answers it 403 with the refusal's words, the same envelope the machine key cashier refusal uses (httputil.RespondError answers the generic message of the status). Nothing is written either way; the offline sync keeps reporting the same refusal per item in its errors array. Test: an admin with a branch selected selling on another branch's register answers 403 naming the rule with no transaction written, and the control sale on the caller's own register still answers 201 at its branch. Signed-off-by: colton --- .../app/serve/wire_key_branch_round7_test.go | 54 ++++++++++++++++++ core/internal/pos/handler.go | 56 +++++++++++-------- core/internal/pos/repository.go | 14 ++++- 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go index 37829696..8ceb9c84 100644 --- a/core/internal/app/serve/wire_key_branch_round7_test.go +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -8,6 +8,7 @@ package serve import ( "context" + "encoding/json" "net/http" "os" "strings" @@ -148,3 +149,56 @@ func TestKeyBranchPin_Migration108LegacyReturnStaysReachable(t *testing.T) { t.Errorf("the legacy return list with X-Branch-Id the register's branch: %d %s, want the return listed", status, buf) } } + +// Fix round 7 (r15 P3-1): a user's sale on a register whose branch disagrees +// with the request's branch context is refused (the round 6 rule), but the +// handler answered it with the 500 it maps every service error to. The refusal +// answers 403 like the other register refusals, nothing written; the control +// sale on the caller's own register still answers 201 at its branch. +func TestKeyBranchPin_UserRegisterBranchRefusalAnswers403(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + regA := f.r15reg(t, "txa", uuid.New(), f.branchA) + regB := f.r15reg(t, "txb", uuid.New(), f.branchB) + hdr := map[string]string{"X-Branch-Id": f.branchA.String()} + created := []uuid.UUID{} + t.Cleanup(func() { + for _, id := range created { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, id) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, id) + } + }) + + status, buf := f.userAs(t, "admin", "POST", "/api/v1/pos/transactions", `{"register_id":"`+regB+`"}`, hdr) + if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { + t.Errorf("a user's sale on another branch's register: %d %s, want the 403 naming the register rule", status, buf) + } + if n := f.transactionsOn(t, regB); n != 0 { + t.Errorf("the refused sale wrote %d transactions on the register, want none", n) + } + + // The control: the caller's own register. + body := `{"register_id":"` + regA + `","cashier_id":"` + uuid.NewString() + `"}` + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/transactions", body, hdr) + var tx struct { + ID uuid.UUID `json:"id"` + BranchID *uuid.UUID `json:"branch_id"` + } + if err := json.Unmarshal(buf, &tx); err == nil && tx.ID != uuid.Nil { + created = append(created, tx.ID) + } + if status != http.StatusCreated || tx.BranchID == nil || *tx.BranchID != f.branchA { + t.Errorf("a user's sale on its own register: %d %s, want 201 at branch A", status, buf) + } +} + +// transactionsOn counts the transactions a register holds. +func (f *keyPinFixture) transactionsOn(t *testing.T, register string) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM pos_transactions WHERE register_id = $1`, register).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index c4b03d63..a4a8599a 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -5,6 +5,7 @@ package pos import ( "encoding/json" + "errors" "log/slog" "net/http" "time" @@ -66,10 +67,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("GET /api/v1/pos/returns", guard(h.ListReturns)) } -// cashierRefusalBody is the wire ADR's error envelope for the one refusal -// this package writes. The shared writer lands with the platform packages; +// posRefusalBody is the wire ADR's error envelope for the refusals this +// package writes. The shared writer lands with the platform packages; // pkg/actor and pkg/middleware write the same shape for theirs. -type cashierRefusalBody struct { +type posRefusalBody struct { Error struct { Code string `json:"code"` Message string `json:"message"` @@ -79,6 +80,28 @@ type cashierRefusalBody struct { } `json:"meta"` } +// writePosRefusal answers the request with 403 and the refusal's words. +// httputil.RespondError logs the words but answers the client with the +// generic message of the status, and a caller has to read which register and +// branch the refusal names. +func writePosRefusal(w http.ResponseWriter, r *http.Request, message string, logAttrs ...any) { + reqID := w.Header().Get("X-Request-ID") + if reqID == "" { + reqID = r.Header.Get("X-Request-ID") + } + + var body posRefusalBody + body.Error.Code = "forbidden" + body.Error.Message = message + body.Meta.RequestID = reqID + + attrs := append([]any{"status", http.StatusForbidden, "method", r.Method, "path", r.URL.Path, "request_id", reqID}, logAttrs...) + slog.Warn("POS refusal", attrs...) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(body) +} + // refuseMachineKeyCashier answers the request with 403 when the caller // authenticated with a machine key, reporting whether it answered. The // cashier on a POS mutation is a human user whose JWT names them; a machine @@ -93,26 +116,7 @@ func refuseMachineKeyCashier(w http.ResponseWriter, r *http.Request) bool { return false } - reqID := w.Header().Get("X-Request-ID") - if reqID == "" { - reqID = r.Header.Get("X-Request-ID") - } - - var body cashierRefusalBody - body.Error.Code = "forbidden" - body.Error.Message = "a cashier must be a user" - body.Meta.RequestID = reqID - - slog.Warn("machine key refused on a POS cashier route", - "key_id", keyID, - "status", http.StatusForbidden, - "method", r.Method, - "path", r.URL.Path, - "request_id", reqID, - ) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(body) + writePosRefusal(w, r, "a cashier must be a user", "key_id", keyID, "reason", "machine key on a POS cashier route") return true } @@ -329,6 +333,12 @@ func (h *Handler) StartTransaction(w http.ResponseWriter, r *http.Request) { tx, err := h.service.StartTransaction(r.Context(), req.RegisterID, req.CashierID, req.CustomerID) if err != nil { + // A register of another branch is a refusal, not an internal error. + var refused *RegisterBranchRefusedError + if errors.As(err, &refused) { + writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") + return + } httputil.RespondError(w, r, "failed to start transaction", http.StatusInternalServerError, err) return } diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 088c01cb..df7ef917 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -14,6 +14,18 @@ import ( "github.com/google/uuid" ) +// RegisterBranchRefusedError is the refusal a write answers when the caller's +// branch context disagrees with the register's own branch. It is a typed error +// so the HTTP layer can answer it 403 instead of the 500 a plain service +// error maps to, nothing written either way. +type RegisterBranchRefusedError struct { + RegisterID string +} + +func (e *RegisterBranchRefusedError) Error() string { + return fmt.Sprintf("register %s belongs to a different branch than the request", e.RegisterID) +} + // Repository handles POS data persistence. type Repository interface { CreateTransaction(ctx context.Context, tx *POSTransaction) error @@ -85,7 +97,7 @@ func (r *PostgresRepository) CreateTransaction(ctx context.Context, tx *POSTrans // Reject if the caller's branch context disagrees with the register's branch. if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && registerBranch != nil && *reqBranch != *registerBranch { - return fmt.Errorf("register %s belongs to a different branch than the request", tx.RegisterID) + return &RegisterBranchRefusedError{RegisterID: tx.RegisterID} } query := ` From 9c84e547acd66756020b3f9632ee8d7d9729f4a6 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 14:50:34 -0700 Subject: [PATCH 60/74] pos: till open and a return hold the caller's branch against the register's (r15 P3-2) A till open and a return stamped the register's branch whatever the caller's branch context named, so a cashier granted only one branch, with that branch selected, opened a till and recorded a cash return on another branch's register. The register on a branched location half of that predates this PR; the locationless register half came with round 6's branch fallback. Both now refuse the way the sale already does, before anything is written: CreateTillSession and ReturnSale return the typed register branch refusal when the request's branch context is set and differs from the register's own branch, and the handlers answer it 403 with the refusal's words. The caller's own register passes for both. Test: an admin with branch A selected on a register of branch B answers 403 for the till open and the return with no session and no return written, and the A controls answer 201 with the session and the return at branch A. Signed-off-by: colton --- .../app/serve/wire_key_branch_round7_test.go | 76 +++++++++++++++++++ core/internal/pos/handler.go | 12 +++ core/internal/pos/returns.go | 9 ++- core/internal/pos/till_repository.go | 5 ++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go index 8ceb9c84..ed476dd2 100644 --- a/core/internal/app/serve/wire_key_branch_round7_test.go +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -202,3 +202,79 @@ func (f *keyPinFixture) transactionsOn(t *testing.T, register string) int { } return n } + +// Fix round 7 (r15 P3-2): till open and a return stamped the register's +// branch whatever the caller's branch context named, so a cashier granted only +// A, sending X-Branch-Id A, opened a till and recorded a cash return at B. +// (The register on a branched location half of that predates this PR; the +// locationless half came with round 6's fallback.) Both now hold the request's +// branch against the register's the way the sale already does: 403, nothing +// written, and the caller's own register passes. +func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { + f, _, _ := round5Fixture(t) + ctx := context.Background() + regA := f.r15reg(t, "t3a", uuid.New(), f.branchA) + regB := f.r15reg(t, "t3b", uuid.New(), f.branchB) + hdr := map[string]string{"X-Branch-Id": f.branchA.String()} + sessionsOn := func(reg string) (n int) { + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM till_sessions WHERE register_id = $1`, reg).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + returnsOn := func(reg string) (n int) { + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM pos_returns WHERE register_id = $1`, reg).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + t.Cleanup(func() { + for _, reg := range []string{regA, regB} { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_return_lines WHERE return_id IN (SELECT id FROM pos_returns WHERE register_id = $1)`, reg) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_returns WHERE register_id = $1`, reg) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM till_sessions WHERE register_id = $1`, reg) + } + }) + + // The till open on another branch's register: 403, no session. + status, buf := f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regB+`","opening_float":50}`, hdr) + if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { + t.Errorf("a user opening a till on another branch's register: %d %s, want the 403 naming the register rule", status, buf) + } + if n := sessionsOn(regB); n != 0 { + t.Errorf("the refused till open wrote %d sessions on the register, want none", n) + } + + // The return on another branch's register: 403, no return. + retBody := `{"register_id":"` + regB + `","refund_method":"CASH","lines":[{"product_id":"` + + f.posProd.String() + `","quantity":1,"unit_price":1}]}` + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/returns", retBody, hdr) + if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { + t.Errorf("a user recording a return on another branch's register: %d %s, want the 403 naming the register rule", status, buf) + } + if n := returnsOn(regB); n != 0 { + t.Errorf("the refused return wrote %d returns on the register, want none", n) + } + + // The A controls: the caller's own register passes for both. + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regA+`","opening_float":50}`, hdr) + var sess struct { + ID uuid.UUID `json:"id"` + BranchID *uuid.UUID `json:"branch_id"` + } + _ = json.Unmarshal(buf, &sess) + if status != http.StatusCreated || sess.BranchID == nil || *sess.BranchID != f.branchA { + t.Errorf("a user opening a till on its own register: %d %s, want 201 at branch A", status, buf) + } + retBody = `{"register_id":"` + regA + `","refund_method":"CASH","lines":[{"product_id":"` + + f.posProd.String() + `","quantity":1,"unit_price":1}]}` + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/returns", retBody, hdr) + var ret struct { + ID uuid.UUID `json:"id"` + BranchID *uuid.UUID `json:"branch_id"` + } + _ = json.Unmarshal(buf, &ret) + if status != http.StatusCreated || ret.BranchID == nil || *ret.BranchID != f.branchA { + t.Errorf("a user recording a return on its own register: %d %s, want 201 at branch A", status, buf) + } +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index a4a8599a..5a369019 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -141,6 +141,12 @@ func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) { } ret, err := h.service.ReturnSale(r.Context(), cashierID, req) if err != nil { + // A register of another branch is a refusal, not a bad request. + var refused *RegisterBranchRefusedError + if errors.As(err, &refused) { + writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") + return + } httputil.RespondError(w, r, err.Error(), http.StatusBadRequest, err) return } @@ -239,6 +245,12 @@ func (h *Handler) OpenTill(w http.ResponseWriter, r *http.Request) { } session, err := h.service.OpenTill(r.Context(), req.RegisterID, cashierID, int64(req.OpeningFloat*100.0+0.5)) if err != nil { + // A register of another branch is a refusal, not a conflict. + var refused *RegisterBranchRefusedError + if errors.As(err, &refused) { + writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") + return + } httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) return } diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index d32ca6aa..44ce5881 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -13,6 +13,7 @@ import ( "github.com/gablelbm/gable/internal/inventory" "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" ) @@ -144,8 +145,14 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, req Retur return nil, fmt.Errorf("return total must be positive") } - // Resolve the register's branch (for tax and stamping); best-effort. + // Resolve the register's branch (for tax and stamping); best-effort. The + // caller's branch context is held against it the way the sale does, so a + // request for another branch's register is refused before anything is + // written. branchID, _ := s.repo.GetRegisterBranch(ctx, req.RegisterID) + if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && branchID != nil && *reqBranch != *branchID { + return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} + } // Tax mirrors the sale path so a full return refunds the tax the sale // charged (same exemption-aware, branch-rate calculation). diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index f52fb167..9cc947cd 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -41,6 +41,11 @@ func (r *PostgresRepository) CreateTillSession(ctx context.Context, s *TillSessi if err != nil { return fmt.Errorf("failed to resolve register branch for till: %w", err) } + // Hold the caller's branch context against the register's branch the + // way the sale does: nothing is written on a mismatch. + if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && registerBranch != nil && *reqBranch != *registerBranch { + return &RegisterBranchRefusedError{RegisterID: s.RegisterID} + } s.BranchID = registerBranch } From 8cdc7527d413efbc7f86e396025641f96bd31908 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 14:57:41 -0700 Subject: [PATCH 61/74] pos: the offline sync refuses a batch register of another branch before the log row (r15 P3-3) The sync logged its batch under the batch register_id whatever the pin held, so a key pinned to A sending batch register regB with an item on regA wrote a pos_sync_log row naming another branch's register. The batch register is the register the client says the batch ran on, so it is held to the caller's branch context the way each replayed create is: the whole batch answers the typed register branch refusal (403) before any item runs and before the log row is written. The walled route sweep's sync leg becomes a held leg (the module itself now refuses the foreign batch register) and its control on the pin's own register still reaches the module. Also resolves the duplicate CONTRACT-CHANGES id: the pos row of round 6 becomes SEC-BOUND-KEY-PIN-7, the id this round's rows carry; the allowlist notes state the batch register hold and the 403 register refusals on the sale, the till open and the return; ADR 0007 section 5.5 states the sync's batch register rule. Test: a bound key's sync with another branch's batch register answers 403 with no pos_sync_log row and no replayed transaction, and the control batch on the pin's own register syncs and logs its one row. Signed-off-by: colton --- .../app/serve/wire_key_branch_round7_test.go | 64 +++++++++++++++++++ .../app/serve/wire_key_branch_walled_test.go | 7 +- core/internal/pos/handler.go | 7 ++ core/internal/pos/service.go | 13 ++++ core/pkg/middleware/keybranch_allow.go | 8 +-- ...7-drafts-links-and-confirm-gated-scopes.md | 4 +- docs/refactor/CONTRACT-CHANGES.md | 2 +- 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go index ed476dd2..cc866fcd 100644 --- a/core/internal/app/serve/wire_key_branch_round7_test.go +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -13,6 +13,7 @@ import ( "os" "strings" "testing" + "time" "github.com/google/uuid" ) @@ -203,6 +204,17 @@ func (f *keyPinFixture) transactionsOn(t *testing.T, register string) int { return n } +// transactionsWithID counts the transactions carrying the id given. +func (f *keyPinFixture) transactionsWithID(t *testing.T, id uuid.UUID) int { + t.Helper() + var n int + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM pos_transactions WHERE id = $1`, id).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + // Fix round 7 (r15 P3-2): till open and a return stamped the register's // branch whatever the caller's branch context named, so a cashier granted only // A, sending X-Branch-Id A, opened a till and recorded a cash return at B. @@ -278,3 +290,55 @@ func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { t.Errorf("a user recording a return on its own register: %d %s, want 201 at branch A", status, buf) } } + +// Fix round 7 (r15 P3-3): the offline sync logged its batch under the batch +// register_id whatever the pin held, so a key pinned to A sending batch +// register regB with an item on regA wrote a pos_sync_log row naming another +// branch's register. The batch register is now held to the caller's branch +// context before any item runs and before the log row is written: the whole +// batch is refused 403, nothing written, and the pin's own register passes. +func TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister(t *testing.T) { + f, _, first := round5Fixture(t) + ctx := context.Background() + client := uuid.New() + batch := "pin-r7-" + uuid.NewString()[:8] + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, client) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, client) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_sync_log WHERE batch_id = $1`, batch) + }) + syncBody := func(batchRegister, itemRegister string) string { + return `{"batch_id":"` + batch + `","register_id":"` + batchRegister + + `","items":[{"client_id":"` + client.String() + `","register_id":"` + itemRegister + + `","cashier_id":"` + uuid.NewString() + `","customer_id":"` + f.custID.String() + + `","items":[],"tenders":[],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` + } + logRows := func() (n int) { + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM pos_sync_log WHERE batch_id = $1`, batch).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + // The batch register of another branch: the whole batch is refused before + // the log row is written. + status, buf := f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody(f.regB, f.regA), nil) + if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { + t.Errorf("bound key sync with another branch's batch register: %d %s, want the 403 naming the register rule", status, buf) + } + if n := logRows(); n != 0 { + t.Errorf("the refused sync wrote %d pos_sync_log rows for the batch, want none", n) + } + if n := f.transactionsWithID(t, client); n != 0 { + t.Errorf("the refused sync wrote %d transactions, want none", n) + } + + // The control: the pin's own register as the batch register passes. + status, buf = f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody(f.regA, f.regA), nil) + if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { + t.Errorf("bound key sync with the pin's own batch register: %d %s, want the sale synced", status, buf) + } + if n := logRows(); n != 1 { + t.Errorf("the control sync wrote %d pos_sync_log rows, want its one", n) + } +} diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go index e22444a5..148e4604 100644 --- a/core/internal/app/serve/wire_key_branch_walled_test.go +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -484,8 +484,11 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { {method: "POST", path: "/api/v1/pos/transactions/%s/complete", body: posTender, twin: f.txnB, own: f.txnA, first: true, sameAsMissing: true, noControl: "the completion deducts stock from a location this fixture does not stock, so the control answers 422 past the body and the transaction read"}, {method: "POST", path: "/api/v1/pos/transactions/%s/void", twin: f.txnB, own: txnA2, first: true, sameAsMissing: true}, - {method: "POST", path: "/api/v1/pos/sync", body: posSync, twin: f.txnB, own: f.txnA, first: true, - reach: "the sync replays a batch for the register its body names; the leg proves the call leaves branch B's transactions as they were"}, + // The sync is held by the module itself now: a batch whose register + // names another branch is refused before any item runs (round 7), so + // the foreign leg is a held leg and the control on the pin's own + // register reaches the module. + {method: "POST", path: "/api/v1/pos/sync", body: posSync, twin: f.txnB, own: f.txnA, first: true}, // The creates that take the branch they land on from the body. {method: "POST", path: "/api/v1/customers", twin: f.branchB, own: f.branchA, body: inBranch(`{"account_number":"PINNEW-%s","name":"pin new","primary_branch_id":%q}`)}, diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 5a369019..41f94713 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -535,6 +535,13 @@ func (h *Handler) SyncOffline(w http.ResponseWriter, r *http.Request) { resp, err := h.service.SyncOfflineTransactions(r.Context(), req) if err != nil { + // A batch register of another branch is a refusal, not an internal + // error. + var refused *RegisterBranchRefusedError + if errors.As(err, &refused) { + writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") + return + } httputil.RespondError(w, r, "offline sync failed", http.StatusInternalServerError, err) return } diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index f7bb4c81..5db10860 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -630,6 +630,19 @@ func (s *Service) SyncOfflineTransactions(ctx context.Context, req OfflineSyncRe BatchID: req.BatchID, } + // The batch register is the register the client says the batch ran on, + // and the log row names it: hold it to the caller's branch context the + // way each replayed create is held, before any item runs and before the + // batch is logged, so neither the log nor a replay names another branch's + // register for a caller held to one branch. + if req.RegisterID != "" { + if branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID); err == nil && branch != nil { + if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { + return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} + } + } + } + syncTag := "offline-v1" for _, offlineTx := range req.Items { diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 6ad98916..3d03261b 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -397,11 +397,11 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, - "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id); no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create, and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id); no machine key cashier"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, - "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after; no machine key cashier"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 449400d8..1f914355 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -727,7 +727,9 @@ A document write that names a customer in its body is held to the pin the same way a path customer is: the cash and card payment, the credit memo (create and update), the order and the quote (create and update, a document moved onto another customer included), the offline POS sync (the customer -each replayed sale names, whose ACCOUNT tender invoices it) and the draft +each replayed sale names, whose ACCOUNT tender invoices it, and the batch +register itself, a batch naming another branch's register refused 403 before +any item runs and before the `pos_sync_log` row names it) and the draft promotions, which read the customer from the draft's stored payload, are refused a bound key whose pin does not hold that customer (403 naming `customer_id`, the `key.branch_refused` row, nothing written). Without it diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 6c3bc45c..e5feb1d0 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -296,4 +296,4 @@ get an entry. | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | | SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | | SEC-BOUND-KEY-PIN-6 | the branch bound key's walls, fix round 6 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 13 and the principal's item 54 rulings found: a bound key's pos sync on a register whose location row names no branch wrote an open transaction at the default branch, because the register's branch was read only through `locations` and not from `pos_registers.branch_id` (the till open and the return derived it the same way); the refusal of a user without the finance or owner role, or of an unbound key without `customers:restricted`, on a shared customer's restricted fields used other words than the bound key's and wrote no row; the customer routes admitted finance on every route; and a bound key changed and deleted a CRM activity of a shared customer | A register's branch is `COALESCE(l.branch_id, r.branch_id)` in the transaction create, the till open and the return branch lookup, so the pin check always runs. The caller refusals use the bound key's words and write a `customer.shared_restricted_refused` row (actor, fields, branch, method, path). Finance reaches only the header write, the salesperson and the escalation policy; every other customer route is back to `admin`, `owner`, `sales`. On a shared customer a bound key adds an activity and never changes or deletes one (403 in the contact rule's words, with its `key.branch_refused` row). New tests: the register branch legs, the stored job recheck on the conversion, the claims-less mint refusal and a bound key that truly carries the scope | -| SEC-BOUND-KEY-PIN-6 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT DO UPDATE`) so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | +| SEC-BOUND-KEY-PIN-7 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT DO UPDATE`) so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | From bccdf4ab7b736a211b7a5ee983c4ce80dc88948f Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 15:00:18 -0700 Subject: [PATCH 62/74] customers: finance may read the customer and its escalation policy (item 57 question 1) Finance passes only the three restricted field routes but was refused the two reads those writes need, so it could never hold the revision it must send as If-Match on a record it cannot read (r14 section 3, the principal's item 57 ruling: the two reads open, reads only). The record read and the escalation policy read move to the third guard (the restricted field guard), so finance reaches exactly the three restricted writes and the two reads they need; every other customer, contact, ship-to and activity route keeps the roles it had. The round 6 test reads its revisions as finance now, and asserts finance's two reads answer 200 while every other route of the groups still answers the 403 insufficient role. Signed-off-by: colton --- core/internal/app/serve/wire_branch_wall.go | 6 ++-- .../app/serve/wire_key_branch_legs_test.go | 9 ++++- .../app/serve/wire_key_branch_round6_test.go | 35 +++++++++++++------ .../app/serve/wire_key_branch_shared_test.go | 14 ++++++++ core/internal/customer/handler.go | 9 +++-- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 3223f976..248b575a 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -121,8 +121,10 @@ func (w *branchWall) inventory(mux *http.ServeMux, svc *inventory.Service) { // shared customer's restricted fields change only for the finance or owner // role (a user) or an unbound key carrying customers:restricted, which is why // the third guard admits finance on exactly the three restricted field routes -// (the header write, the salesperson and the escalation policy) and nowhere -// else: every other customer route keeps the roles it had. The routes note their method and path so a +// (the header write, the salesperson and the escalation policy) and the two +// reads those writes need (the record and its escalation policy, so finance +// can hold the revision it must send as If-Match, item 57) and nowhere else: +// every other customer route keeps the roles it had. The routes note their method and path so a // refusal the service serves can name them in its key.branch_refused row. func (w *branchWall) customers(mux *http.ServeMux, svc *customer.Service) { svc.WithBranchGuard(customerguard.New(w.guard)).WithSharedGuard(customerguard.NewShared(w.keyWall)) diff --git a/core/internal/app/serve/wire_key_branch_legs_test.go b/core/internal/app/serve/wire_key_branch_legs_test.go index 9219fdc4..b0acf97c 100644 --- a/core/internal/app/serve/wire_key_branch_legs_test.go +++ b/core/internal/app/serve/wire_key_branch_legs_test.go @@ -143,8 +143,15 @@ func multipartFile(field, name, content string) (string, string) { // customerReplace builds a PUT /customers/{id} body from the customer's // current values with the change given applied, on the revision it reads. func (f *keyPinFixture) customerReplace(t *testing.T, id uuid.UUID, change map[string]any) string { + return f.customerReplaceAs(t, "admin", id, change) +} + +// customerReplaceAs is customerReplace with the record read as the role given: +// a role that may write a restricted field but not list customers (finance, +// item 57) still reads the record it replaces. +func (f *keyPinFixture) customerReplaceAs(t *testing.T, role string, id uuid.UUID, change map[string]any) string { t.Helper() - cur := f.readJSON(t, "/api/v1/customers/"+id.String()) + cur := f.readJSONAs(t, role, "/api/v1/customers/"+id.String()) body := map[string]any{"revision": cur["revision"]} for _, k := range []string{"account_number", "name", "email", "phone", "address", "tier", "is_active", "price_level_id", "salesperson_id", "credit_limit_cents", "currency", "payment_terms_id", "po_required"} { diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index 5f7638c6..91796277 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -173,10 +173,13 @@ func TestKeyBranchPin_BoundKeyWithTheScopeIsRefused(t *testing.T) { } } -// Fix round 6 (item 54 question 1): the finance role reaches only the three -// restricted field routes of a customer, not every customer route. Customer -// and contact create, the reads and the other writes answer finance what -// refactor/v1 answered (403 insufficient role); the restricted changes pass. +// Fix round 6 (item 54 question 1) and round 7 (principal item 57 question 1): +// the finance role reaches only the three restricted field routes of a +// customer and the two reads those writes need (the record and its escalation +// policy: finance cannot send If-Match on a record it cannot read), not every +// customer route. Customer and contact create, the other reads and the other +// writes answer finance what refactor/v1 answered (403 insufficient role); the +// restricted changes pass, on revisions finance reads itself. func TestKeyBranchPin_FinanceReachesOnlyTheRestrictedRoutes(t *testing.T) { f, _, _ := round5Fixture(t) s := seedSharedCustomer(t, f, f.branchA, f.branchB) @@ -186,10 +189,13 @@ func TestKeyBranchPin_FinanceReachesOnlyTheRestrictedRoutes(t *testing.T) { {"POST", "/api/v1/customers", `{"name":"finance create"}`}, {"POST", sh + "/contacts", `{"name":"finance contact"}`}, {"POST", sh + "/ship-tos", `{"name":"finance ship to"}`}, - {"GET", sh, ""}, {"GET", "/api/v1/customers", ""}, {"GET", sh + "/contacts", ""}, - {"GET", sh + "/escalation-policy", ""}, + {"GET", sh + "/ship-tos", ""}, + {"GET", "/api/v1/ship-tos/" + s.shipTo.String(), ""}, + {"GET", "/api/v1/contacts/" + s.contact.String(), ""}, + {"GET", sh + "/activities", ""}, + {"POST", sh + "/activities", `{"activity_type":"call","description":"finance activity"}`}, } for _, r := range refused { status, buf := f.userAs(t, "finance", r.method, r.path, r.body, nil) @@ -197,22 +203,31 @@ func TestKeyBranchPin_FinanceReachesOnlyTheRestrictedRoutes(t *testing.T) { t.Errorf("finance %s %s: %d %s, want the 403 insufficient role refactor/v1 answers", r.method, r.path, status, buf) } } + // The two reads the restricted writes need (item 57 question 1): finance + // reads the customer and its escalation policy, and nothing else opened. + for _, path := range []string{sh, sh + "/escalation-policy"} { + status, buf := f.userAs(t, "finance", "GET", path, "", nil) + if status != http.StatusOK { + t.Errorf("finance GET %s: %d %s, want 200, the read the restricted write needs", path, status, buf) + } + } // The sales user keeps the reads. if status, buf := f.userAs(t, "sales", "GET", sh, "", nil); status != http.StatusOK { t.Errorf("sales GET customer: %d %s, want 200", status, buf) } - // The three restricted field routes pass for finance on the shared customer. + // The three restricted field routes pass for finance on the shared + // customer, on the revisions finance reads itself. if status, buf := f.userAs(t, "finance", "PUT", sh, - f.customerReplace(t, s.id, map[string]any{"credit_limit_cents": 333000}), nil); status != http.StatusOK { + f.customerReplaceAs(t, "finance", s.id, map[string]any{"credit_limit_cents": 333000}), nil); status != http.StatusOK { t.Errorf("finance changing the credit limit: %d %s, want 200", status, buf) } - cur := f.readJSON(t, sh) + cur := f.readJSONAs(t, "finance", sh) raw, _ := json.Marshal(map[string]any{"salesperson_id": s.salesperson.String(), "revision": cur["revision"]}) if status, buf := f.userAs(t, "finance", "PATCH", sh+"/salesperson", string(raw), nil); status != http.StatusOK { t.Errorf("finance changing the salesperson: %d %s, want 200", status, buf) } - esc := f.readJSON(t, sh+"/escalation-policy") + esc := f.readJSONAs(t, "finance", sh+"/escalation-policy") raw, _ = json.Marshal(map[string]any{"policy": "require_ack", "threshold_percent": "7.5", "revision": esc["revision"]}) if status, buf := f.userAs(t, "finance", "PUT", sh+"/escalation-policy", string(raw), nil); status != http.StatusOK { t.Errorf("finance setting the escalation policy: %d %s, want 200", status, buf) diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index 559b1b2e..485f447f 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -102,6 +102,20 @@ func (f *keyPinFixture) readJSON(t *testing.T, path string) map[string]any { return m } +// readJSONAs reads a record as the role given, as a map. +func (f *keyPinFixture) readJSONAs(t *testing.T, role, path string) map[string]any { + t.Helper() + status, buf := f.userAs(t, role, "GET", path, "", nil) + if status != http.StatusOK { + t.Fatalf("read %s as %s: %d %s", path, role, status, buf) + } + var m map[string]any + if err := json.Unmarshal(buf, &m); err != nil { + t.Fatal(err) + } + return m +} + // sharedChangeRows reads the audit rows a dealer wide key's change to a // shared customer left, oldest first. func (f *keyPinFixture) sharedChangeRows(t *testing.T, customer uuid.UUID) []map[string]any { diff --git a/core/internal/customer/handler.go b/core/internal/customer/handler.go index bbabe40d..8acb8a8a 100644 --- a/core/internal/customer/handler.go +++ b/core/internal/customer/handler.go @@ -36,7 +36,10 @@ func NewHandler(service *Service) *Handler { // and editing terms is a finance act, choosing them is a sales one). A third // guard, when given, replaces it on the three routes that change a customer's // restricted fields (the header write, the salesperson and the escalation -// policy), so a role can reach those without reaching the rest. +// policy) and on the two reads those writes need (the record and its +// escalation policy: a role that may change a restricted field under If-Match +// must be able to read the revision, item 57), so a role can reach those +// without reaching the rest. func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guardWith := func(g func(http.Handler) http.Handler) func(http.HandlerFunc) http.HandlerFunc { return func(handler http.HandlerFunc) http.HandlerFunc { @@ -62,10 +65,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("GET /api/v1/customers", guard(h.HandleListCustomers)) mux.HandleFunc("POST /api/v1/customers", guard(h.HandleCreateCustomer)) - mux.HandleFunc("GET /api/v1/customers/{id}", guard(h.HandleGetCustomer)) + mux.HandleFunc("GET /api/v1/customers/{id}", restricted(h.HandleGetCustomer)) mux.HandleFunc("PUT /api/v1/customers/{id}", restricted(h.HandleUpdateCustomer)) mux.HandleFunc("PATCH /api/v1/customers/{id}/salesperson", restricted(h.HandleUpdateSalesperson)) - mux.HandleFunc("GET /api/v1/customers/{id}/escalation-policy", guard(h.HandleGetEscalationPolicy)) + mux.HandleFunc("GET /api/v1/customers/{id}/escalation-policy", restricted(h.HandleGetEscalationPolicy)) mux.HandleFunc("PUT /api/v1/customers/{id}/escalation-policy", restricted(h.HandleSetEscalationPolicy)) mux.HandleFunc("GET /api/v1/price_levels", guard(h.HandleListPriceLevels)) From 5dfd25c7086392146b09e492137e355af0e1e9f0 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 15:07:14 -0700 Subject: [PATCH 63/74] customers: the shared refusals name their caller behind one shared clause (item 57 question 2) Every shared customer refusal served the bound key's words, so a sales user read "a branch bound key may act only on its own branch", which is wrong for a user (the r14 and r15 reviews' note, the principal's item 57 ruling). The refusals now carry one clause naming the other branch behind a lead in that names the caller, words true for whoever reads them: the bound key's lead says "this key may act only on its own branch", an unbound key without the customers:restricted scope reads "this key may not change a shared customer's restricted fields", and a user without the finance or owner role reads "this role may not change a shared customer's restricted fields". The verdict marks a caller refusal served to a machine key so the service can choose the key's or the role's lead; the child rule (ship-tos, contacts, activities) refuses bound keys only and keeps the key's lead. The key branch wall's own refusals keep their words (they are served to a bound key alone). ADR 0007 section 5.5 quotes all three. Tests assert each caller's exact words: the round 7 leg drives the bound key, the unbound key and the sales user; the round 5 and round 6 legs are updated to the new words. Signed-off-by: colton --- .../app/serve/wire_key_branch_round5_test.go | 18 ++++--- .../app/serve/wire_key_branch_round6_test.go | 24 +++++---- .../app/serve/wire_key_branch_round7_test.go | 48 ++++++++++++++++++ .../customer/customerguard/customerguard.go | 3 +- core/internal/customer/shared.go | 50 ++++++++++++++----- core/pkg/middleware/keybranch_shared.go | 6 ++- ...7-drafts-links-and-confirm-gated-scopes.md | 22 +++++--- 7 files changed, 131 insertions(+), 40 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 87606fef..203d58db 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -279,7 +279,8 @@ func TestKeyBranchPin_ActivityContactOwnership(t *testing.T) { // role (a user) or an unbound machine key carrying the dedicated // customers:restricted scope, minted only by an owner and audited when it is // minted. A sales user, an admin user and every unbound key without the scope -// are refused in the same words naming the other branch; a branch bound key is +// are refused in caller specific words naming the other branch (the lead in +// names the caller, item 57); a branch bound key is // refused even with the scope; the allowed key's change keeps its // customer.shared_restricted_changed audit row. func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { @@ -324,7 +325,7 @@ func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { // The bound key with the scope is still refused (the pin rule comes first). if status, buf := f.key(t, f.bound, "PUT", sh, change(), nil); status != http.StatusForbidden || - !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + !strings.Contains(string(buf), "this key may act only on its own branch") { t.Errorf("bound key changing a restricted field: %d %s, want the pin's 403", status, buf) } @@ -336,11 +337,12 @@ func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { if f.branchB.String() < f.branchA.String() { lowest = f.branchB } - callerRefusal := "the customer is also held by branch " + lowest.String() + callerRefusal := "this key may not change a shared customer's restricted fields: the customer is also held by branch " + lowest.String() if status, buf := f.key(t, f.unbound, "PUT", sh, change(), nil); status != http.StatusForbidden || !strings.Contains(string(buf), callerRefusal) { - t.Errorf("unbound key without the scope changing a restricted field: %d %s, want the 403 naming branch B", status, buf) + t.Errorf("unbound key without the scope changing a restricted field: %d %s, want the 403 in the key's caller words", status, buf) } + roleRefusal := "this role may not change a shared customer's restricted fields: the customer is also held by branch " + lowest.String() before := len(f.sharedChangeRows(t, s.id)) if status, buf := f.key(t, minted.APIKey, "PUT", sh, change(), nil); status != http.StatusOK { t.Errorf("unbound key with the scope changing a restricted field: %d %s, want 200", status, buf) @@ -349,12 +351,12 @@ func TestKeyBranchPin_SharedRestrictedScope(t *testing.T) { t.Errorf("the scoped key's change left %d audit rows, want one new row", len(rows)-before) } - // The user legs: sales and admin are refused in the same words, finance - // and owner change the field (no audit row for a user). + // The user legs: sales and admin are refused in the role's caller words, + // finance and owner change the field (no audit row for a user). for _, role := range []string{"sales", "admin"} { if status, buf := f.userAs(t, role, "PUT", sh, change(), nil); status != http.StatusForbidden || - !strings.Contains(string(buf), callerRefusal) { - t.Errorf("%s user changing a restricted field: %d %s, want the 403 naming branch B", role, status, buf) + !strings.Contains(string(buf), roleRefusal) { + t.Errorf("%s user changing a restricted field: %d %s, want the 403 in the role's caller words", role, status, buf) } } rowsBefore := len(f.sharedChangeRows(t, s.id)) diff --git a/core/internal/app/serve/wire_key_branch_round6_test.go b/core/internal/app/serve/wire_key_branch_round6_test.go index 91796277..01b16dda 100644 --- a/core/internal/app/serve/wire_key_branch_round6_test.go +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -52,9 +52,11 @@ func (f *keyPinFixture) sharedRefusalRows(t *testing.T, customer uuid.UUID) int return n } -// Fix round 6 (r13 P3-1): the unbound key and the user refused on a shared -// customer's restricted fields get the bound key's words and leave a refusal -// row of their own; the callers the rule admits leave none. +// Fix round 6 (r13 P3-1) and round 7 (item 57 question 2): the unbound key +// and the user refused on a shared customer's restricted fields get a refusal +// whose lead in names the caller ("this key" for a machine key, "this role" +// for a user) ahead of the one clause naming the other branch, and leave a +// refusal row of their own; the callers the rule admits leave none. func TestKeyBranchPin_SharedCallerRefusalRow(t *testing.T) { f, _, _ := round5Fixture(t) s := seedSharedCustomer(t, f, f.branchA, f.branchB) @@ -66,12 +68,12 @@ func TestKeyBranchPin_SharedCallerRefusalRow(t *testing.T) { if f.branchB.String() < f.branchA.String() { lowest = f.branchB } - words := "a branch bound key may act only on its own branch: the customer is also held by branch " + lowest.String() + clause := "the customer is also held by branch " + lowest.String() - check := func(who string, status int, buf []byte, rowsBefore int) { + check := func(who, lead string, status int, buf []byte, rowsBefore int) { t.Helper() - if status != http.StatusForbidden || !strings.Contains(string(buf), words) { - t.Errorf("%s changing a restricted field: %d %s, want the 403 in the bound key's words naming branch %s", who, status, buf, lowest) + if status != http.StatusForbidden || !strings.Contains(string(buf), lead+": "+clause) { + t.Errorf("%s changing a restricted field: %d %s, want the 403 %q", who, status, buf, lead+": "+clause) } if got := f.sharedRefusalRows(t, s.id) - rowsBefore; got != 1 { t.Errorf("%s refusal left %d refusal rows, want one", who, got) @@ -79,11 +81,11 @@ func TestKeyBranchPin_SharedCallerRefusalRow(t *testing.T) { } before := f.sharedRefusalRows(t, s.id) status, buf := f.key(t, f.unbound, "PUT", sh, change(), nil) - check("unbound key without the scope", status, buf, before) + check("unbound key without the scope", "this key may not change a shared customer's restricted fields", status, buf, before) for _, role := range []string{"sales", "admin"} { before = f.sharedRefusalRows(t, s.id) status, buf = f.userAs(t, role, "PUT", sh, change(), nil) - check(role+" user", status, buf, before) + check(role+" user", "this role may not change a shared customer's restricted fields", status, buf, before) } // The row names the actor, the fields and the method. var kind, raw string @@ -161,7 +163,7 @@ func TestKeyBranchPin_BoundKeyWithTheScopeIsRefused(t *testing.T) { } rows := refusals() status, buf := f.key(t, scoped, "PUT", "/api/v1/customers/"+s.id.String(), change(s.id), nil) - if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + if status != http.StatusForbidden || !strings.Contains(string(buf), "this key may act only on its own branch") { t.Errorf("scoped bound key changing a shared customer's restricted field: %d %s, want the pin's 403", status, buf) } if after := refusals(); after != rows+1 { @@ -264,7 +266,7 @@ func TestKeyBranchPin_ActivityFollowsTheContactRule(t *testing.T) { } _, rev, _ := state(logged) ifMatch := map[string]string{"If-Match": fmt.Sprintf(`"%d"`, rev)} - words := "a branch bound key may act only on its own branch" + words := "this key may act only on its own branch" rows := f.auditRows(t) status, buf := f.key(t, f.bound, "PUT", path, diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go index cc866fcd..ce6ce908 100644 --- a/core/internal/app/serve/wire_key_branch_round7_test.go +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -342,3 +342,51 @@ func TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister(t *testing.T) { t.Errorf("the control sync wrote %d pos_sync_log rows, want its one", n) } } + +// Fix round 7 (principal item 57 question 2): the shared customer refusals +// carry one clause naming the other branch behind a lead in that names the +// caller, words true for whoever reads them. A bound key read "a branch bound +// key may act only on its own branch" even as a sales user, which is wrong +// for a user. Now: the bound key's lead names the key and its pin, the +// unbound key's lead names the key and the scope it lacks, and a user's lead +// names the role and the finance or owner roles that may. +func TestKeyBranchPin_SharedRefusalWordsNameTheCaller(t *testing.T) { + f, _, _ := round5Fixture(t) + s := seedSharedCustomer(t, f, f.branchA, f.branchB) + sh := "/api/v1/customers/" + s.id.String() + change := func() string { + return f.customerReplace(t, s.id, map[string]any{"credit_limit_cents": 666000 + int(time.Now().UnixNano()%1000)}) + } + lowest := f.branchA + if f.branchB.String() < f.branchA.String() { + lowest = f.branchB + } + cases := []struct { + who, lead string // who: "bound key", "unbound key", or a user role + }{ + {"bound key", "this key may act only on its own branch"}, + {"unbound key", "this key may not change a shared customer's restricted fields"}, + {"sales", "this role may not change a shared customer's restricted fields"}, + } + for _, c := range cases { + var status int + var buf []byte + if c.who == "bound key" { + status, buf = f.key(t, f.bound, "PUT", sh, change(), nil) + } else if c.who == "unbound key" { + status, buf = f.key(t, f.unbound, "PUT", sh, change(), nil) + } else { + status, buf = f.userAs(t, c.who, "PUT", sh, change(), nil) + } + // The bound key's refusal names the lowest holder besides the pin + // (branch B here); the caller refusals name the lowest holder at all. + named := f.branchB + if c.who != "bound key" { + named = lowest + } + want := c.lead + ": the customer is also held by branch " + named.String() + if status != http.StatusForbidden || !strings.Contains(string(buf), want) { + t.Errorf("%s changing a restricted field: %d %s, want the 403 %q", c.who, status, buf, want) + } + } +} diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index ce79c271..f5df2ed7 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -43,7 +43,8 @@ func (a *Shared) Shared(ctx context.Context, customerID uuid.UUID) (customer.Sha if err != nil { return customer.SharedVerdict{}, err } - return customer.SharedVerdict{Refuse: v.Refuse, Branch: v.Branch, RefuseCaller: v.RefuseCaller, Audit: v.Audit}, nil + return customer.SharedVerdict{Refuse: v.Refuse, Branch: v.Branch, RefuseCaller: v.RefuseCaller, + RefuseCallerKey: v.RefuseCallerKey, Audit: v.Audit}, nil } // RecordRefusal writes the refusal's key.branch_refused row. diff --git a/core/internal/customer/shared.go b/core/internal/customer/shared.go index 9e425b66..c1be0993 100644 --- a/core/internal/customer/shared.go +++ b/core/internal/customer/shared.go @@ -52,7 +52,11 @@ type SharedVerdict struct { // key without the customers:restricted scope, and the customer is shared // (principal ruling item 48). RefuseCaller bool - Audit bool // a scoped dealer wide key, and several branches hold the customer + // RefuseCallerKey marks a RefuseCaller refusal served to a machine key + // (an unbound key) rather than a user: the refusal's lead in names the + // caller (principal ruling item 57). + RefuseCallerKey bool + Audit bool // a scoped dealer wide key, and several branches hold the customer } // SharedGuard decides how the caller stands to a shared customer. The @@ -94,32 +98,52 @@ type sharedRefusal struct { func (e *sharedRefusal) Error() string { return e.err.Message } func refusedOn(branch uuid.UUID, fields ...string) *sharedRefusal { - return refusedSharing(branch, fields...) + return refusedSharing(branch, boundKeyRefusalLead, fields...) } // refusedCallerOn is the refusal a caller without the finance or owner role // (a user) or without the customers:restricted scope (an unbound key) gets on -// a shared customer's restricted fields: the bound key's words, naming the -// lowest numbered branch that holds the customer (principal ruling item 48, -// ADR 0007 section 5.5). Its audit row is the caller refusal row. -func refusedCallerOn(customer, branch uuid.UUID, fields ...string) *sharedRefusal { - ref := refusedSharing(branch, fields...) +// a shared customer's restricted fields, naming the lowest numbered branch +// that holds the customer (principal ruling item 48, ADR 0007 section 5.5). +// Its lead in names the caller, so its words are true for whoever reads them +// (item 57): "this key" for a machine key, "this role" for a user. Its audit +// row is the caller refusal row. +func refusedCallerOn(customer, branch uuid.UUID, callerKey bool, fields ...string) *sharedRefusal { + lead := callerRoleRefusalLead + if callerKey { + lead = callerKeyRefusalLead + } + ref := refusedSharing(branch, lead, fields...) ref.caller, ref.customer, ref.branch, ref.fields = true, customer, branch, fields return ref } -// sharedRefusalWords is the one rule sentence every shared customer refusal -// carries, whoever the caller is. -const sharedRefusalWords = "a branch bound key may act only on its own branch" +// The lead ins of the shared customer refusals (principal ruling item 57): +// words true for the caller they name, ahead of the one clause every caller +// shares. ADR 0007 section 5.5 quotes them. +const ( + // boundKeyRefusalLead: the pin holds this key to its own branch. + boundKeyRefusalLead = "this key may act only on its own branch" + // callerKeyRefusalLead: this machine key lacks the dedicated scope. + callerKeyRefusalLead = "this key may not change a shared customer's restricted fields" + // callerRoleRefusalLead: this role is neither finance nor owner. + callerRoleRefusalLead = "this role may not change a shared customer's restricted fields" +) + +// sharedRefusalClause is the one rule sentence every shared customer refusal +// carries after its caller specific lead in: it names the other branch. +func sharedRefusalClause(branch uuid.UUID) string { + return "the customer is also held by branch " + branch.String() +} -func refusedSharing(branch uuid.UUID, fields ...string) *sharedRefusal { +func refusedSharing(branch uuid.UUID, lead string, fields ...string) *sharedRefusal { sort.Strings(fields) details := make([]httpx.FieldError, 0, len(fields)) for _, f := range fields { details = append(details, httpx.FieldError{Field: f, Code: httpx.CodeForbidden, Message: "shared with another branch"}) } return &sharedRefusal{err: &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, - Message: sharedRefusalWords + ": the customer is also held by branch " + branch.String(), + Message: lead + ": " + sharedRefusalClause(branch), Details: details}} } @@ -160,7 +184,7 @@ func (s *Service) checkRestricted(ctx context.Context, id uuid.UUID, changes map return refusedOn(verdict.Branch, fields...) } if verdict.RefuseCaller { - return refusedCallerOn(id, verdict.Branch, fields...) + return refusedCallerOn(id, verdict.Branch, verdict.RefuseCallerKey, fields...) } if verdict.Audit { return s.shared.RecordChange(ctx, id, changes) diff --git a/core/pkg/middleware/keybranch_shared.go b/core/pkg/middleware/keybranch_shared.go index 9fa15f49..bab42d62 100644 --- a/core/pkg/middleware/keybranch_shared.go +++ b/core/pkg/middleware/keybranch_shared.go @@ -49,6 +49,10 @@ type SharedVerdict struct { // for an unbound key without the customers:restricted scope, on a // customer another branch also holds (principal ruling item 48). RefuseCaller bool + // RefuseCallerKey marks a RefuseCaller refusal served to a machine key + // (an unbound key) rather than a user, so the refusal's lead in can name + // the caller (principal ruling item 57). + RefuseCallerKey bool // Audit is set for an unbound key carrying the dedicated scope when two // or more branches hold the customer: its change goes through, audited // with the actor and the before and after values. @@ -137,7 +141,7 @@ func (w *KeyBranchWall) SharedCustomer(ctx context.Context, customer uuid.UUID) } scopes, _ := KeyScopesFromContext(ctx) if !scopeHeld(scopes, CustomerRestrictedScope) { - return SharedVerdict{RefuseCaller: true, Branch: other}, nil + return SharedVerdict{RefuseCaller: true, RefuseCallerKey: true, Branch: other}, nil } return SharedVerdict{Audit: true, Branch: other}, nil } diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index 1f914355..be756def 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -798,19 +798,29 @@ held by the pin's branch and another, a bound key changes only the branch local fields; every other field of the record, and the salesperson and the escalation policy, is restricted, and a field added to the header write is restricted until it is listed as branch local. A restricted write is refused -403 in the same words naming the other branch, with the `key.branch_refused` +403 naming the other branch, with the `key.branch_refused` row and nothing changed. A customer the pin holds alone and an unshared customer are unchanged for every caller. Who may change a shared customer's restricted fields (the principal's ruling on the open question below): a user only with the finance or owner role, a machine key only when unbound and carrying the dedicated `customers:restricted` scope. The finance role reaches exactly the three routes that change those fields (the header -write, the salesperson and the escalation policy) and no other customer -route: customer and contact create, the reads and the other writes keep the +write, the salesperson and the escalation policy) and the two reads those +writes need, the record and its escalation policy (a caller that must send +the revision as `If-Match` has to be able to read it; reads only, nothing +else opens), and no other customer route: customer and contact create, the +other reads and the other writes keep the roles they had before this section. A sales or admin user, and every -unbound key without the scope, are refused in the same words as the bound -key (the words of the rule above, naming the lowest numbered branch holding -the customer) and the refusal writes a `customer.shared_restricted_refused` +unbound key without the scope, are refused with one shared clause naming +the lowest numbered branch holding the customer behind a lead in that names +the caller, words true for whoever reads them (the principal's item 57 +ruling): a bound key reads "this key may act only on its own branch: the +customer is also held by branch X", an unbound key without the scope reads +"this key may not change a shared customer's restricted fields: the +customer is also held by branch X", and a user without the finance or owner +role reads "this role may not change a shared customer's restricted fields: +the customer is also held by branch X". The refusal writes a +`customer.shared_restricted_refused` row for the customer, naming the actor, the refused fields, that branch and the method and path (the bound key's refusal keeps its `key.branch_refused` row); a caller with no claims at all is refused fail closed; a bound key is From be6962f09dc0c9b73ceb80a5a5b35c515c33a532 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 15:09:02 -0700 Subject: [PATCH 64/74] seed: the REG-01 upsert is limited to the seed's own row and the fields it owns (item 57 question 3) The seed moved REG-01 onto Kelowna with ON CONFLICT (id) DO UPDATE SET location_id, branch_id, name, resetting a register an operator had renamed on every re-seed (r14 P3-2), and the principal's item 57 condition admits the upsert only while it can never move a register that a person, an import or another seed created. Kept the upsert rather than going back to DO NOTHING, because with DO NOTHING a migrated database holds REG-01 on the nil location and the migration-time default branch, which on a seeded database is not Kelowna, so the demo register's sales would resolve to the wrong branch and the goldens' branches and taxes would change. The conflict stays on the id, so only the migration's own demo register row can ever be touched: a register with any other id, created by a person, an import or another seed, is never matched. Of that one row the update now sets the location and the branch alone, the fields the seed owns; the register's name is kept. The statement becomes the package level posRegisterUpsertSQL so the test runs the seeder's actual upsert twice: a register created outside the seed is left exactly as it was, and the seed's own row moves idempotently while keeping an operator's rename. Signed-off-by: colton --- core/internal/app/seed/seed.go | 20 +++++-- core/internal/app/seed/seed_test.go | 82 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 0e9829a0..b0926302 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -1317,9 +1317,7 @@ func Run() { // register branch". Migration 027 seeds REG-01 on a location that does not // exist and migration 066 gives it the migration-time default branch, so // the row is moved to Kelowna, the branch the seed makes the default. - db.Exec(`INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) - VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) - ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1, name = 'Main Counter - Kelowna'`, kelMainID.String()) + db.Exec(posRegisterUpsertSQL, kelMainID.String()) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) VALUES ('sync-batch-001', 'REG-01', 42, 0, 0, NOW() - interval '2 hours')`) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) @@ -1368,6 +1366,22 @@ const pricingRuleUpsertSQL = ` expires_at = EXCLUDED.expires_at, updated_at = NOW()` +// posRegisterUpsertSQL moves the demo register REG-01 onto the location given +// (Kelowna main, the branch the seed makes the default), as a package level +// constant for the same reason as pricingRuleUpsertSQL: the behaviour worth +// testing is what a re-seed leaves behind, and the test has to run the same +// statement the seeder runs. +// +// The conflict is on the id, so the update can only ever touch the seed's own +// REG-01 row (migration 027's demo register); a register with any other id, +// created by a person, an import or another seed, is never matched. Of that +// one row the seed owns the location and the branch alone: the register's +// name is an operator's to keep (the principal's item 57 condition). +const posRegisterUpsertSQL = ` + INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) + VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) + ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1` + // pricingRule is one row of the demo pricing_rules table. // // DiscPct and MarginFloor are PERCENTAGES — 10 means ten percent — because diff --git a/core/internal/app/seed/seed_test.go b/core/internal/app/seed/seed_test.go index b3d5eb97..a00038ca 100644 --- a/core/internal/app/seed/seed_test.go +++ b/core/internal/app/seed/seed_test.go @@ -234,3 +234,85 @@ func TestPricingRuleUpsert_IsIdempotentAndRepairsValues(t *testing.T) { t.Errorf("discount_pct = %v after re-seeding at 15, want 15 — DO NOTHING would have left the stale value", discount) } } + +// CORRECTNESS: a re-seed moves the seed's own REG-01 and nothing else +// (principal item 57 question 3's condition). +// +// The upsert conflicts on the id, so it can only ever touch the migration's +// demo register REG-01; a register created by a person, an import or another +// seed has another id and must be left exactly as it was. Of REG-01 the seed +// owns the location and the branch alone: a register's name is an operator's +// to keep, so a re-seed must not rename it back (r14 P3-2). +// +// DB-gated: it is a test of a Postgres upsert, so it needs Postgres, and it +// runs the seeder's actual statement, twice. +func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { + db := testutil.RequireDB(t) + ctx := context.Background() + + // A branch location of the test's own for the seed's target. + loc := uuid.New() + if _, err := db.Pool.Exec(ctx, + `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'seed test branch')`, + loc, "SEEDT-"+loc.String()[:8]); err != nil { + t.Fatal(err) + } + // A register created outside the seed, on a location and branch of its + // own, with its own name. + foreign := "REG-SEEDTEST-" + uuid.NewString()[:8] + if _, err := db.Pool.Exec(ctx, + `INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) + VALUES ($1, $2, $2, 'a person made this counter', false)`, foreign, loc); err != nil { + t.Fatal(err) + } + // REG-01 as an operator renamed it. + var origLoc, origBranch *string + if err := db.Pool.QueryRow(ctx, `SELECT location_id::text, branch_id::text FROM pos_registers WHERE id = 'REG-01'`).Scan(&origLoc, &origBranch); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_registers WHERE id = $1`, foreign) + _, _ = db.Pool.Exec(ctx, `UPDATE pos_registers SET location_id = $2, branch_id = $3, name = 'Counter 1' WHERE id = 'REG-01'`, + nil, origLoc, origBranch) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, loc) + }) + if _, err := db.Pool.Exec(ctx, `UPDATE pos_registers SET name = 'renamed by the operator' WHERE id = 'REG-01'`); err != nil { + t.Fatal(err) + } + + upsert := func() { + t.Helper() + if _, err := db.Pool.Exec(ctx, posRegisterUpsertSQL, loc.String()); err != nil { + t.Fatalf("the seeder's register upsert: %v", err) + } + } + upsert() + upsert() + + // The register created outside the seed is exactly as it was. + var fLoc, fBranch, fName string + var fActive bool + if err := db.Pool.QueryRow(ctx, + `SELECT location_id::text, branch_id::text, name, is_active FROM pos_registers WHERE id = $1`, foreign). + Scan(&fLoc, &fBranch, &fName, &fActive); err != nil { + t.Fatal(err) + } + if fLoc != loc.String() || fBranch != loc.String() || fName != "a person made this counter" || fActive { + t.Errorf("a re-seed changed a register it did not create: location %s branch %s name %q active %v", fLoc, fBranch, fName, fActive) + } + + // The seed's own row moved onto the seed's location, twice idempotently, + // and kept the operator's name. + var loc1, branch1, name1 string + if err := db.Pool.QueryRow(ctx, + `SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`). + Scan(&loc1, &branch1, &name1); err != nil { + t.Fatal(err) + } + if loc1 != loc.String() || branch1 != loc.String() { + t.Errorf("the seed's own register did not move to the seed's location: location %s branch %s", loc1, branch1) + } + if name1 != "renamed by the operator" { + t.Errorf("a re-seed renamed the register to %q, want the operator's name kept", name1) + } +} From d40efd1fb17d9d99d143bcecee0d23db62f98e97 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 15:10:46 -0700 Subject: [PATCH 65/74] docs: the round 7 rules in CONTRACT-CHANGES, ADR 0007's module pages CONTRACT-CHANGES gains the fix round 7 row (SEC-BOUND-KEY-PIN-7: the backfill migration, the 403 register refusals on the sale, the till open, the return and the sync batch, finance's two reads, the caller specific refusal words), and the pos row states the seed's REG-01 upsert as it now stands: on the id alone, setting the location and the branch only, keeping a register's name. The customers module page states finance's two reads and the three caller specific refusal lead ins; the crm activities page quotes the activity refusal's new words. Signed-off-by: colton --- docs/modules/crm-activities.md | 5 +++-- docs/modules/customers.md | 16 +++++++++++----- docs/refactor/CONTRACT-CHANGES.md | 3 ++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/modules/crm-activities.md b/docs/modules/crm-activities.md index 9ce89fb3..eac918d2 100644 --- a/docs/modules/crm-activities.md +++ b/docs/modules/crm-activities.md @@ -127,8 +127,9 @@ customer", the order's words), so one branch's record can never point at another's contact. On a customer another branch also holds, a branch bound key adds an activity but never changes or deletes one: an activity carries no branch tag, so every existing one is shared. The refusal is `403 -forbidden` in the customer contact rule's words ("a branch bound key may act -only on its own branch: the customer is also held by branch "), with the +forbidden` in the customer contact rule's words ("this key may act +only on its own branch: the customer is also held by branch ", the +shared rule's clause behind the caller's lead in), with the `key.branch_refused` row and the activity untouched; a customer the pin holds alone, an unbound key and a user are unchanged. A key without the scope is `403 forbidden`; the audit row carries the refused scope. diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 80c57643..c360478f 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -171,7 +171,10 @@ segment is the first path segment under `/api/v1/`). Ship-tos need `finance` added on exactly the three routes that change a customer's restricted fields (`PUT /customers/{id}`, `PATCH /customers/{id}/salesperson` and `PUT /customers/{id}/escalation-policy`; the shared customer rule below -hears its role there) and on no other customer route; the exact guard is composed in +hears its role there) and on the two reads those writes need +(`GET /customers/{id}` and `GET /customers/{id}/escalation-policy`: a caller +that must send the revision as `If-Match` has to be able to read it; reads +only), and on no other customer route; the exact guard is composed in `core/internal/app/serve/wire_branch_wall.go` at `wall.customers`. A key without the scope is `403 forbidden`; the audit row carries the refused scope. @@ -181,17 +184,20 @@ branch shares with another, and changes a shared customer's record only on the branch local fields. The rule is deny by default: any header field not listed as branch local is restricted, so a field added later is restricted until it is listed in `branchLocalFields` (`core/internal/customer/shared.go`). -A restricted write is `403 forbidden` in the words "a branch bound key may act +A restricted write is `403 forbidden` in the words "this key may act only on its own branch: the customer is also held by branch " (the lowest numbered other branch), with the `key.branch_refused` row, and the record is untouched. A customer the key's branch holds alone and an unshared customer are unchanged for every caller. Who else may change a shared customer's restricted fields: a user only with the `finance` or `owner` role (a sales -or admin user is refused in the bound key's words naming the lowest +or admin user is refused with the lead in "this role may not change a shared +customer's restricted fields" naming the lowest numbered branch that holds the customer, and a caller with no claims at all is refused fail closed), and a dealer wide key only while it carries the dedicated `customers:restricted` scope; every other unbound key is refused -in the same words. A refusal of a user or an unbound key writes a +with the lead in "this key may not change a shared customer's restricted +fields" over the same clause (the lead in names the caller, so the words are +true for whoever reads them). A refusal of a user or an unbound key writes a `customer.shared_restricted_refused` audit row for the customer naming the actor, the refused fields, that branch and the method and path. A branch bound key is refused even with the scope. The scope is in the grant grammar @@ -216,7 +222,7 @@ user's change writes none. Ship-tos and contacts carry no branch column, so on a shared customer every existing one is shared: a bound key reads them and may add a new one with `POST`, and is refused `PUT /ship-tos/{id}`, `PUT /contacts/{id}` and -`DELETE /contacts/{id}` in the same words. A ship-to add that names +`DELETE /contacts/{id}` in the bound key's words. A ship-to add that names `is_default: true` is refused the same way, because naming a default clears the customer's existing default, which on a shared customer is a shared ship to every branch's delivery orders default to; an add that diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index e5feb1d0..085619b6 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -296,4 +296,5 @@ get an entry. | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | | SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | | SEC-BOUND-KEY-PIN-6 | the branch bound key's walls, fix round 6 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 13 and the principal's item 54 rulings found: a bound key's pos sync on a register whose location row names no branch wrote an open transaction at the default branch, because the register's branch was read only through `locations` and not from `pos_registers.branch_id` (the till open and the return derived it the same way); the refusal of a user without the finance or owner role, or of an unbound key without `customers:restricted`, on a shared customer's restricted fields used other words than the bound key's and wrote no row; the customer routes admitted finance on every route; and a bound key changed and deleted a CRM activity of a shared customer | A register's branch is `COALESCE(l.branch_id, r.branch_id)` in the transaction create, the till open and the return branch lookup, so the pin check always runs. The caller refusals use the bound key's words and write a `customer.shared_restricted_refused` row (actor, fields, branch, method, path). Finance reaches only the header write, the salesperson and the escalation policy; every other customer route is back to `admin`, `owner`, `sales`. On a shared customer a bound key adds an activity and never changes or deletes one (403 in the contact rule's words, with its `key.branch_refused` row). New tests: the register branch legs, the stored job recheck on the conversion, the claims-less mint refusal and a bound key that truly carries the scope | -| SEC-BOUND-KEY-PIN-7 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT DO UPDATE`) so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | +| SEC-BOUND-KEY-PIN-7 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT (id) DO UPDATE` on `location_id` and `branch_id` alone, so it can never move a register another hand created and it keeps a register's name), so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | +| SEC-BOUND-KEY-PIN-7 | the branch bound key's walls, fix round 7 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 15 and the principal's item 57 rulings found: the till, Z report and return reads' branch predicate hid every row written before the column was stamped, so a drawer open at upgrade read as no drawer (close 409, open 409) and the legacy Z reports and returns vanished from every caller with a branch context; a user's register branch refusal on the sale answered 500; the till open and a return stamped the register's branch whatever the caller's branch context named; a bound key's sync logged its batch under another branch's register; the CONTRACT-CHANGES file held two rows with the id `SEC-BOUND-KEY-PIN-6`; finance could not read the customer it may change; and a sales user read "a branch bound key may act only on its own branch" on a shared customer's restricted fields | Migration 108 backfills `branch_id` on `till_sessions`, `till_z_reports` and `pos_returns` from each row's own register (`COALESCE(the register's location's branch, pos_registers.branch_id)`), touching only rows whose branch is NULL, counting what it set per table in a NOTICE, reporting by count and first ids any row whose register names no branch and leaving it as it is, moving no money and deleting nothing; its down leaves the data. A register branch refusal is a typed error answered 403 with its words on the sale, the till open, the return and the whole sync batch (the batch register is held to the caller's branch context before any item runs and before the `pos_sync_log` row is written). Finance may read `GET /customers/{id}` and `GET /customers/{id}/escalation-policy` (reads only, the reads its writes need for `If-Match`; every other customer, contact, ship-to and activity route keeps the roles it had). The shared customer refusals carry one clause naming the other branch behind a caller specific lead in: "this key may act only on its own branch" for a bound key, "this key may not change a shared customer's restricted fields" for an unbound key without the scope, "this role may not change a shared customer's restricted fields" for a user without the finance or owner role (ADR 0007 section 5.5 quotes them) | From ac00ab9ee280789879d16a58070bd6ab386782ce Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 16:20:22 -0700 Subject: [PATCH 66/74] seed: the REG-01 upsert moves only the row still on 027's placeholder Migration 027 creates REG-01 on every database, so the row is not the seed's own however the desk hardcodes it, and the unconditional DO UPDATE moved a REG-01 an operator re-pointed back onto Kelowna. The update now fires only while the row still carries 027's placeholder location, so a re-pointed register stays where its operator put it (the r16 P2 reading of item 57's condition), a second seed run is a no-op, and the name was never touched either way. The test forces the row's migration shape first, so it runs on any database. Signed-off-by: colton --- core/internal/app/seed/seed.go | 16 ++++--- core/internal/app/seed/seed_test.go | 66 ++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index b0926302..02e21d1e 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -1373,14 +1373,20 @@ const pricingRuleUpsertSQL = ` // statement the seeder runs. // // The conflict is on the id, so the update can only ever touch the seed's own -// REG-01 row (migration 027's demo register); a register with any other id, -// created by a person, an import or another seed, is never matched. Of that -// one row the seed owns the location and the branch alone: the register's -// name is an operator's to keep (the principal's item 57 condition). +// REG-01 row; a register with any other id, created by a person, an import or +// another seed, is never matched. Of that one row the seed owns the location +// and the branch alone, and only while the row still sits on migration 027's +// placeholder location: the row is born in migration 027 on every database, +// so a REG-01 an operator re-pointed by hand is not the seed's to move back +// (item 57's condition reads 027 as another seed; r16 P2), and the register's +// name is an operator's to keep the same way. The update therefore fires only +// under that placeholder, which also makes a second run a no-op: the row no +// longer carries it. const posRegisterUpsertSQL = ` INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) - ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1` + ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1 + WHERE pos_registers.location_id = '00000000-0000-0000-0000-000000000000'` // pricingRule is one row of the demo pricing_rules table. // diff --git a/core/internal/app/seed/seed_test.go b/core/internal/app/seed/seed_test.go index a00038ca..d2d47abe 100644 --- a/core/internal/app/seed/seed_test.go +++ b/core/internal/app/seed/seed_test.go @@ -241,17 +241,23 @@ func TestPricingRuleUpsert_IsIdempotentAndRepairsValues(t *testing.T) { // The upsert conflicts on the id, so it can only ever touch the migration's // demo register REG-01; a register created by a person, an import or another // seed has another id and must be left exactly as it was. Of REG-01 the seed -// owns the location and the branch alone: a register's name is an operator's -// to keep, so a re-seed must not rename it back (r14 P3-2). +// owns the location and the branch alone, and only while the row still sits +// on migration 027's placeholder location: a register's name is an +// operator's to keep, so a re-seed must not rename it back (r14 P3-2), and a +// register the operator re-pointed by hand is not the seed's to move back +// either, because migration 027 created the row and item 57's condition +// reads that as another seed (r16 P2). // // DB-gated: it is a test of a Postgres upsert, so it needs Postgres, and it -// runs the seeder's actual statement, twice. +// runs the seeder's actual statement, twice per leg. func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { db := testutil.RequireDB(t) ctx := context.Background() - // A branch location of the test's own for the seed's target. + // A branch location of the test's own for the seed's target, and one the + // operator re-point leg moves REG-01 onto. loc := uuid.New() + opLoc := uuid.New() if _, err := db.Pool.Exec(ctx, `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'seed test branch')`, loc, "SEEDT-"+loc.String()[:8]); err != nil { @@ -265,16 +271,27 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { VALUES ($1, $2, $2, 'a person made this counter', false)`, foreign, loc); err != nil { t.Fatal(err) } - // REG-01 as an operator renamed it. - var origLoc, origBranch *string - if err := db.Pool.QueryRow(ctx, `SELECT location_id::text, branch_id::text FROM pos_registers WHERE id = 'REG-01'`).Scan(&origLoc, &origBranch); err != nil { - t.Fatal(err) + // REG-01 as migrations 027 and 066 leave it: the placeholder location, the + // default branch, the name 027 gave. The fixture is forced, not assumed: + // the upsert under test can never put the row back once it has moved, so + // the test starts from the migration shape whatever an earlier run (or an + // operator) left behind, and the cleanup restores the same shape. + restoreMigrationShape := func() { + t.Helper() + if _, err := db.Pool.Exec(ctx, + `UPDATE pos_registers SET location_id = '00000000-0000-0000-0000-000000000000', + branch_id = (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), + name = 'Counter 1' + WHERE id = 'REG-01'`); err != nil { + t.Fatalf("restore REG-01's migration shape: %v", err) + } } + restoreMigrationShape() t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM pos_registers WHERE id = $1`, foreign) - _, _ = db.Pool.Exec(ctx, `UPDATE pos_registers SET location_id = $2, branch_id = $3, name = 'Counter 1' WHERE id = 'REG-01'`, - nil, origLoc, origBranch) + restoreMigrationShape() _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, loc) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, opLoc) }) if _, err := db.Pool.Exec(ctx, `UPDATE pos_registers SET name = 'renamed by the operator' WHERE id = 'REG-01'`); err != nil { t.Fatal(err) @@ -315,4 +332,33 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { if name1 != "renamed by the operator" { t.Errorf("a re-seed renamed the register to %q, want the operator's name kept", name1) } + + // An operator re-pointed REG-01 onto a location and branch of their own + // and named it: the row migration 027 created is not the seed's to move + // back, so a re-seed, run twice, leaves it exactly where the operator + // put it. + if _, err := db.Pool.Exec(ctx, + `INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'seed test operator branch')`, + opLoc, "SEEDT-"+opLoc.String()[:8]); err != nil { + t.Fatal(err) + } + if _, err := db.Pool.Exec(ctx, + `UPDATE pos_registers SET location_id = $1, branch_id = $1, name = 'operator counter' WHERE id = 'REG-01'`, opLoc); err != nil { + t.Fatal(err) + } + upsert() + upsert() + var opLoc1, opBranch1, opName1 string + if err := db.Pool.QueryRow(ctx, + `SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`). + Scan(&opLoc1, &opBranch1, &opName1); err != nil { + t.Fatal(err) + } + if opLoc1 != opLoc.String() || opBranch1 != opLoc.String() { + t.Errorf("a re-seed moved an operator re-pointed REG-01: location %s branch %s, want it kept at %s", + opLoc1, opBranch1, opLoc) + } + if opName1 != "operator counter" { + t.Errorf("a re-seed renamed the re-pointed register to %q, want the operator's name kept", opName1) + } } From 995c0fc3535e813be2de73e2219c711b40152e11 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 16:21:12 -0700 Subject: [PATCH 67/74] pos: a hidden till session's report reads as missing, not a 500 The report handler mapped every service error to 500, so a session the caller's branch context hides answered 500 where the other by id reads (the Z report, the return) answer 404. A hidden session now reads as missing the same way, nothing leaked either way (r17 P3-1). The spec entry, the allowlist note and the pos_till_close golden follow the new answer. Signed-off-by: colton --- core/api/fragments/pos.yaml | 4 ++- core/api/openapi.yaml | 4 ++- .../app/serve/wire_key_branch_round8_test.go | 33 +++++++++++++++++++ .../testdata/goldens/pos_till_close.json | 6 ++-- core/internal/pos/handler.go | 8 +++++ core/pkg/middleware/keybranch_allow.go | 2 +- web/packages/api-client/src/schema.d.ts | 3 +- 7 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 core/internal/app/serve/wire_key_branch_round8_test.go diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index d0436d21..518ea936 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -443,7 +443,7 @@ paths: tags: - pos summary: Live X report of a session - description: An unknown session answers 500. + description: An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. parameters: - name: id in: path @@ -465,6 +465,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' /api/v1/pos/till/{id}/close: diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 1ba988fc..4637739f 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -10595,7 +10595,7 @@ paths: tags: - pos summary: Live X report of a session - description: An unknown session answers 500. + description: An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. parameters: - name: id in: path @@ -10617,6 +10617,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' /api/v1/pos/till/{id}/close: diff --git a/core/internal/app/serve/wire_key_branch_round8_test.go b/core/internal/app/serve/wire_key_branch_round8_test.go new file mode 100644 index 00000000..157c8a47 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round8_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// Fix round 8 of the branch bound key pin: the r17 P3s, each through the real +// serve chain. + +import ( + "net/http" + "strings" + "testing" +) + +// Fix round 8 (r17 P3-1): the till report read mapped every service error to +// 500, so a session the caller's branch context hides answered 500 where the +// other by id reads (the Z report, the return) answer 404. The hidden session +// now reads as missing, the caller's own still reads. +func TestKeyBranchPin_HiddenTillReportAnswers404(t *testing.T) { + f, _, _ := round5Fixture(t) + hdr := map[string]string{"X-Branch-Id": f.branchA.String()} + status, buf := f.userAs(t, "admin", "GET", "/api/v1/pos/till/"+f.tillB.String()+"/report", "", hdr) + if status != http.StatusNotFound { + t.Errorf("another branch's till report: %d %s, want the 404 the other by id reads answer", status, buf) + } + if strings.Contains(string(buf), f.tillB.String()) { + t.Errorf("the hidden till report answer carries the session's id: %s", buf) + } + status, buf = f.userAs(t, "admin", "GET", "/api/v1/pos/till/"+f.tillA.String()+"/report", "", hdr) + if status != http.StatusOK { + t.Errorf("the caller's own till report: %d %s, want 200", status, buf) + } +} diff --git a/core/internal/characterization/testdata/goldens/pos_till_close.json b/core/internal/characterization/testdata/goldens/pos_till_close.json index 2e6284e5..f210bfa0 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -59,12 +59,12 @@ "path": "/api/v1/pos/till//report" }, "response": { - "status": 500, + "status": 404, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "NOT_FOUND", + "message": "Not Found" }, "meta": { "request_id": "" diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 41f94713..5a98e29e 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -13,6 +13,7 @@ import ( "github.com/gablelbm/gable/pkg/httputil" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) // Handler handles POS HTTP endpoints. @@ -281,6 +282,13 @@ func (h *Handler) TillReportHandler(w http.ResponseWriter, r *http.Request) { } report, err := h.service.TillReport(r.Context(), id) if err != nil { + // A session the caller's branch context hides reads as missing, the + // same 404 the Z report and return reads answer (r17 P3-1), not a + // 500. + if errors.Is(err, pgx.ErrNoRows) { + httputil.RespondError(w, r, err.Error(), http.StatusNotFound, err) + return + } httputil.RespondError(w, r, "failed to build till report", http.StatusInternalServerError, err) return } diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 3d03261b..c5ec3488 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -337,7 +337,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/pos/returns": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/returns/{id}": {KeyBranchClassWalled, "pos_returns.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/till/current": {KeyBranchClassWalled, "till_sessions.branch_id (the register's open session); TestKeyBranchPin_PosAndAccountRows"}, - "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "till_sessions.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "GET /api/v1/pos/till/{id}/report": {KeyBranchClassWalled, "till_sessions.branch_id (a session the caller's branch context hides reads as missing, the 404 the Z report read answers, round 8); TestKeyBranchPin_PosAndAccountRows, TestKeyBranchPin_HiddenTillReportAnswers404"}, "GET /api/v1/pos/till/{id}/zreport": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, "GET /api/v1/pos/transactions": {KeyBranchClassWalled, "pos_transactions.branch_id"}, "GET /api/v1/pos/transactions/{id}": {KeyBranchClassWalled, "pos_transactions.branch_id"}, diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 1b6ee295..34d6bb74 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -4647,7 +4647,7 @@ export interface paths { }; /** * Live X report of a session - * @description An unknown session answers 500. + * @description An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. */ get: operations["posTillReport"]; put?: never; @@ -23875,6 +23875,7 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["NotFound"]; 500: components["responses"]["InternalError"]; }; }; From 3910066f7c100722762f7dda4841582fa625ce73 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 16:21:35 -0700 Subject: [PATCH 68/74] middleware: the exclusive walls use the quoted lead in (r17 P3-2) The wall that holds a customer scoped pricing or tax write on a shared customer still led its refusal with "a branch bound key may act only on its own branch", words ADR 0007 section 5.5 does not quote; the customer service's round 7 refusal already carries "this key may act only on its own branch". The wall now carries the same quoted lead in, so every shared customer refusal a bound key meets carries words the section quotes. The ADR and the customers page say so. Signed-off-by: colton --- core/internal/app/serve/wire_key_branch_pin_test.go | 6 +++++- core/internal/app/serve/wire_key_branch_shared_test.go | 6 ++++-- core/pkg/middleware/keybranch.go | 10 +++++++--- docs/adr/0007-drafts-links-and-confirm-gated-scopes.md | 6 +++++- docs/modules/customers.md | 6 +++++- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 5f540ac6..35d4f5fc 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -828,7 +828,11 @@ func (f *keyPinFixture) refuseForeign(t *testing.T, method, path, body string, h t.Errorf("bound key %s %s: %d %s, want 403", method, path, status, buf) return } - if !strings.Contains(string(buf), "a branch bound key may act only on its own branch") { + // The pin rule words the walls share: the route gate and the customer + // branch wall lead with "a branch bound key", the exclusive wall with + // "this key" (the ADR 0007 section 5.5 lead in, r17 P3-2); each wall's + // own lead in is asserted where its words matter. + if !strings.Contains(string(buf), "may act only on its own branch") { t.Errorf("bound key %s %s: refusal body does not name the pin rule: %s", method, path, buf) } if after := f.auditRows(t); after != before+1 { diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index 485f447f..d5f881b0 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -483,13 +483,15 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { // The exclusive walls name the same lowest numbered other branch on the // three branch customer (round 10 P3-3: the second query of the exclusive - // lookup, the shared customer pricing and exemption line). + // lookup, the shared customer pricing and exemption line), and their + // refusal carries the lead in ADR 0007 section 5.5 quotes for a bound + // key (r17 P3-2), so a reader of the section finds the wall's words. { pinned := f.mintWithScopes(t, "pin exclusive walls", []string{"tax:read", "tax:write"}, &f.branchA) before := f.auditRows(t) body := fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin three branch","certificate_number":"PIN-EXP-3","issuing_state":"WA"}`, s.id.String()) status, buf := f.key(t, pinned, "POST", "/api/v1/tax/exemptions", body, nil) - if status != http.StatusForbidden || !strings.Contains(string(buf), "a branch bound key may act only on its own branch") || + if status != http.StatusForbidden || !strings.Contains(string(buf), "this key may act only on its own branch") || !strings.Contains(string(buf), named) { t.Errorf("bound key exemption on the three branch customer: %d %s, want the 403 naming the lowest other branch", status, buf) } diff --git a/core/pkg/middleware/keybranch.go b/core/pkg/middleware/keybranch.go index cb212f63..588747ba 100644 --- a/core/pkg/middleware/keybranch.go +++ b/core/pkg/middleware/keybranch.go @@ -273,8 +273,12 @@ func (w *KeyBranchWall) CustomerRowBranch(customerOf func(ctx context.Context, i // sales to the customer too. A customer held by any branch outside the pin // is refused with a message naming that branch; a customer with no // customer_branches row holds no branch at all and is refused the same way -// as by the read wall. A resolver failure answers 500 rather than failing -// open, and a resolver may hand back ErrBodyRefused. +// as by the read wall. The refusal's lead in is the one ADR 0007 section +// 5.5 quotes for a bound key (item 57's caller specific lead ins; r17 +// P3-2), the same words the customer service's shared customer refusal +// writes, so a reader of the section finds the wall's words. A resolver +// failure answers 500 rather than failing open, and a resolver may hand +// back ErrBodyRefused. func (w *KeyBranchWall) CustomerBranchExclusive(customersOf func(ctx context.Context, r *http.Request) ([]uuid.UUID, error)) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { @@ -304,7 +308,7 @@ func (w *KeyBranchWall) CustomerBranchExclusive(customersOf func(ctx context.Con if keyID, ok := KeyIDFromContext(r.Context()); ok && w.auditor != nil { w.auditor.AuditKeyBranchRefusal(r.Context(), keyID, pin, r.Method, r.URL.Path) } - message := "a branch bound key may act only on its own branch" + message := "this key may act only on its own branch" if other != uuid.Nil { message = message + ": the customer is also held by branch " + other.String() } diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index be756def..a66e03dd 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -819,7 +819,11 @@ customer is also held by branch X", an unbound key without the scope reads "this key may not change a shared customer's restricted fields: the customer is also held by branch X", and a user without the finance or owner role reads "this role may not change a shared customer's restricted fields: -the customer is also held by branch X". The refusal writes a +the customer is also held by branch X". The exclusive walls (the customer +scoped pricing rules, tax exemptions and category rules of the stricter +line above) refuse a bound key in the same quoted lead in as the customer +service's bound key refusal, so every shared customer refusal a bound key +meets carries words this section quotes. The refusal writes a `customer.shared_restricted_refused` row for the customer, naming the actor, the refused fields, that branch and the method and path (the bound key's refusal keeps its `key.branch_refused` diff --git a/docs/modules/customers.md b/docs/modules/customers.md index c360478f..21f53447 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -187,7 +187,11 @@ until it is listed in `branchLocalFields` (`core/internal/customer/shared.go`). A restricted write is `403 forbidden` in the words "this key may act only on its own branch: the customer is also held by branch " (the lowest numbered other branch), with the `key.branch_refused` row, and the record is -untouched. A customer the key's branch holds alone and an unshared customer +untouched. The customer scoped pricing rules, tax exemptions and category +rules refuse a shared customer's bound key write in the same quoted lead in +(`core/pkg/middleware/keybranch.go`), so every shared customer refusal a +bound key meets carries words ADR 0007 section 5.5 quotes. A customer the +key's branch holds alone and an unshared customer are unchanged for every caller. Who else may change a shared customer's restricted fields: a user only with the `finance` or `owner` role (a sales or admin user is refused with the lead in "this role may not change a shared From 8bc4ba82e0ad83f7601ea55f25b7b56447ab3811 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 16:22:37 -0700 Subject: [PATCH 69/74] pos: an unknown sync batch register is refused before the log row A sync batch naming a register that does not exist ran its items and was logged under that register, because the batch check ran only when the register resolved (r17 P3-3). The batch is now refused 400, a typed error naming the register, before any item runs and before the pos_sync_log row, and a lookup failure answers an error rather than skipping the hold. The spec entry, the allowlist note and the ADR's sync passage follow. Signed-off-by: colton --- core/api/fragments/pos.yaml | 2 +- core/api/openapi.yaml | 2 +- .../app/serve/wire_key_branch_round8_test.go | 55 +++++++++++++++++++ core/internal/pos/handler.go | 7 +++ core/internal/pos/repository.go | 14 +++++ core/internal/pos/service.go | 18 ++++-- core/pkg/middleware/keybranch_allow.go | 2 +- ...7-drafts-links-and-confirm-gated-scopes.md | 5 +- web/packages/api-client/src/schema.d.ts | 2 +- 9 files changed, 96 insertions(+), 11 deletions(-) diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index 518ea936..c2f921ef 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -312,7 +312,7 @@ paths: tags: - pos summary: Replay offline transactions - description: A missing batch_id or an empty items list answers 400. + description: A missing batch_id, an empty items list or a register_id that names no register answers 400. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 4637739f..b64deb50 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -10466,7 +10466,7 @@ paths: tags: - pos summary: Replay offline transactions - description: A missing batch_id or an empty items list answers 400. + description: A missing batch_id, an empty items list or a register_id that names no register answers 400. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/internal/app/serve/wire_key_branch_round8_test.go b/core/internal/app/serve/wire_key_branch_round8_test.go index 157c8a47..3abbc0a6 100644 --- a/core/internal/app/serve/wire_key_branch_round8_test.go +++ b/core/internal/app/serve/wire_key_branch_round8_test.go @@ -7,9 +7,13 @@ package serve // serve chain. import ( + "context" "net/http" "strings" "testing" + "time" + + "github.com/google/uuid" ) // Fix round 8 (r17 P3-1): the till report read mapped every service error to @@ -31,3 +35,54 @@ func TestKeyBranchPin_HiddenTillReportAnswers404(t *testing.T) { t.Errorf("the caller's own till report: %d %s, want 200", status, buf) } } + +// Fix round 8 (r17 P3-3): a sync batch naming a register that does not exist +// ran its items and was logged under that register, because the batch check +// ran only when the register resolved. An unknown batch register is now +// refused before any item runs and before the log row, so the log never names +// a register nobody made; the pin's own register still passes. +func TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister(t *testing.T) { + f, _, first := round5Fixture(t) + ctx := context.Background() + client := uuid.New() + batch := "pin-r8-" + uuid.NewString()[:8] + t.Cleanup(func() { + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, client) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, client) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_sync_log WHERE batch_id = $1`, batch) + }) + syncBody := func(batchRegister string) string { + return `{"batch_id":"` + batch + `","register_id":"` + batchRegister + + `","items":[{"client_id":"` + client.String() + `","register_id":"` + f.regA + + `","cashier_id":"` + uuid.NewString() + `","customer_id":"` + f.custID.String() + + `","items":[],"tenders":[],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` + } + logRows := func() (n int) { + if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM pos_sync_log WHERE batch_id = $1`, batch).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + // The batch register that does not exist: the whole batch is refused + // before any item runs and before the log row is written. + status, buf := f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody("NOPE-PIN-R8"), nil) + if status != http.StatusBadRequest { + t.Errorf("bound key sync with a batch register that does not exist: %d %s, want the 400 refusing the batch", status, buf) + } + if n := logRows(); n != 0 { + t.Errorf("the refused sync wrote %d pos_sync_log rows for the batch, want none", n) + } + if n := f.transactionsWithID(t, client); n != 0 { + t.Errorf("the refused sync wrote %d transactions, want none", n) + } + + // The control: the pin's own register as the batch register still passes. + status, buf = f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody(f.regA), nil) + if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { + t.Errorf("bound key sync with the pin's own batch register: %d %s, want the sale synced", status, buf) + } + if n := logRows(); n != 1 { + t.Errorf("the control sync wrote %d pos_sync_log rows, want its one", n) + } +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 5a98e29e..d8ab4b25 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -550,6 +550,13 @@ func (h *Handler) SyncOffline(w http.ResponseWriter, r *http.Request) { writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") return } + // A batch register that does not exist is a bad request, refused + // before any item runs and before the batch is logged. + var unknown *UnknownRegisterError + if errors.As(err, &unknown) { + httputil.RespondError(w, r, unknown.Error(), http.StatusBadRequest, err) + return + } httputil.RespondError(w, r, "offline sync failed", http.StatusInternalServerError, err) return } diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index df7ef917..a160e046 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -26,6 +26,20 @@ func (e *RegisterBranchRefusedError) Error() string { return fmt.Sprintf("register %s belongs to a different branch than the request", e.RegisterID) } +// UnknownRegisterError is the refusal a write answers when the register it +// names does not exist. It is a typed error so the HTTP layer can answer it +// 400 instead of the 500 a plain service error maps to; the offline sync +// refuses a batch whose register does not resolve before any item runs and +// before the log row, so the log never names a register nobody made (r17 +// P3-3). +type UnknownRegisterError struct { + RegisterID string +} + +func (e *UnknownRegisterError) Error() string { + return fmt.Sprintf("register %s does not exist", e.RegisterID) +} + // Repository handles POS data persistence. type Repository interface { CreateTransaction(ctx context.Context, tx *POSTransaction) error diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 5db10860..9e2b4001 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -634,12 +634,20 @@ func (s *Service) SyncOfflineTransactions(ctx context.Context, req OfflineSyncRe // and the log row names it: hold it to the caller's branch context the // way each replayed create is held, before any item runs and before the // batch is logged, so neither the log nor a replay names another branch's - // register for a caller held to one branch. + // register for a caller held to one branch. A batch register that does + // not resolve is refused the same way (r17 P3-3): a log row under a + // register nobody made is never written, and a lookup failure answers an + // error rather than skipping the hold. if req.RegisterID != "" { - if branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID); err == nil && branch != nil { - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} - } + branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID) + if err != nil { + return nil, fmt.Errorf("sync: register branch lookup: %w", err) + } + if branch == nil { + return nil, &UnknownRegisterError{RegisterID: req.RegisterID} + } + if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { + return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} } } diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index c5ec3488..e042b92b 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -398,7 +398,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that names no register is refused 400 the same way, round 8), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); no machine key cashier"}, diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index a66e03dd..fc9c85af 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -728,8 +728,9 @@ same way a path customer is: the cash and card payment, the credit memo (create and update), the order and the quote (create and update, a document moved onto another customer included), the offline POS sync (the customer each replayed sale names, whose ACCOUNT tender invoices it, and the batch -register itself, a batch naming another branch's register refused 403 before -any item runs and before the `pos_sync_log` row names it) and the draft +register itself, a batch naming another branch's register refused 403, or a +batch register naming no register at all refused 400, before any item runs +and before the `pos_sync_log` row names it) and the draft promotions, which read the customer from the draft's stored payload, are refused a bound key whose pin does not hold that customer (403 naming `customer_id`, the `key.branch_refused` row, nothing written). Without it diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 34d6bb74..202ec790 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -4575,7 +4575,7 @@ export interface paths { put?: never; /** * Replay offline transactions - * @description A missing batch_id or an empty items list answers 400. + * @description A missing batch_id, an empty items list or a register_id that names no register answers 400. */ post: operations["posSync"]; delete?: never; From ae8f690b5d242fa750b9ba6e97bed431121dc93b Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 16:22:42 -0700 Subject: [PATCH 70/74] docs: round 8 in CONTRACT-CHANGES, the pos row its own id Every row of this PR now carries a unique id (r16 P3): the pos row (the till session wire change and its goldens) its own SEC-BOUND-KEY-PIN-POS, each round's rows under their own id, and round 8's row SEC-BOUND-KEY-PIN-8 records this round's findings (round 7 had left the pos row and its own row both on SEC-BOUND-KEY-PIN-7). Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 085619b6..b2397afb 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -296,5 +296,6 @@ get an entry. | SEC-BOUND-KEY-PIN-4 | the branch bound key's walls, fix round 4 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 8 and 9 probed through the real wiring and found: a key bound to A could post a payment (with its GL entry), a credit memo, an order (taking the other customer's tax exemption) or a quote for a customer held only by branch B, move a quote or a draft order onto it, and promote a draft whose payload named it; an order carried another customer's ship to (its address in the answer) or contact, for every caller; a legacy tier rule that also named an A customer was deleted by an A key, singly and in bulk; a customer shared with B could have its tier, credit limit, terms, currency, active flag, escalation policy and contacts changed by the A key; the order exposure gate and override looked up the source quote by order id alone; and the twin sweep sent bodies that failed validation, counted whole tables other packages move, and left about 25 walled routes undriven | The customer a body names is held to the pin on POST `/payments`, `/payments/card`, `/credit-memos` (and PUT), `/orders` and `/quotes` (and their PUTs) by the key branch wall composed in the `branchWall` mount methods, and on the draft promotions by the customer in the draft's stored payload (`wall.draftGuard`); a refused call is 403 naming `customer_id` with the `key.branch_refused` row and nothing written. An order create or update refuses a `ship_to_id` or `ordered_by_contact_id` its customer does not own (400 naming the field, for every caller). Any category rule row whose target is not an account rule is dealer wide in the row and bulk walls, and the rule audit read now uses the plain customer wall (a shared customer's rule audit is readable, PD-4). The order exposure gate and override read the order under the branch wall first. The customer service holds the shared customer rule (principal rulings 43 to 45, deny by default): on a customer shared with another branch a bound key changes only `email` and `phone`; every other header field, the salesperson and the escalation policy are refused in the PD-4 words naming the other branch; ship-tos and contacts of a shared customer may be added but not changed or deleted; a dealer wide key changes them with a `customer.shared_restricted_changed` audit row naming the key and the before and after values (the field by field table is in ADR 0007 section 5.5). The "also held by branch" refusal names the lowest numbered other branch, never the pin. The parsing upload is audited before the extractor runs. Tests: the twin sweep is rebuilt on bodies that pass validation with an own twin control per write, measures changes by the twins' ids, and a coverage test fails by name for any WALLED route no bound key leg drives; migration 107, the bulk category delete on a shared customer, the AI and grant audit deltas and serve's own audit wiring are tested. The fixture builds the order service as serve does and removes the rows and keys it makes. Users and unbound keys keep their reach, apart from the order ship to and contact ownership, which holds for them too | | SEC-BOUND-KEY-PIN-5 | the branch bound key's walls, fix round 5 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 10 and 11 probed through the real wiring and found: the offline POS sync replayed a sale (and its ACCOUNT tender's invoice) on a customer held only by branch B; an order or a quote took a `job_id` of B's project, and the order was priced by B's job rule, for every caller; a bound key converted a quote at branch A whose customer was held only by B, the order taking B's tax exemption; a bound key's ship-to add with `is_default: true` cleared the shared customer's default ship to; a CRM activity of A's customer named B's contact, for every caller; a shared customer's restricted fields were open to every user and every unbound key; a legacy tier row's PUT answered 500 for the unbound key and the user; the intent route's wall refused a bound key for a body field the route does not accept; and the credit memo PUT wall, the PUT body's salesperson, the third branch's naming order, the service level refusal row's method and path, the escalation audit's signature date and the coverage test's control legs were untested | The offline sync is walled through the customer each replayed sale names (`wall.pos`, items[].customer_id; no pos code touched). The order and quote creates and updates hold their body's `job_id` through `projects.customer_id` (`bodyDocumentCustomer`), the conversion rechecks the quote's stored customer under the customer wall (`quoteRowCustomerOf`), and for every caller the order build, the conversion's build and the quote's priceDraft refuse a `job_id` its customer does not own (400 naming `job_id`, the credit memo's words); a CRM activity refuses a `contact_id` its customer does not own the same way. A ship-to add naming `is_default: true` is refused on a shared customer like a change (checkSharedChild), so the shared default is never cleared by an add. The principal's item 48 ruling: a shared customer's restricted fields change only with the finance or owner role (a user; sales and admin are refused in the same words naming a branch, no claims fails closed) or an unbound key carrying the dedicated `customers:restricted` scope (in the grant grammar, required by no route so no default grant carries it, minted only by an owner with the `key.created` row naming it; every other unbound key refused with the same words, a bound key refused even with it); the `customer.shared_restricted_changed` audit row stays, and a user's change writes none; the customer routes' guard admits finance. An update of a legacy single target row answers a 409 naming the invariant for every caller instead of the 500. The intent create leaves the body customer wall: its own 400 unknown field answer serves every caller. The escalation audit's before and after values carry `agreement_signed_at` beside the reference, a service level refusal row names the method and path `RecordRequest` noted, the shared customer is held by a third branch so every also held by answer names the lowest numbered other branch on each of its three queries, and the walled route coverage counts only the drivers' explicit twin marks, so an A control alone covers nothing. The pre existing draft order promote 500 (round 11 P3-3) is left for a later item. The field by field table is in ADR 0007 section 5.5 | | SEC-BOUND-KEY-PIN-6 | the branch bound key's walls, fix round 6 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 13 and the principal's item 54 rulings found: a bound key's pos sync on a register whose location row names no branch wrote an open transaction at the default branch, because the register's branch was read only through `locations` and not from `pos_registers.branch_id` (the till open and the return derived it the same way); the refusal of a user without the finance or owner role, or of an unbound key without `customers:restricted`, on a shared customer's restricted fields used other words than the bound key's and wrote no row; the customer routes admitted finance on every route; and a bound key changed and deleted a CRM activity of a shared customer | A register's branch is `COALESCE(l.branch_id, r.branch_id)` in the transaction create, the till open and the return branch lookup, so the pin check always runs. The caller refusals use the bound key's words and write a `customer.shared_restricted_refused` row (actor, fields, branch, method, path). Finance reaches only the header write, the salesperson and the escalation policy; every other customer route is back to `admin`, `owner`, `sales`. On a shared customer a bound key adds an activity and never changes or deletes one (403 in the contact rule's words, with its `key.branch_refused` row). New tests: the register branch legs, the stored job recheck on the conversion, the claims-less mint refusal and a bound key that truly carries the scope | -| SEC-BOUND-KEY-PIN-7 | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT (id) DO UPDATE` on `location_id` and `branch_id` alone, so it can never move a register another hand created and it keeps a register's name), so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | +| SEC-BOUND-KEY-PIN-POS | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT (id) DO UPDATE` on `location_id` and `branch_id` alone that fires only while the row still sits on migration 027's placeholder location, so it can never move a register another hand created, never moves one an operator re-pointed, and keeps a register's name), so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | | SEC-BOUND-KEY-PIN-7 | the branch bound key's walls, fix round 7 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 15 and the principal's item 57 rulings found: the till, Z report and return reads' branch predicate hid every row written before the column was stamped, so a drawer open at upgrade read as no drawer (close 409, open 409) and the legacy Z reports and returns vanished from every caller with a branch context; a user's register branch refusal on the sale answered 500; the till open and a return stamped the register's branch whatever the caller's branch context named; a bound key's sync logged its batch under another branch's register; the CONTRACT-CHANGES file held two rows with the id `SEC-BOUND-KEY-PIN-6`; finance could not read the customer it may change; and a sales user read "a branch bound key may act only on its own branch" on a shared customer's restricted fields | Migration 108 backfills `branch_id` on `till_sessions`, `till_z_reports` and `pos_returns` from each row's own register (`COALESCE(the register's location's branch, pos_registers.branch_id)`), touching only rows whose branch is NULL, counting what it set per table in a NOTICE, reporting by count and first ids any row whose register names no branch and leaving it as it is, moving no money and deleting nothing; its down leaves the data. A register branch refusal is a typed error answered 403 with its words on the sale, the till open, the return and the whole sync batch (the batch register is held to the caller's branch context before any item runs and before the `pos_sync_log` row is written). Finance may read `GET /customers/{id}` and `GET /customers/{id}/escalation-policy` (reads only, the reads its writes need for `If-Match`; every other customer, contact, ship-to and activity route keeps the roles it had). The shared customer refusals carry one clause naming the other branch behind a caller specific lead in: "this key may act only on its own branch" for a bound key, "this key may not change a shared customer's restricted fields" for an unbound key without the scope, "this role may not change a shared customer's restricted fields" for a user without the finance or owner role (ADR 0007 section 5.5 quotes them) | +| SEC-BOUND-KEY-PIN-8 | the branch bound key's walls, fix round 8 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 16 and 17 found: the seed's REG-01 upsert moved a register migration 027 created that an operator later re-pointed, because its `DO UPDATE` was unconditional (item 57's condition reads migration 027 as another seed); the CONTRACT-CHANGES file held two rows with the id `SEC-BOUND-KEY-PIN-7`, the pos row and round 7's; `GET /pos/till/{id}/report` answered 500 for a session the caller's branch context hides, where the other by id reads answer 404; the exclusive walls still led the shared customer refusal with "a branch bound key" in words ADR 0007 section 5.5 does not quote; and a sync batch naming a register that does not exist ran its items and was logged under that register | The seed's upsert updates only while REG-01 still sits on migration 027's placeholder location, so a re-seed never moves a register an operator re-pointed (nor renames one; the register a person made is untouched as before). Every row of this PR carries a unique id: the pos row its own (`SEC-BOUND-KEY-PIN-POS`), each round's rows under their own id. The hidden till session reads as missing (404, the Z report and return words; the spec entry updated). The exclusive walls' refusal carries the quoted lead in "this key may act only on its own branch", the customer service's bound key words, so every shared customer refusal a bound key meets carries words the ADR quotes. A sync batch whose `register_id` names no register is refused 400, a typed error, before any item runs and before the `pos_sync_log` row, which never names an unknown register (spec entry, allowlist notes and ADR 0007's sync passage updated) | From 591320963d849366eea30f42a7099dbc107b9f39 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 17:18:16 -0700 Subject: [PATCH 71/74] seed: the register upsert fires only on the shape 027 and 066 left Round 8's guard keyed on the placeholder location alone. The placeholder names no locations row, so while REG-01 sits on it the register's branch comes from pos_registers.branch_id alone on every pos read and write, and an operator who re-pointed the register by its branch alone had moved it as completely as one who moved the location; the next demo re-seed moved it back (r19 P2-1, item 59's the operator's row is never moved). The upsert now also requires the branch migration 066 gave the row, the default_branch_id the seed reads before it changes anything, so it fires only on the row exactly as migrations 027 and 066 left it. A re-seed leaves a branch only, location only or both re-point alone, still moves a row in the migrations' shape exactly once, keeps every name, and a database whose default an operator changed keeps REG-01 where it is. Proven with the seed's own statement (red first) and with the real cmd/seed over two runs per leg. Signed-off-by: colton --- core/internal/app/seed/seed.go | 48 ++++++++++++++++++----- core/internal/app/seed/seed_test.go | 60 ++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 02e21d1e..41bcbeae 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -187,6 +187,12 @@ func Run() { log.Fatalf("Failed to ping database: %v", err) } + // Read the default branch before anything below changes it: dedupeBranches + // may clear the setting and section 1 re-points it at Kelowna, while the + // register upsert in section 22 compares against the value the migrations + // left, the branch migration 066 stamped REG-01 with. + regMigrationBranch := preSeedDefaultBranch(db) + // The AR core: every invoice, payment and credit memo the seed writes moves // AR through it, never through a raw balance_due or subledger write. ar, err := newReceivables(dbURL) @@ -1316,8 +1322,11 @@ func Run() { // pos_registers, /api/v1/pos/transactions fails with "failed to resolve // register branch". Migration 027 seeds REG-01 on a location that does not // exist and migration 066 gives it the migration-time default branch, so - // the row is moved to Kelowna, the branch the seed makes the default. - db.Exec(posRegisterUpsertSQL, kelMainID.String()) + // the row is moved to Kelowna, the branch the seed makes the default, but + // only while it is still exactly in that shape: a row an operator moved, + // by its location or by its branch alone, is not the seed's to move back + // (the shape condition on posRegisterUpsertSQL below). + db.Exec(posRegisterUpsertSQL, kelMainID.String(), regMigrationBranch) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) VALUES ('sync-batch-001', 'REG-01', 42, 0, 0, NOW() - interval '2 hours')`) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) @@ -1366,6 +1375,19 @@ const pricingRuleUpsertSQL = ` expires_at = EXCLUDED.expires_at, updated_at = NOW()` +// preSeedDefaultBranch reads system_settings.default_branch_id as it stands +// before this seed run changes anything. Migration 059 records the setting and +// migration 066 stamps every register with it, so that value is half of the +// shape migrations 027 and 066 leave REG-01 in, and the register upsert below +// fires only on that exact shape (r19 P2-1). A database holding no setting yet +// reads NULL, which matches no row: the seed then leaves REG-01 where it is +// rather than move a row it cannot prove is still the migrations' own. +func preSeedDefaultBranch(db *sql.DB) sql.NullString { + var v sql.NullString + db.QueryRow(`SELECT value FROM system_settings WHERE key = 'default_branch_id'`).Scan(&v) + return v +} + // posRegisterUpsertSQL moves the demo register REG-01 onto the location given // (Kelowna main, the branch the seed makes the default), as a package level // constant for the same reason as pricingRuleUpsertSQL: the behaviour worth @@ -1375,18 +1397,24 @@ const pricingRuleUpsertSQL = ` // The conflict is on the id, so the update can only ever touch the seed's own // REG-01 row; a register with any other id, created by a person, an import or // another seed, is never matched. Of that one row the seed owns the location -// and the branch alone, and only while the row still sits on migration 027's -// placeholder location: the row is born in migration 027 on every database, -// so a REG-01 an operator re-pointed by hand is not the seed's to move back -// (item 57's condition reads 027 as another seed; r16 P2), and the register's -// name is an operator's to keep the same way. The update therefore fires only -// under that placeholder, which also makes a second run a no-op: the row no -// longer carries it. +// and the branch alone, and only while the row is exactly as migrations 027 +// and 066 left it: the placeholder location, and the branch 066 gave it, the +// default branch the seed reads before it changes anything. The placeholder +// names no locations row, so while the register sits on it the register's +// branch comes from pos_registers.branch_id alone on every pos read and write, +// and an operator who re-points REG-01 by its branch alone has moved it as +// completely as one who moves the location; the seed's to move is neither +// (item 57 reads migration 027 as another seed; r16 P2, r19 P2-1), and the +// register's name is an operator's to keep the same way. A database whose +// default an operator changed then keeps REG-01 where it is, the safe side. +// The shape condition also makes a second run a no-op: the row no longer +// carries it. const posRegisterUpsertSQL = ` INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1 - WHERE pos_registers.location_id = '00000000-0000-0000-0000-000000000000'` + WHERE pos_registers.location_id = '00000000-0000-0000-0000-000000000000' + AND pos_registers.branch_id = $2::uuid` // pricingRule is one row of the demo pricing_rules table. // diff --git a/core/internal/app/seed/seed_test.go b/core/internal/app/seed/seed_test.go index d2d47abe..4de9c0ca 100644 --- a/core/internal/app/seed/seed_test.go +++ b/core/internal/app/seed/seed_test.go @@ -297,9 +297,18 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { t.Fatal(err) } + // The branch half of the migration shape: the seeder reads + // default_branch_id before it changes anything (preSeedDefaultBranch in + // seed.go), and the test reads it the same way so the two run the same + // statement with the same comparison value. + var migrationBranch string + if err := db.Pool.QueryRow(ctx, + `SELECT value FROM system_settings WHERE key = 'default_branch_id'`).Scan(&migrationBranch); err != nil { + t.Fatal(err) + } upsert := func() { t.Helper() - if _, err := db.Pool.Exec(ctx, posRegisterUpsertSQL, loc.String()); err != nil { + if _, err := db.Pool.Exec(ctx, posRegisterUpsertSQL, loc.String(), migrationBranch); err != nil { t.Fatalf("the seeder's register upsert: %v", err) } } @@ -361,4 +370,53 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) { if opName1 != "operator counter" { t.Errorf("a re-seed renamed the re-pointed register to %q, want the operator's name kept", opName1) } + + // The location alone leg (r16 P2's case, held since round 8): the + // operator moved the location and left the branch as the migrations set + // it. A re-seed, run twice, leaves the row where the operator put it. + if _, err := db.Pool.Exec(ctx, + `UPDATE pos_registers SET location_id = $1, branch_id = $2 WHERE id = 'REG-01'`, + opLoc, migrationBranch); err != nil { + t.Fatal(err) + } + upsert() + upsert() + var lLoc, lBranch string + if err := db.Pool.QueryRow(ctx, + `SELECT location_id::text, branch_id::text FROM pos_registers WHERE id = 'REG-01'`). + Scan(&lLoc, &lBranch); err != nil { + t.Fatal(err) + } + if lLoc != opLoc.String() || lBranch != migrationBranch { + t.Errorf("a re-seed moved a location only re-pointed REG-01: location %s branch %s, want %s and the migration branch", + lLoc, lBranch, opLoc) + } + + // While the location is the placeholder, which names no locations row, + // the register's branch comes from pos_registers.branch_id alone on every + // pos read and write, so an operator who re-points REG-01 by its branch + // alone has moved it as completely as a location re-point (r19 P2-1, + // item 59's "the operator's row is never moved"). A re-seed, run twice, + // must leave the branch where the operator put it and the location on + // the placeholder. + if _, err := db.Pool.Exec(ctx, + `UPDATE pos_registers SET location_id = '00000000-0000-0000-0000-000000000000', + branch_id = $1, name = 'operator counter' WHERE id = 'REG-01'`, opLoc); err != nil { + t.Fatal(err) + } + upsert() + upsert() + var bLoc, bBranch, bName string + if err := db.Pool.QueryRow(ctx, + `SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`). + Scan(&bLoc, &bBranch, &bName); err != nil { + t.Fatal(err) + } + if bLoc != "00000000-0000-0000-0000-000000000000" || bBranch != opLoc.String() { + t.Errorf("a re-seed moved a branch only re-pointed REG-01: location %s branch %s, want the placeholder and %s", + bLoc, bBranch, opLoc) + } + if bName != "operator counter" { + t.Errorf("a re-seed renamed the branch only re-pointed register to %q, want the operator's name kept", bName) + } } From 6eea086c36ca7ddf80f3b431d6a9045e6da1aa33 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 17:18:21 -0700 Subject: [PATCH 72/74] pos: an empty sync batch register is refused before the log row The unknown batch register refusal ran only when the register_id was not empty, so a batch with an empty register_id still ran its items and was logged under a register nobody made, the log row carrying an empty register_id (r19 P3-1). An empty register_id names no register either, so the batch is now refused the same typed 400 before any item runs and before the pos_sync_log row, for every caller: a register that does not exist is a bad request for anyone (r18 P3-2's confirmation, stated in CONTRACT-CHANGES as SEC-BOUND-KEY-PIN-9). The spec entry, the allowlist note and the ADR's sync passage follow. Signed-off-by: colton --- core/api/fragments/pos.yaml | 2 +- core/api/openapi.yaml | 2 +- .../app/serve/wire_key_branch_round8_test.go | 14 ++++++++++ core/internal/pos/repository.go | 8 ++++-- core/internal/pos/service.go | 27 ++++++++++--------- core/pkg/middleware/keybranch_allow.go | 2 +- ...7-drafts-links-and-confirm-gated-scopes.md | 6 +++-- web/packages/api-client/src/schema.d.ts | 2 +- 8 files changed, 43 insertions(+), 20 deletions(-) diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index c2f921ef..8093f315 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -312,7 +312,7 @@ paths: tags: - pos summary: Replay offline transactions - description: A missing batch_id, an empty items list or a register_id that names no register answers 400. + description: A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 99141d31..49127635 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -10473,7 +10473,7 @@ paths: tags: - pos summary: Replay offline transactions - description: A missing batch_id, an empty items list or a register_id that names no register answers 400. + description: A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/internal/app/serve/wire_key_branch_round8_test.go b/core/internal/app/serve/wire_key_branch_round8_test.go index 3abbc0a6..e3b35123 100644 --- a/core/internal/app/serve/wire_key_branch_round8_test.go +++ b/core/internal/app/serve/wire_key_branch_round8_test.go @@ -77,6 +77,20 @@ func TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister(t *testing.T) { t.Errorf("the refused sync wrote %d transactions, want none", n) } + // The empty batch register names no register either (r19 P3-1), so it is + // refused the same 400, before any item runs and before a log row is + // written under a register nobody made. + status, buf = f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody(""), nil) + if status != http.StatusBadRequest { + t.Errorf("bound key sync with an empty batch register: %d %s, want the 400 refusing the batch", status, buf) + } + if n := logRows(); n != 0 { + t.Errorf("the empty register sync wrote %d pos_sync_log rows, want none", n) + } + if n := f.transactionsWithID(t, client); n != 0 { + t.Errorf("the empty register sync wrote %d transactions, want none", n) + } + // The control: the pin's own register as the batch register still passes. status, buf = f.key(t, first.raw, "POST", "/api/v1/pos/sync", syncBody(f.regA), nil) if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index a160e046..0e29315b 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -27,8 +27,9 @@ func (e *RegisterBranchRefusedError) Error() string { } // UnknownRegisterError is the refusal a write answers when the register it -// names does not exist. It is a typed error so the HTTP layer can answer it -// 400 instead of the 500 a plain service error maps to; the offline sync +// names does not exist, an empty register_id included, which names no register +// either (r19 P3-1). It is a typed error so the HTTP layer can answer it 400 +// instead of the 500 a plain service error maps to; the offline sync // refuses a batch whose register does not resolve before any item runs and // before the log row, so the log never names a register nobody made (r17 // P3-3). @@ -37,6 +38,9 @@ type UnknownRegisterError struct { } func (e *UnknownRegisterError) Error() string { + if e.RegisterID == "" { + return "the batch register_id is empty and names no register" + } return fmt.Sprintf("register %s does not exist", e.RegisterID) } diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 9e2b4001..1cbc1264 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -637,18 +637,21 @@ func (s *Service) SyncOfflineTransactions(ctx context.Context, req OfflineSyncRe // register for a caller held to one branch. A batch register that does // not resolve is refused the same way (r17 P3-3): a log row under a // register nobody made is never written, and a lookup failure answers an - // error rather than skipping the hold. - if req.RegisterID != "" { - branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID) - if err != nil { - return nil, fmt.Errorf("sync: register branch lookup: %w", err) - } - if branch == nil { - return nil, &UnknownRegisterError{RegisterID: req.RegisterID} - } - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} - } + // error rather than skipping the hold. An empty register_id names no + // register either (r19 P3-1), so it is refused the same 400 rather than + // logged under a register nobody made. + if req.RegisterID == "" { + return nil, &UnknownRegisterError{RegisterID: req.RegisterID} + } + branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID) + if err != nil { + return nil, fmt.Errorf("sync: register branch lookup: %w", err) + } + if branch == nil { + return nil, &UnknownRegisterError{RegisterID: req.RegisterID} + } + if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { + return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} } syncTag := "offline-v1" diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index e042b92b..2649a8f4 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -398,7 +398,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, - "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that names no register is refused 400 the same way, round 8), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, + "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that is empty or names no register is refused 400 the same way, rounds 8 and 9), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); no machine key cashier"}, diff --git a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md index fc9c85af..8d50cabb 100644 --- a/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md +++ b/docs/adr/0007-drafts-links-and-confirm-gated-scopes.md @@ -729,8 +729,10 @@ same way a path customer is: the cash and card payment, the credit memo moved onto another customer included), the offline POS sync (the customer each replayed sale names, whose ACCOUNT tender invoices it, and the batch register itself, a batch naming another branch's register refused 403, or a -batch register naming no register at all refused 400, before any item runs -and before the `pos_sync_log` row names it) and the draft +batch register that is empty or names no register at all refused 400 for +every caller, a register that does not exist being a bad request for +anyone and not only a bound key, before any item runs and before the +`pos_sync_log` row names it) and the draft promotions, which read the customer from the draft's stored payload, are refused a bound key whose pin does not hold that customer (403 naming `customer_id`, the `key.branch_refused` row, nothing written). Without it diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 86b24845..7fa85042 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -4575,7 +4575,7 @@ export interface paths { put?: never; /** * Replay offline transactions - * @description A missing batch_id, an empty items list or a register_id that names no register answers 400. + * @description A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. */ post: operations["posSync"]; delete?: never; From 79817424579be77de075116a77ad32a9c831b5fc Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 17:18:27 -0700 Subject: [PATCH 73/74] serve: the exclusive wall test asserts the refusal's audit row The exclusive walls leg counted the fixture's bound key rows, never the pinned key it drives, and its two later checks were placeholders that could not fail, so the leg that carries the wall's quoted refusal words never checked that the refusal wrote its row (r19 P3-2). The test now reads the pinned key's id after the mint, asserts its key.branch_refused count rose by exactly one, and reads the row back: the refused call's method and path and the pin branch. The module page states the row. Signed-off-by: colton --- .../app/serve/wire_key_branch_shared_test.go | 30 ++++++++++++++----- docs/modules/customers.md | 4 ++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/core/internal/app/serve/wire_key_branch_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go index d5f881b0..cdfc874a 100644 --- a/core/internal/app/serve/wire_key_branch_shared_test.go +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -486,17 +486,37 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { // lookup, the shared customer pricing and exemption line), and their // refusal carries the lead in ADR 0007 section 5.5 quotes for a bound // key (r17 P3-2), so a reader of the section finds the wall's words. + // The refusal also writes its row (r19 P3-2): one key.branch_refused row + // for the pinned key that drove it, naming the refused call's method, + // path and pin branch, with nothing written to the wall's own table. { pinned := f.mintWithScopes(t, "pin exclusive walls", []string{"tax:read", "tax:write"}, &f.branchA) - before := f.auditRows(t) + var pinnedID string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id FROM api_keys WHERE name = $1`, "pin exclusive walls").Scan(&pinnedID); err != nil { + t.Fatal(err) + } + before := f.auditRowsFor(t, pinnedID) body := fmt.Sprintf(`{"customer_id":%q,"exempt_reason":"pin three branch","certificate_number":"PIN-EXP-3","issuing_state":"WA"}`, s.id.String()) status, buf := f.key(t, pinned, "POST", "/api/v1/tax/exemptions", body, nil) if status != http.StatusForbidden || !strings.Contains(string(buf), "this key may act only on its own branch") || !strings.Contains(string(buf), named) { t.Errorf("bound key exemption on the three branch customer: %d %s, want the 403 naming the lowest other branch", status, buf) } - if after := f.auditRowsFor(t, "no key"); after != 0 { - t.Fatal("unreachable") + if after := f.auditRowsFor(t, pinnedID); after != before+1 { + t.Errorf("the exclusive wall refusal wrote %d rows over the %d before it, want exactly one key.branch_refused row", + after-before, before) + } + var method, path, rowBranch string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT changes->>'method', changes->>'path', changes->>'branch_id' FROM audit_log + WHERE action = 'key.branch_refused' AND actor_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, + pinnedID).Scan(&method, &path, &rowBranch); err != nil { + t.Fatal(err) + } + if method != "POST" || path != "/api/v1/tax/exemptions" || rowBranch != f.branchA.String() { + t.Errorf("the exclusive wall's refusal row names %s %s at branch %s, want POST %s at the pin %s", + method, path, rowBranch, "/api/v1/tax/exemptions", f.branchA) } var exemptions int if err := f.db.Pool.QueryRow(context.Background(), @@ -506,10 +526,6 @@ func TestKeyBranchPin_SharedCustomerRecord(t *testing.T) { if exemptions != 0 { t.Errorf("the refused exemption write left %d rows", exemptions) } - if rows := f.auditRowsFor(t, "placeholder"); rows != 0 { - t.Fatal("unreachable") - } - _ = before } // A service level refusal names the call it refused: the row's method and diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 21f53447..c0c87878 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -190,7 +190,9 @@ numbered other branch), with the `key.branch_refused` row, and the record is untouched. The customer scoped pricing rules, tax exemptions and category rules refuse a shared customer's bound key write in the same quoted lead in (`core/pkg/middleware/keybranch.go`), so every shared customer refusal a -bound key meets carries words ADR 0007 section 5.5 quotes. A customer the +bound key meets carries words ADR 0007 section 5.5 quotes; each wall +refusal writes its own `key.branch_refused` row naming the refused call's +method, path and the pin branch, with nothing written to the wall's table. A customer the key's branch holds alone and an unshared customer are unchanged for every caller. Who else may change a shared customer's restricted fields: a user only with the `finance` or `owner` role (a sales From 460d17da8aa20029ad68c0a3c931a737862e9be2 Mon Sep 17 00:00:00 2001 From: colton Date: Sun, 11 Oct 2026 17:18:32 -0700 Subject: [PATCH 74/74] docs: round 9 in CONTRACT-CHANGES as SEC-BOUND-KEY-PIN-9 The row states the three round 9 fixes and the round 18 confirmation: the seed's upsert fires only on the exact shape migrations 027 and 066 left, an empty or unknown sync batch register is a 400 for every caller, and the exclusive wall test asserts the refusal's audit row. It also records that the ids repeating on refactor/v1 (PR 99's C4-1b rows among them) predate this PR and are left alone: a whole file pass on citable ids is the principal's hygiene work. Every id of this PR stays unique. Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 1bcd41ef..dff762a3 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -299,6 +299,7 @@ get an entry. | SEC-BOUND-KEY-PIN-POS | pos: `POST /api/v1/pos/till/open` (and the till session reads that return it), goldens `pos`, `pos_transactions`, `pos_returns`, `pos_till_close` | A till session opened on a register whose location names no branch was stamped with no branch (`branch_id` absent from the session) | The session carries the register's branch (`COALESCE(l.branch_id, r.branch_id)`), so `branch_id` appears in the till session body; the seed also moves the seeded `REG-01` onto Kelowna (an `ON CONFLICT (id) DO UPDATE` on `location_id` and `branch_id` alone that fires only while the row still sits on migration 027's placeholder location, so it can never move a register another hand created, never moves one an operator re-pointed, and keeps a register's name), so a seeded register belongs to the branch the seed makes the default, which leaves every sale and return amount in the goldens unchanged | | SEC-BOUND-KEY-PIN-7 | the branch bound key's walls, fix round 7 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Round 15 and the principal's item 57 rulings found: the till, Z report and return reads' branch predicate hid every row written before the column was stamped, so a drawer open at upgrade read as no drawer (close 409, open 409) and the legacy Z reports and returns vanished from every caller with a branch context; a user's register branch refusal on the sale answered 500; the till open and a return stamped the register's branch whatever the caller's branch context named; a bound key's sync logged its batch under another branch's register; the CONTRACT-CHANGES file held two rows with the id `SEC-BOUND-KEY-PIN-6`; finance could not read the customer it may change; and a sales user read "a branch bound key may act only on its own branch" on a shared customer's restricted fields | Migration 108 backfills `branch_id` on `till_sessions`, `till_z_reports` and `pos_returns` from each row's own register (`COALESCE(the register's location's branch, pos_registers.branch_id)`), touching only rows whose branch is NULL, counting what it set per table in a NOTICE, reporting by count and first ids any row whose register names no branch and leaving it as it is, moving no money and deleting nothing; its down leaves the data. A register branch refusal is a typed error answered 403 with its words on the sale, the till open, the return and the whole sync batch (the batch register is held to the caller's branch context before any item runs and before the `pos_sync_log` row is written). Finance may read `GET /customers/{id}` and `GET /customers/{id}/escalation-policy` (reads only, the reads its writes need for `If-Match`; every other customer, contact, ship-to and activity route keeps the roles it had). The shared customer refusals carry one clause naming the other branch behind a caller specific lead in: "this key may act only on its own branch" for a bound key, "this key may not change a shared customer's restricted fields" for an unbound key without the scope, "this role may not change a shared customer's restricted fields" for a user without the finance or owner role (ADR 0007 section 5.5 quotes them) | | SEC-BOUND-KEY-PIN-8 | the branch bound key's walls, fix round 8 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 16 and 17 found: the seed's REG-01 upsert moved a register migration 027 created that an operator later re-pointed, because its `DO UPDATE` was unconditional (item 57's condition reads migration 027 as another seed); the CONTRACT-CHANGES file held two rows with the id `SEC-BOUND-KEY-PIN-7`, the pos row and round 7's; `GET /pos/till/{id}/report` answered 500 for a session the caller's branch context hides, where the other by id reads answer 404; the exclusive walls still led the shared customer refusal with "a branch bound key" in words ADR 0007 section 5.5 does not quote; and a sync batch naming a register that does not exist ran its items and was logged under that register | The seed's upsert updates only while REG-01 still sits on migration 027's placeholder location, so a re-seed never moves a register an operator re-pointed (nor renames one; the register a person made is untouched as before). Every row of this PR carries a unique id: the pos row its own (`SEC-BOUND-KEY-PIN-POS`), each round's rows under their own id. The hidden till session reads as missing (404, the Z report and return words; the spec entry updated). The exclusive walls' refusal carries the quoted lead in "this key may act only on its own branch", the customer service's bound key words, so every shared customer refusal a bound key meets carries words the ADR quotes. A sync batch whose `register_id` names no register is refused 400, a typed error, before any item runs and before the `pos_sync_log` row, which never names an unknown register (spec entry, allowlist notes and ADR 0007's sync passage updated) | +| SEC-BOUND-KEY-PIN-9 | the branch bound key's walls, fix round 9 (the same 345 census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118; no route added or removed) | Rounds 18 and 19 found: while REG-01 still sat on migration 027's placeholder location, which names no locations row, the register's branch came from pos_registers.branch_id alone, so an operator who re-pointed it by its branch alone had moved it as completely as by its location, and the next demo re-seed moved it back (item 59's "the operator's row is never moved"); a sync batch with an empty register_id still ran its items and was logged under a register nobody made (the log row carried an empty register_id); and the PR's exclusive wall test left the refusal's audit row unasserted (its checks were placeholders). Round 18 also asked whether the unknown batch register 400, which answers for every caller, is the right class | The seed's upsert fires only on the row exactly as migrations 027 and 066 left it: the placeholder location AND the branch 066 gave it, the default_branch_id the seed reads before it changes anything (a database whose default an operator changed keeps REG-01 where it is, the safe side), so a re-seed never moves a register an operator re-pointed by its branch, by its location or by both, still moves one still in the migrations' shape exactly once, keeps every name, and leaves every other register untouched; a sync batch whose register_id is empty or names no register is refused 400 for every caller (a register that does not exist is a bad request for anyone, not only a bound key; stated here as round 18 P3-2 asked), before any item runs and before the pos_sync_log row; the exclusive wall test asserts the pinned key's key.branch_refused row, its method, path and pin branch, beside the refusal words. The repeated ids that already repeat on refactor/v1 (PR 99's C4-1b rows among them, 22 ids in all) predate this PR and are left alone: a whole file pass on citable ids is the principal's hygiene work, not this PR's | | C4-1b | ap: `POST /api/v1/ap/invoices/{id}/transitions` to approved, on a pending bill carrying a paid amount (review fix) | A legacy pending bill with `amount_paid <> 0` (an unknown status migration 106 made PENDING, or a hand edited row) approved whole: the entry credited `2010` with the entire total while `amount_open` stood at the migration's clamp (zero on a bill paid past its total, the rest on a partly paid one), so `2010` held money no bill owed and no route cleared it | The approve refuses it with a 409 `invalid_state_transition` and the `has_paid_amount` blocker naming the paid cents; nothing is written and the row is left for the operator to mend by hand (new data cannot reach the shape: the pay applies only to an approved or partial bill) | | C4-1b | ap: `POST /api/v1/ap/invoices/{id}/transitions` to voided, the no reversal arm (review fix) | The arm was keyed on the bill's total: an approved bill with a nonzero total and no `gl_entry_id` was refused `entry_not_linked` (even a legacy bill whose entry never posted or was already reversed, holding no credit in `2010`), while a legacy bill hand edited to a zero total after its approve voided with nothing reversed, stranding its posted credit in `2010` under a VOIDED bill | The arm is keyed on the ledger: a bill that holds a posted, unreversed `VENDOR_INVOICE` entry it is not linked to (a legacy bill whose posting was swallowed, one holding the removed sync's one leg credit, or one whose total was hand edited after its approve, whatever its total says now) is refused `entry_not_linked` and keeps its status; a bill with no live entry (one that prices to zero, or a legacy bill whose entry never posted or was already reversed) holds no credit and voids with nothing to reverse; a linked live entry still voids reversed whole | | C4-1b | ap: `POST /api/v1/ap/invoices` currency default (review fix, PR 89 r1 P2-1) | `Parse` set an omitted currency to `USD` before the service saw it, so the `currency.default` setting was never read on the route (only an in-process caller with an empty currency reached it) | `Parse` leaves an omitted currency empty and the create's `resolve` reads the `currency.default` setting for it (`USD` where the setting is absent), on the route as in process; a currency the request names is still validated as an ISO 4217 code of three capital letters |