diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index d0436d21..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 or an empty items list 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' @@ -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 4b1ba7e0..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 or an empty items list 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' @@ -10602,7 +10602,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 @@ -10624,6 +10624,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/seed/seed.go b/core/internal/app/seed/seed.go index e3bc54ff..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) @@ -1314,10 +1320,13 @@ 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". - 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()) + // 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, 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,47 @@ 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 +// 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; 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 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' + AND pos_registers.branch_id = $2::uuid` + // 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..4de9c0ca 100644 --- a/core/internal/app/seed/seed_test.go +++ b/core/internal/app/seed/seed_test.go @@ -234,3 +234,189 @@ 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, 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 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, 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 { + 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 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) + 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) + } + + // 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(), migrationBranch); 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) + } + + // 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) + } + + // 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) + } +} 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 e677a5c4..b6d112e2 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 @@ -319,10 +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, middleware.RequireRole("admin", "owner", "sales")) + salesTeamHandler.RegisterRoutes(mux, + 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 @@ -342,7 +345,9 @@ 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) @@ -372,7 +377,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, db, "admin", "owner")) // Category Pricing Engine (feature-flagged) if strings.EqualFold(os.Getenv("CATEGORY_PRICING_ENABLED"), "true") { @@ -383,7 +388,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 { @@ -510,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 @@ -601,7 +606,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) @@ -664,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 @@ -683,7 +688,9 @@ func Run() { 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) @@ -693,7 +700,7 @@ func Run() { 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 @@ -707,12 +714,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. @@ -805,7 +812,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) @@ -841,9 +848,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 6c65abdf..248b575a 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -13,40 +13,75 @@ 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" "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" + "github.com/gablelbm/gable/internal/reporting" + "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 + db *database.DB } 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)), db: db} w.scoped = func(roles ...string) func(http.Handler) http.Handler { return middleware.Compose(middleware.RequireRole(roles...), w.mw) } return w } +// 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.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) +} + +// reportingBIIntegration is reporting over the BI export routes. +func (w *branchWall) reportingBIIntegration(mux *http.ServeMux, h *reporting.Handler) { + 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 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.RequireRole("admin", "owner")) +} + // 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). @@ -57,9 +92,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 @@ -76,18 +114,37 @@ 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 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 third guard admits finance on exactly the three restricted field routes +// (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) { - 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"), + middleware.Compose(w.scoped("admin", "owner", "sales", "finance"), w.keyWall.RecordRequest())) } // 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 @@ -101,38 +158,129 @@ 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), 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.scoped("admin", "owner", "sales")) + quote.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + 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, -// 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). 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.scoped("admin", "owner", "sales"), + order.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, + w.bodyDocumentCustomer(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, // 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 // 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 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.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")) +} + +// 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...) +} + +// 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 { + 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") +} + +// 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) { 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..0fe35e92 --- /dev/null +++ b/core/internal/app/serve/wire_confinement_source_test.go @@ -0,0 +1,49 @@ +// 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"}, + {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_customer_branch.go b/core/internal/app/serve/wire_customer_branch.go new file mode 100644 index 00000000..567d5f1d --- /dev/null +++ b/core/internal/app/serve/wire_customer_branch.go @@ -0,0 +1,648 @@ +// 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 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) 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" + "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 + } +} + +// 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 + } +} + +// 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 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 { + out = append(out, id) + } + 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 + } +} + +// 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 + } +} + +// 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 +// 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 + } +} + +// 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 +// 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 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 { + 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 + targetType string + ) + err = db.GetExecutor(r.Context()).QueryRow(r.Context(), + `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 || targetType != "ACCOUNT" { + return nil, middleware.ErrBodyRefused + } + return []uuid.UUID{*customer}, nil + } +} + +// 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 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 { + return nil, errors.New("category rule customer lookup: no database") + } + 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 + } + 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 OR target_type <> 'ACCOUNT')`, 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 + } + 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() + } +} + +// 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) +} + +// 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.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 { + 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 r.Pattern { + case "POST /api/v1/tax/exemptions": + createHandler.ServeHTTP(w, r) + 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) + } + }) + } +} + +// 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, 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.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) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Pattern { + case "POST /api/v1/pricing/rules": + createHandler.ServeHTTP(w, r) + case "GET /api/v1/pricing/calculate": + calculateHandler.ServeHTTP(w, r) + default: + roleHandler.ServeHTTP(w, r) + } + }) + } +} + +// categoryRulesCustomerWall composes the category pricing role guard with the +// 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 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, 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": + 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}": + 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: + roleHandler.ServeHTTP(w, r) + } + }) + } +} diff --git a/core/internal/app/serve/wire_exposure.go b/core/internal/app/serve/wire_exposure.go index 41b1c81c..1760c7c6 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,16 +174,75 @@ 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, + // 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 + } + 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 - // a duplicate pattern would panic the ServeMux. + // GET /api/v1/market-indices: that belongs to pricing.EscalatorHandler and + // 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, middleware.RequireRole("admin", "owner")) + RegisterRoutes(mux, indexAdminGuard()) +} + +// 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 +// 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 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) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("id") != "" { + byID.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r) + }) + } } // exposureAuditAdapter bridges the synchronous pkg/audit.Logger to the narrow 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..5d9fd59a --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_allow_test.go @@ -0,0 +1,790 @@ +// 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 ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "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" +) + +// 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)) + + // 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=" + 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", "") + + // 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. + 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.StatusOK) + 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 (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 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(who), 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 (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) + 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) + } + + // 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)}) + 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 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, "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 module's own answer", path, bb) + } + if sb != sk { + t.Errorf("GET %s: bound %d, unbound %d, want the same answer", path, sb, sk) + } + } +} + +// 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, 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, + 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)" +} + +// 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) + } + } +} + +// 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() { + 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) + }) + 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(), "") + 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", + 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' AND actor_id = $2`, + action, f.boundKeyID).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(action string) (n int) { + t.Helper() + if err := f.db.Pool.QueryRow(context.Background(), + `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 + } + + // 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) + } + beforeUpload := spendRows("ai.parsing.upload") + 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 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("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("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_legs_test.go b/core/internal/app/serve/wire_key_branch_legs_test.go new file mode 100644 index 00000000..b0acf97c --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_legs_test.go @@ -0,0 +1,260 @@ +// 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 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" + "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 count the twin legs its drivers mark, from here on. +func (f *keyPinFixture) record() { f.recording = true } + +// 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 { + 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 { + 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.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"} { + 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 + 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_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/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..35d4f5fc --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -0,0 +1,1703 @@ +// 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" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "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" + "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/customer/customeraudit" + "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" + "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/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/parsing" + "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/internal/vision" + "github.com/gablelbm/gable/pkg/apps" + "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" + "github.com/jackc/pgx/v5" +) + +// 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 + 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 + 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 +// 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", "orders:commit", "quotes:propose", "quotes:commit", + "dashboard:read", "inventory:read", "inventory:write", "locations:read", "locations:write", + "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", +} + +var pinKeyScopes = []string{ + "locations:write", "branches:read", "branches:write", + "users:read", "users:grants", "quotes:read", "quotes:write", "admin: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", + "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", + "parsing:write", "vision:write", +} + +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) + 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(), + 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 + 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) + } + // 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) + } + } + // 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 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 + // 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, `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() { + 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) + _, _ = 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 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) 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) 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) + _, _ = 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) + }) + + setSetting(t, db, "multi_branch_enabled", switchState) + 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")). + WithAuditLog(audit.NewLogger(db))) + + // 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) + 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(), + 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. 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, 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")) + + // 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)).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")) + pricing.NewHandler(pricing.NewService(pricing.NewRepository(db)).WithTxRunner(db), customerSvc, productSvc). + RegisterRoutes(mux, pricingRulesCustomerWall(wall.keyWall, db, "admin", "owner")) + pricing.NewCategoryHandler( + pricing.NewCategoryPricingService(pricing.NewCategoryRepository(db)).WithTxRunner(db). + WithOutbox(outbox.NewWriter(db, "")). + 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)) + 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). + 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 + // 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), 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)) + 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)) + + // 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) + // 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)) + 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) + } + 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) + draftsSvc := drafts.NewService(draftsRepo, draftsRegistry). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db).WithAudit(audit.NewLogger(db)). + 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{}, + 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))) + + techadmin.NewHandler(keys).RegisterRoutes(mux, middleware.RequireRole("admin", "owner")) + mint := func(name string, branch *uuid.UUID) (string, string) { + t.Helper() + scopes := pinKeyScopes + if strings.Contains(name, "documents") { + scopes = pinDocumentScopes + } + raw, key, err := keys.GenerateKey(ctx, name, scopes, branch) + 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, 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() + } + 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 + // 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 +} + +// 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) + }) +} + +// 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.Invoice, error) { + return nil, nil +} + +func (pinAPSource) GetVendorInvoice(context.Context, uuid.UUID) (*ap.Invoice, error) { + return nil, nil +} + +func (pinAPSource) ApproveInvoice(context.Context, uuid.UUID, uuid.UUID) (*ap.Invoice, error) { + return nil, nil +} + +// 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 +} + +// 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() + 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 +} + +// 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' AND actor_id = $1`, + f.boundKeyID).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) + f.twinLeg(method, path) + if status != http.StatusForbidden { + t.Errorf("bound key %s %s: %d %s, want 403", method, path, status, buf) + return + } + // 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 { + 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 +} + +// 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() + 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 +} + +// 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 + 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) + + // 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 { + 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) + + // 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) { + 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) + } +} + +// 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", ""}, + {"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", ""}, + {"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() + "/transitions", ""}, + {"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) + } + + // 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", + "/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) + } + } + + // A route the router resolves to no pattern at all is refused the same + // 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.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) + } +} + +// 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, certificate_number, issuing_state) + VALUES ($1, $2, 'pin', 'PIN-CERT-1', 'WA')`, 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) + } + + _, 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 != 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) + 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. + _, 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 != 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) + 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) + } + _, 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 != 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", + 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) +} + +// 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) + f.record() + defer driverDone(t, "pos") + 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) + 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) + } + 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) + 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) + } + 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) + f.twinLeg("POST", "/api/v1/pos/till/"+f.tillB.String()+"/close") + 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) + } + 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) + } + 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) + 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) + } + 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.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) + + // 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) + } + 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) + } + 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/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..2cc10a74 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round4_test.go @@ -0,0 +1,271 @@ +// 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 "drafts": + exec(`DELETE FROM draft_events WHERE draft_id = $1`) + 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) + 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) +} + +// 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) + 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) + } + 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) + f.record() + defer driverDone(t, "body") + 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 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") + } + 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. + 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) + 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/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..203d58db --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -0,0 +1,468 @@ +// 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" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "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, 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) + 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), 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_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_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_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 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) { + 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), "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) + } + + // 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 := "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 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) + } + 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 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), 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)) + 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 +// 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, _, 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(), + `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.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.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 { + 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, 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) + } + 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/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..01b16dda --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round6_test.go @@ -0,0 +1,318 @@ +// 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" + "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) + } +} + +// 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) 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) + 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 + } + clause := "the customer is also held by branch " + lowest.String() + + check := func(who, lead string, status int, buf []byte, rowsBefore int) { + t.Helper() + 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) + } + } + before := f.sharedRefusalRows(t, s.id) + status, buf := f.key(t, f.unbound, "PUT", sh, change(), nil) + 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", "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 + 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) + } +} + +// 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), "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 { + 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) + } +} + +// 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) + 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", "/api/v1/customers", ""}, + {"GET", sh + "/contacts", ""}, + {"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) + 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 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, on the revisions finance reads itself. + if status, buf := f.userAs(t, "finance", "PUT", sh, + 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.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.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) + } +} + +// 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 := "this 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/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..ce6ce908 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -0,0 +1,392 @@ +// 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" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + "time" + + "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) + } +} + +// 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 +} + +// 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. +// (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) + } +} + +// 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) + } +} + +// 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/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..e3b35123 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_round8_test.go @@ -0,0 +1,102 @@ +// 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 ( + "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 +// 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) + } +} + +// 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 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`) { + 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_shared_test.go b/core/internal/app/serve/wire_key_branch_shared_test.go new file mode 100644 index 00000000..cdfc874a --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_shared_test.go @@ -0,0 +1,586 @@ +// 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" + "time" + + "github.com/gablelbm/gable/pkg/middleware" + "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, 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`) + 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, `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, `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 +} + +// 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 { + 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 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) + // 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() + + // 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) + }}, + {"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. + 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), 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 { + 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 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 { + 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) + } + } + // 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("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 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 + // 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) }) + } + // 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), 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 { + 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 + 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), 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 { + 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 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), 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) + 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, 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(), + `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) + } + } + + // 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. + 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/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..ae0d8d8d --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_tier_test.go @@ -0,0 +1,88 @@ +// 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_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") + } + // 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() + 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") + } +} 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..148e4604 --- /dev/null +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -0,0 +1,750 @@ +// 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" + "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" +) + +// 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 + vendor 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() + 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) + } + // 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) + } + } + 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 { + 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 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) + 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 +} + +// 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. 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() + + 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. + noRealUserAnswer := map[string]bool{ + "/api/v1/quotes/%s/file": 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 + } + 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}, + // 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}`)}, + {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) + 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) + } + 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) + 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 || + (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)", l.method, bPath, ub, um, ubuf) + } + } + } + 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) + } + } + + // 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 + first bool // sent with the first mint (the pos scopes) + }{ + {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 { + key := f.bound + if l.first { + 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 + } + 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 !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) + 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 + } + 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) + } + } + + // 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.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 + // yard is refused (the branch guard's own refusal) with nothing written. + 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) + } + 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/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/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/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..f210bfa0 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,18 +56,18 @@ "name": "pos.till.report.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/till//report" + "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": "" + "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/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..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) @@ -93,9 +142,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 +179,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 +228,12 @@ 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 + } next := *cur next.ContactID, next.ActivityType = d.ContactID, d.ActivityType next.Description, next.LoggedBy = d.Description, d.LoggedBy @@ -174,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 } @@ -185,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) } @@ -196,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 } @@ -206,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/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 { diff --git a/core/internal/customer/customerguard/customerguard.go b/core/internal/customer/customerguard/customerguard.go index 874ee681..f5df2ed7 100644 --- a/core/internal/customer/customerguard/customerguard.go +++ b/core/internal/customer/customerguard/customerguard.go @@ -29,3 +29,47 @@ 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, RefuseCaller: v.RefuseCaller, + RefuseCallerKey: v.RefuseCallerKey, Audit: v.Audit}, nil +} + +// 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) +} + +// 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/handler.go b/core/internal/customer/handler.go index 68d65a90..8acb8a8a 100644 --- a/core/internal/customer/handler.go +++ b/core/internal/customer/handler.go @@ -33,7 +33,13 @@ 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) 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 { @@ -45,22 +51,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("GET /api/v1/customers/{id}/escalation-policy", guard(h.HandleGetEscalationPolicy)) - mux.HandleFunc("PUT /api/v1/customers/{id}/escalation-policy", guard(h.HandleSetEscalationPolicy)) + 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", 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)) mux.HandleFunc("GET /api/v1/customers/{id}/ship-tos", guard(h.HandleListShipTos)) diff --git a/core/internal/customer/service.go b/core/internal/customer/service.go index a19ef935..076d05be 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,53 @@ 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_signed_at": p.AgreementSignedAt, "agreement_ref": p.AgreementRef} +} diff --git a/core/internal/customer/service_children.go b/core/internal/customer/service_children.go index 0ab72e4d..7dfe5ee9 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 ---- @@ -194,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 { @@ -216,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 } @@ -229,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 } @@ -254,6 +268,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 +290,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..c1be0993 --- /dev/null +++ b/core/internal/customer/shared.go @@ -0,0 +1,215 @@ +// 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 + // 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 + // 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 +// 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) + // 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 +} + +// 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 + // 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(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, 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 +} + +// 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, 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: lead + ": " + sharedRefusalClause(branch), + 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 { + if ref.caller { + s.shared.RecordCallerRefusal(ctx, ref.customer, ref.branch, ref.fields) + } else { + 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 + } + fields := make([]string, 0, len(changes)) + for f := range changes { + fields = append(fields, f) + } + if verdict.Refuse { + return refusedOn(verdict.Branch, fields...) + } + if verdict.RefuseCaller { + return refusedCallerOn(id, verdict.Branch, verdict.RefuseCallerKey, fields...) + } + if verdict.Audit { + return s.shared.RecordChange(ctx, id, changes) + } + 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 { + 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/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/location/handler.go b/core/internal/location/handler.go index a32ec502..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" @@ -24,6 +26,34 @@ 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 + 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 @@ -49,6 +79,21 @@ 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 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). 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 +} + func NewHandler(service *Service, userRepo UserRepository, adminGuards ...func(http.Handler) http.Handler) *Handler { return &Handler{ service: service, @@ -83,6 +128,24 @@ 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 + // 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 } + } + 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")) + // Legacy / shared location endpoints. create := http.HandlerFunc(h.CreateLocation) read := http.HandlerFunc(h.GetLocation) @@ -95,15 +158,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("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 +178,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 +806,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) } @@ -794,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) } @@ -812,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) } @@ -838,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/order/repository.go b/core/internal/order/repository.go index 54131519..2f1c1a51 100644 --- a/core/internal/order/repository.go +++ b/core/internal/order/repository.go @@ -79,6 +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, 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) @@ -911,6 +916,29 @@ func (r *PostgresRepository) ContactAuthority(ctx context.Context, contactID uui return a, nil } +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, 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, false, fmt.Errorf("failed to check the contact: %w", err) + } + } + 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) { 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..c5239805 100644 --- a/core/internal/order/service.go +++ b/core/internal/order/service.go @@ -186,6 +186,28 @@ 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, 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 + } + 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 !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...) + } + o := Order{} o.ID = uuid.New() o.BranchID = branchID @@ -1529,6 +1551,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 } @@ -1537,6 +1566,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"} @@ -1713,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/parsing/handler.go b/core/internal/parsing/handler.go index b3cb7451..04405fbd 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. +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 { @@ -100,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. 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/portal/service_repo_test.go b/core/internal/portal/service_repo_test.go index b44a50ce..eab4b707 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, *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/pos/handler.go b/core/internal/pos/handler.go index c4b03d63..d8ab4b25 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" @@ -12,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. @@ -66,10 +68,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 +81,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 +117,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 } @@ -137,6 +142,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 } @@ -235,6 +246,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 } @@ -265,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 } @@ -329,6 +353,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 } @@ -513,6 +543,20 @@ 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 + } + // 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/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/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 4299e3cf..0e29315b 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -14,6 +14,36 @@ 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) +} + +// UnknownRegisterError is the refusal a write answers when the register it +// 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). +type UnknownRegisterError struct { + RegisterID string +} + +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) +} + // Repository handles POS data persistence. type Repository interface { CreateTransaction(ctx context.Context, tx *POSTransaction) error @@ -72,10 +102,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) @@ -85,7 +115,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 := ` @@ -268,9 +298,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.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/returns_repository.go b/core/internal/pos/returns_repository.go index b1f3189e..def20ef4 100644 --- a/core/internal/pos/returns_repository.go +++ b/core/internal/pos/returns_repository.go @@ -9,16 +9,17 @@ import ( "fmt" "time" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" "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) @@ -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..1cbc1264 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 } } @@ -625,6 +630,30 @@ 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. 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. 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" for _, offlineTx := range req.Items { diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index 42215df2..9cc947cd 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" ) @@ -33,13 +34,18 @@ 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) 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 } @@ -93,7 +99,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 +108,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 +241,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 +255,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/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/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) 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/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/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/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) + } +} 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/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/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/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/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 $$; diff --git a/core/pkg/audit/audit.go b/core/pkg/audit/audit.go index fa897494..0d60b32e 100644 --- a/core/pkg/audit/audit.go +++ b/core/pkg/audit/audit.go @@ -430,6 +430,56 @@ 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, + }) +} + +// 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.go b/core/pkg/middleware/keybranch.go new file mode 100644 index 00000000..588747ba --- /dev/null +++ b/core/pkg/middleware/keybranch.go @@ -0,0 +1,522 @@ +// 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. 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) { + var body struct { + BranchID uuid.UUID `json:"branch_id"` + } + if err := decodeOneJSON(r, &body); err != nil { + return uuid.Nil, false, err + } + if body.BranchID == uuid.Nil { + return uuid.Nil, false, 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. 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 +// 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 +} + +// 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 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") + 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 errors.Is(err, ErrRowNamesNoBranch) { + return uuid.Nil, true, nil + } + 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) + } + if branch == nil { + return nil, ErrRowNamesNoBranch + } + return branch, 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 +// 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, 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) + }) +} + +// 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 + }) +} + +// 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. 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) { + 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 := "this 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, 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") + } + 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) 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) + } + 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 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) + } + 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 +// 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, error) { + if parsed, err := uuid.Parse(id); err == nil { + return append(out, parsed), nil + } + if required && id != "" { + return nil, errRefused + } + return out, nil + } + 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 + } + if required && len(many) == 0 { + return nil, errRefused + } + out := make([]uuid.UUID, 0, len(many)) + for _, m := range many { + if out, err = collect(m.CustomerID, out); err != nil { + return nil, err + } + } + if required && len(out) != len(many) { + return nil, errRefused + } + 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 == "" { + if required { + return nil, errRefused + } + return nil, nil + } + return collect(one.CustomerID, 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; +// 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 { + 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 + } + 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/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go new file mode 100644 index 00000000..2649a8f4 --- /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/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"}, + "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, "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"}, + "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/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"}, + "GET /api/v1/admin/staff/{id}": {KeyBranchClassRefused, "the staff roster and its module grants 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"}, + "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"}, + "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"}, + "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 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"}, + "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/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"}, + "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/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"}, + "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/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/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/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/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/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; 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"}, + "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"}, + "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"}, + "GET /api/v1/pos/zreports": {KeyBranchClassWalled, "till_z_reports.branch_id; TestKeyBranchPin_PosAndAccountRows"}, + "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); 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"}, + "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); 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)"}, + "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 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"}, + "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, "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); 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; 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)"}, + "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 +// 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_db_test.go b/core/pkg/middleware/keybranch_db_test.go new file mode 100644 index 00000000..26e1bfa1 --- /dev/null +++ b/core/pkg/middleware/keybranch_db_test.go @@ -0,0 +1,118 @@ +// 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" + "strings" + "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") + } +} + +// 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) + } +} diff --git a/core/pkg/middleware/keybranch_shared.go b/core/pkg/middleware/keybranch_shared.go new file mode 100644 index 00000000..bab42d62 --- /dev/null +++ b/core/pkg/middleware/keybranch_shared.go @@ -0,0 +1,213 @@ +// 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. The pin rule + // comes first: a bound key is refused even when it carries the dedicated + // scope. + Refuse bool + Branch uuid.UUID + // 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 + // 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. + 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 +} + +// 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 { + 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 (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 { + 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") + } + 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 + } + 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, RefuseCallerKey: 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 uuid.Nil, holders, 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) +} + +// 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. +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) +} diff --git a/core/pkg/middleware/keybranch_test.go b/core/pkg/middleware/keybranch_test.go new file mode 100644 index 00000000..388014ae --- /dev/null +++ b/core/pkg/middleware/keybranch_test.go @@ -0,0 +1,352 @@ +// 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}, + {"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_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) + } +} + +// 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) + } +} diff --git a/core/pkg/middleware/machinekey.go b/core/pkg/middleware/machinekey.go index bc745f90..41f748f6 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" @@ -486,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 { @@ -507,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 } @@ -861,24 +871,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) } 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 947ab1e3..8d50cabb 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,230 @@ 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 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. + +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 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 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. + +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 batch +register itself, a batch naming another branch's register refused 403, or a +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 +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 +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, +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 (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, 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 +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 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 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 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 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` +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` +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; 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 +values carry the agreement's signature date beside its reference. 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 | +| `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 +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 dealer that needs a key for two branches mints two. 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/crm-activities.md b/docs/modules/crm-activities.md index 1c4c7bb7..eac918d2 100644 --- a/docs/modules/crm-activities.md +++ b/docs/modules/crm-activities.md @@ -120,7 +120,18 @@ 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. 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 ("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. ## ADRs that govern this module diff --git a/docs/modules/customers.md b/docs/modules/customers.md index 4461dba4..c0c87878 100644 --- a/docs/modules/customers.md +++ b/docs/modules/customers.md @@ -166,13 +166,75 @@ 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` 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 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. +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 "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. 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; 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 +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 +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 +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 | +|---|---|---|---| +| `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 | +| `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 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 +names no default stays allowed. On a customer the key's branch +holds alone, all of it is 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/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/drafts.md b/docs/modules/drafts.md index fed9f3bd..79fb7143 100644 --- a/docs/modules/drafts.md +++ b/docs/modules/drafts.md @@ -114,22 +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 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. 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. 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), the stated limits with +their reasons are listed in ADR 0007 section 5.5. An unbound +key behaves as before. ## Routes @@ -756,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/locations.md b/docs/modules/locations.md index d4babd11..1870db07 100644 --- a/docs/modules/locations.md +++ b/docs/modules/locations.md @@ -375,11 +375,36 @@ 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 (`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. +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 +grant body or path is a 403 `forbidden` naming the field, audited as +`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; 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 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 +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/orders.md b/docs/modules/orders.md index 50a37d4f..953a4d07 100644 --- a/docs/modules/orders.md +++ b/docs/modules/orders.md @@ -235,6 +235,18 @@ 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, 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 - [`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..32ced166 100644 --- a/docs/modules/payments.md +++ b/docs/modules/payments.md @@ -423,6 +423,14 @@ 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. 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 - [`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/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/modules/quotes.md b/docs/modules/quotes.md index d6783b07..fa19e999 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -235,6 +235,17 @@ 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, 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 - [`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/modules/tech-admin.md b/docs/modules/tech-admin.md index ff3c3ffc..6fe44af7 100644 --- a/docs/modules/tech-admin.md +++ b/docs/modules/tech-admin.md @@ -111,9 +111,14 @@ there, at mint. (optional, array of text; stored verbatim, grammar validation at the mint is ADR 0007 section 5.3) and `branch_id` (nullable UUID; the branch bound key's pin per ADR 0007 section 5.5, set at mint and never -edited). `AdminCreateKeyResponse` carries `api_key` (the raw key, -`sk_live_` plus 43 URL safe base64 characters) and `key` (the saved -`ApiKey` row, which also carries `branch_id`). +edited). A mint naming no scopes 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, and 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, +which also carries `branch_id`). `AISettings` (`components.schemas.AISettings`) is the AI settings document: a singleton with a revision anchor in `admin_revisions`. diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 69ec7588..dff762a3 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -291,6 +291,15 @@ get an entry. | C4-1b | migration 106 `ap_wire_contract` (operational, no route) | `vendor_invoices` had no revision, number, branch, currency or `amount_open`; `po_id` was a bare UUID column with no foreign key; `vendor_invoice_lines` had no `position`, purchase line, product or freight charge links and a scale 2 unit price; nothing was unique | The C4-1b migration of ADR 0008 section 12: `created_at` NOT NULL, `revision`, `number` (`AP-`, a sequence, backfilled in `(created_at, id)` order, the DEFAULT minting through the writer's transaction), `branch_id` (backfilled from the purchase order's branch when that column exists, else the default branch; a defaults trigger serves raw inserts), `currency` (from the `currency.default` setting), a status CHECK on today's values (lowercase spellings uppercased and unknown values become PENDING, each with a notice; cancel-like values and negative totals become VOIDED, see the review fix row above), `po_id` a real foreign key with orphans set null (notice), `UNIQUE (vendor_id, invoice_number)` after duplicates are suffixed `#2`, `#3` in `(created_at, id)` order (notice), `amount_open` (the total less paid, zero on a voided bill; the review fix row above gives the rule now in force), `gl_entry_id`; the lines gain `position` (backfilled in `(created_at, id)` order), `purchase_order_line_id`, `product_id`, a `unit_price` widened to scale 4 and `po_freight_charge_id` (written from package C's freight rule on; today's lines are not linked, the positional pairing being the defect); the keyset indexes. The down reverses each step, keeps the suffixed duplicates, normalized statuses and nulled orphans (each valid old shape data), and refuses while any unit price is finer than cents | | C4-1b | ap module scope and wiring (ADR 0002) | The AP routes sat behind `RequireRole` only, with no branch middleware, no outbox and no audit log; `SyncVendorInvoice` and `SyncVendorPayment` lived in `internal/gl` | The routes sit behind `scoped("admin", "owner", "finance")` with the branch middleware and the branch guard on the create's payload branch; the service carries the outbox writer, the audit logger and the database as transaction runner, wired in serve as every converted module is; the two broken sync functions are deleted from `internal/gl` (their only caller was AP), and `gl` gains the `2010` account code constant beside the sales postings' | | 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 | +| 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-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 | diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 0432f32f..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 or an empty items list 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; @@ -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; @@ -23883,6 +23883,7 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["NotFound"]; 500: components["responses"]["InternalError"]; }; };