Files
backend_fiesta/scratch/posloginproof/main.go
Suriya 12165d5e58 Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query
or in an ingest batch — and was believed, so one number changed in Settings
read another tenant's catalogue or posted bills into their books. There was no
middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was
read and never used.

Products were never mis-scoped: `resolvePosStore` already derived the tenant
from the location and the catalogue query already filtered on both. The tenant
was never taken from the wire. What was missing was any check that the caller
was entitled to the location they named.

So the outlet now comes *out* of a sign-in rather than going *in* from the
till. `POST /pos/login` authenticates against the same `app_users` rows the web
console uses — one account store, so deactivating a leaver closes both doors —
and answers with the outlets that account may reach, sealed in an HMAC-SHA256
token the terminal cannot edit.

Two checks then guard everything else, in order: the token verifies, and the
outlet named in the request belongs to the token's tenant. The second is the
one that matters — a valid token is a licence to name *your* outlets, not any.

Notes on the awkward parts:

- The guard reads the outlet from the body as well as the query. The two routes
  that write carry `store_id` in a JSON batch and never in the URL, so a
  query-only check would have left exactly the dangerous call unguarded.
- Three spellings of one thing survive — `store_id`, `locationid`,
  `location_id`. All three are read rather than normalised, because renaming
  them breaks terminals already in the field.
- `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers
  against the open endpoints right now and enforcing at deploy would stop every
  one mid-trade. A token is still verified when sent, and a wrong-tenant token
  still refused; the flag only governs requests carrying none.
- `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development
  secret in source is the same as no signature at all.
- `configid` is inferred when the till does not send it, because a person at a
  counter has no way to know theirs. `authname` is not unique in this schema —
  live data has one address twice under one configid — so an ambiguous match is
  refused rather than resolved by LIMIT 1, which could bill into the wrong
  tenant's books.

Verified against live data: 58 accounts across 34 tenants can open a till, an
account pinned to a location resolves to it alone, a tenant-level account gets
all six of its outlets, and a cross-tenant outlet request is refused.

Passwords are still plaintext platform-wide. Flagged at the comparison site;
fixing it is a migration touching every login path, not this endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:38 +05:30

242 lines
7.5 KiB
Go

// Proves POS sign-in against the live database, read-only.
//
// Written because the interesting half of this feature is not the token — that
// has unit tests — but whether the *account model* actually holds up against
// real rows: does a shop's user resolve to the right tenant, does the outlet
// list come back non-empty, and does an account from one tenant get refused at
// another tenant's outlet.
//
// Passwords are read out of the database and handed straight back into the
// login so the happy path can be proven without anyone typing or printing one.
// Nothing here is ever echoed.
//
// go run ./scratch/posloginproof users 1087 # who could open a till
// go run ./scratch/posloginproof login 1087 # sign the first one in
// go run ./scratch/posloginproof cross # refuse another tenant's outlet
package main
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode := "users"
if len(os.Args) > 1 {
mode = os.Args[1]
}
tenantID := 1087
if len(os.Args) > 2 {
tenantID, _ = strconv.Atoi(os.Args[2])
}
_ = godotenv.Load()
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
// Only needed by the service layer; the repository probes below work
// without it. Set a throwaway so `login` can mint.
os.Setenv("POS_TOKEN_SECRET", "scratch-proof-signing-key-not-real")
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatal(err)
}
repo := repositories.NewPosRepository(db)
switch mode {
case "users":
listUsers(db, tenantID)
case "login":
proveLogin(db, repo, tenantID)
case "cross":
proveCrossTenantRefusal(db, repo)
default:
log.Fatalf("unknown mode %q", mode)
}
}
// listUsers shows who could open a till for a tenant, and whether their record
// is complete enough to do it.
func listUsers(db *gorm.DB, tenantID int) {
type row struct {
Userid int
Authname string
Roleid int
Status string
Locationid int
Haspw bool
}
var rows []row
err := db.Raw(`
SELECT userid, COALESCE(authname,'') AS authname, COALESCE(roleid,0) AS roleid,
COALESCE(status,'') AS status, COALESCE(locationid,0) AS locationid,
(COALESCE(password,'') <> '') AS haspw
FROM app_users WHERE tenantid = ? ORDER BY userid`, tenantID).Scan(&rows).Error
if err != nil {
log.Fatal(err)
}
fmt.Printf("tenant %d — %d user(s)\n", tenantID, len(rows))
fmt.Printf("%-8s %-34s %-6s %-10s %-10s %s\n", "userid", "authname", "role", "status", "location", "password set")
fmt.Println(strings.Repeat("-", 92))
for _, r := range rows {
loc := "any"
if r.Locationid > 0 {
loc = strconv.Itoa(r.Locationid)
}
fmt.Printf("%-8d %-34s %-6d %-10s %-10s %v\n",
r.Userid, r.Authname, r.Roleid, r.Status, loc, r.Haspw)
}
var locs []struct {
Locationid int
Locationname string
Status string
}
db.Raw(`SELECT locationid, COALESCE(locationname,'') AS locationname,
COALESCE(status,'') AS status
FROM tenantlocations WHERE tenantid = ? ORDER BY locationid`, tenantID).Scan(&locs)
fmt.Printf("\noutlets: %d\n", len(locs))
for _, l := range locs {
fmt.Printf(" %-8d %-40s %s\n", l.Locationid, l.Locationname, l.Status)
}
}
// proveLogin signs in the tenant's first usable account and reports what the
// session resolved to.
func proveLogin(db *gorm.DB, repo repositories.PosRepository, tenantID int) {
// An explicit account, when the interesting case is a particular one — the
// till's own outlet, or a proprietor who reaches several.
wanted := ""
if len(os.Args) > 3 {
wanted = strings.TrimSpace(os.Args[3])
}
var cred struct {
Authname string
Password string
}
query := `
SELECT COALESCE(authname,'') AS authname, COALESCE(password,'') AS password
FROM app_users
WHERE tenantid = ? AND COALESCE(password,'') <> '' AND COALESCE(authname,'') <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
params := []interface{}{tenantID}
if wanted != "" {
query += ` AND LOWER(authname) = LOWER(?)`
params = append(params, wanted)
}
query += ` ORDER BY userid LIMIT 1`
err := db.Raw(query, params...).Scan(&cred).Error
if err != nil {
log.Fatal(err)
}
if cred.Authname == "" {
log.Fatalf("tenant %d has no active account with a password set", tenantID)
}
fmt.Printf("signing in %s (password read from the database, not printed)\n\n", cred.Authname)
session, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname,
Password: cred.Password,
Terminalid: "T5EDD",
})
if err != nil {
log.Fatalf("REFUSED: %v", err)
}
fmt.Printf(" user %d %s\n", session.Userid, session.Fullname)
fmt.Printf(" tenant %d %s\n", session.Tenantid, session.Tenantname)
fmt.Printf(" store_id %s\n", session.Storeid)
fmt.Printf(" outlet %d %s\n", session.Locationid, session.Locationname)
fmt.Printf(" gstin %s\n", session.Gstin)
fmt.Printf(" outlets %d reachable\n", len(session.Locations))
for _, l := range session.Locations {
fmt.Printf(" %-8d %s\n", l.Locationid, l.Locationname)
}
// The check that matters: a wrong password must be refused, and refused
// with the same message a wrong email gets.
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname, Password: cred.Password + "x",
}); err == nil {
fmt.Println("\n !! a wrong password was ACCEPTED")
} else {
fmt.Printf("\n wrong password refused: %v\n", err)
}
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: "nobody@nowhere.invalid", Password: "whatever",
}); err != nil {
fmt.Printf(" unknown account refused: %v\n", err)
}
}
// proveCrossTenantRefusal is the authorisation test: an account from one tenant
// must not be able to open a till at another tenant's outlet, which is exactly
// what a till could do before by editing one field in Settings.
func proveCrossTenantRefusal(db *gorm.DB, repo repositories.PosRepository) {
var cred struct {
Authname string
Password string
Tenantid int
}
err := db.Raw(`
SELECT COALESCE(a.authname,'') AS authname, COALESCE(a.password,'') AS password, a.tenantid
FROM app_users a
WHERE COALESCE(a.password,'') <> '' AND COALESCE(a.authname,'') <> '' AND a.tenantid > 0
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
ORDER BY a.userid LIMIT 1`).Scan(&cred).Error
if err != nil || cred.Authname == "" {
log.Fatalf("no usable account to test with: %v", err)
}
// Somebody else's outlet.
var foreign int
db.Raw(`SELECT locationid FROM tenantlocations WHERE tenantid <> ? ORDER BY locationid LIMIT 1`,
cred.Tenantid).Scan(&foreign)
if foreign == 0 {
log.Fatal("only one tenant has outlets; nothing to cross")
}
fmt.Printf("account belongs to tenant %d; asking for outlet %d, which does not\n\n",
cred.Tenantid, foreign)
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname, Password: cred.Password, Locationid: foreign,
}); err == nil {
fmt.Println(" !! ACCEPTED — a tenant signed a till into another tenant's outlet")
} else {
fmt.Printf(" refused: %v\n", err)
}
allowed, err := repo.PosLocationAllowed(cred.Tenantid, foreign)
if err != nil {
log.Fatal(err)
}
fmt.Printf(" PosLocationAllowed(tenant %d, outlet %d) = %v (want false)\n",
cred.Tenantid, foreign, allowed)
}