Stop the till and Nearle Daily from sharing accounts

app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.

Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.

Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.

So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.

Two things this surfaced that were not visible before.

A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.

UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.

Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.

All five outlets that stock products now have a Supervisor and a Cashier.

Also moves the loose markdown into docs/, which was already staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-07 11:52:23 +05:30
parent f5e16b54cc
commit c0a7fbc1b1
15 changed files with 508 additions and 32 deletions

View File

@@ -0,0 +1,151 @@
// Proves the till and the Nearle Daily application no longer share accounts.
//
// Read-only. Four claims, each checked against live rows rather than asserted:
//
// 1. a provisioned supervisor can open a closed terminal;
//
// 2. a back-office account cannot, however senior it is;
//
// 3. a till account cannot reach the Nearle Daily application; and
//
// 4. a till account is not listed as though it were an app user.
//
// go run ./scratch/posseparation
package main
import (
"fmt"
"os"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var failures int
func check(claim string, ok bool, detail string) {
mark := "PASS"
if !ok {
mark = "FAIL"
failures++
}
fmt.Printf(" [%s] %s\n %s\n", mark, claim, detail)
}
func main() {
_ = godotenv.Load()
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 {
fmt.Println(err)
os.Exit(1)
}
pos := repositories.NewPosRepository(db)
users := repositories.NewUserRepository(db)
// A real provisioned supervisor, and its password, read back out.
var sup struct {
Userid int
Authname, Password string
Configid, Tenantid, Location int
}
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
COALESCE(configid,0) configid, COALESCE(tenantid,0) tenantid,
COALESCE(locationid,0) location
FROM app_users
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
ORDER BY userid LIMIT 1`, models.PosRoleSupervisor).Scan(&sup)
if sup.Userid == 0 {
fmt.Println("no provisioned supervisor to test with")
os.Exit(1)
}
fmt.Printf("supervisor under test: %d %s (tenant %d, outlet %d)\n\n",
sup.Userid, sup.Authname, sup.Tenantid, sup.Location)
fmt.Println("1. a provisioned supervisor opens a closed terminal")
session, err := pos.PosLogin(models.PosLoginRequest{
Authname: sup.Authname, Password: sup.Password,
})
check("supervisor signs in at the till",
err == nil && session != nil,
fmt.Sprintf("err=%v", err))
if session != nil {
check("and gets the supervisor shell",
session.Canmanagestaff && session.Roleid == models.PosRoleSupervisor,
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
}
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
var backOffice []struct {
Userid int
Authname, Password string
Roleid int
}
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
COALESCE(roleid,0) roleid
FROM app_users
WHERE COALESCE(roleid,0) IN (1,2,3,4,5,6)
AND COALESCE(authname,'') <> '' AND COALESCE(password,'') <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY userid LIMIT 5`).Scan(&backOffice)
for _, b := range backOffice {
_, err := pos.PosLogin(models.PosLoginRequest{
Authname: b.Authname, Password: b.Password,
})
check(fmt.Sprintf("roleid %d (%s) refused at the till", b.Roleid, b.Authname),
err != nil && strings.Contains(err.Error(), "not set up for the till"),
fmt.Sprintf("err=%v", err))
}
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid)
check("applogin lookup does not find the supervisor",
uid == 0,
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid))
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
check("tenant web login does not find the supervisor",
uid2 == 0,
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
check("password-setup lookup does not find the supervisor",
uid3 == 0,
fmt.Sprintf("FindUserID(%s) -> userid %d", sup.Authname, uid3))
fmt.Println("\n4. till accounts are not listed as app users")
list, err := users.GetAllUsers(0, sup.Tenantid, 1, 500, "")
leaked := 0
for _, u := range list {
if u.Roleid == models.PosRoleSupervisor || u.Roleid == models.PosRoleCashier {
leaked++
}
}
check("getallusers hides till accounts",
err == nil && leaked == 0,
fmt.Sprintf("%d of %d rows were till accounts", leaked, len(list)))
// ...but the POS console can still read its own people by asking for them.
sups, err := users.GetAllUsers(models.PosRoleSupervisor, sup.Tenantid, 1, 500, "")
check("asking for role 7 explicitly still works",
err == nil && len(sups) > 0,
fmt.Sprintf("%d supervisor(s) returned", len(sups)))
fmt.Println()
if failures > 0 {
fmt.Printf("%d CHECK(S) FAILED\n", failures)
os.Exit(1)
}
fmt.Println("all checks passed")
}

View File

@@ -29,6 +29,24 @@ import (
"gorm.io/gorm/logger"
)
// newPassword generates a password for a supervisor's till login.
//
// From crypto/rand and printed once, like the PINs. Deliberately not derived
// from the shop's name or id: a credential anybody could guess from the sign
// above the door is not a credential.
func newPassword() string {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
log.Fatalf("generating a password: %v", err)
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
func main() {
mode, tenantID, locationID := "plan", 1087, 1135
if len(os.Args) > 1 {
@@ -78,8 +96,29 @@ func main() {
return
}
// The supervisor gets a username and password as well as a PIN, because a
// PIN cannot open a *closed* terminal — the PIN route requires a session
// that already exists. Without these, an outlet whose only accounts are POS
// accounts has no way in at all: the back-office logins are refused by role
// and the till logins have no password. That deadlock is not hypothetical;
// it is what the first cut of strict mode actually produced.
//
// The cashier deliberately gets neither. They sign on at a terminal a
// supervisor has already opened, so a second password would be one more
// credential to leak for no capability gained.
//
// The username is derived from the outlet rather than from a person, so it
// survives staff turnover. `authname` is not unique in this schema, but
// scoping it to the outlet keeps it unambiguous in practice, and `email` is
// left null on purpose — that column *is* unique, and blank strings collide.
wanted := []models.PosUserRequest{
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
{
Fullname: "Store Supervisor",
Role: "supervisor",
Pin: newPin(),
Authname: fmt.Sprintf("supervisor.%d@pos.nearle.in", locationID),
Password: newPassword(),
},
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
}
for wanted[0].Pin == wanted[1].Pin {
@@ -88,7 +127,11 @@ func main() {
fmt.Println("\nwould create:")
for _, w := range wanted {
fmt.Printf(" %-22s %-12s pin=%s\n", w.Fullname, w.Role, w.Pin)
fmt.Printf(" %-22s %-12s pin=%s", w.Fullname, w.Role, w.Pin)
if w.Authname != "" {
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
}
fmt.Println()
}
if mode != "apply" {
@@ -102,8 +145,12 @@ func main() {
if err != nil {
log.Fatalf("creating %s: %v", w.Fullname, err)
}
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s",
created.Userid, created.Fullname, created.Role, created.Pin)
if w.Authname != "" {
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
}
fmt.Println()
}
// The point of the exercise: does the till now see real staff?

View File

@@ -0,0 +1,128 @@
// Gives every provisioned supervisor a way to open a closed terminal.
//
// A PIN cannot do it: the PIN route requires a session that already exists, so
// an outlet whose only POS accounts are PIN-only has no way in once back-office
// roles are refused. This backfills the username and password for supervisors
// created before that was understood.
//
// Cashiers are deliberately skipped. They sign on at a terminal a supervisor
// has already opened, so a password would be one more credential to leak for no
// capability gained.
//
// go run ./scratch/possupervisorlogin plan
// go run ./scratch/possupervisorlogin apply
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newPassword() string {
// No l/I/O/0/1 — these get read off a screen and typed at a counter.
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
log.Fatalf("generating a password: %v", err)
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = godotenv.Load()
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)
type target struct {
Userid, Tenantid, Locationid int
Fullname, Locationname string
}
var targets []target
db.Raw(`SELECT a.userid, a.tenantid, a.locationid,
TRIM(COALESCE(a.firstname,'')||' '||COALESCE(a.lastname,'')) AS fullname,
COALESCE(l.locationname,'') AS locationname
FROM app_users a
LEFT JOIN tenantlocations l
ON l.locationid = a.locationid AND l.tenantid = a.tenantid
WHERE COALESCE(a.roleid,0) = ?
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
ORDER BY a.userid`, models.PosRoleSupervisor).Scan(&targets)
if len(targets) == 0 {
fmt.Println("Every supervisor already has a till login. Nothing to do.")
return
}
fmt.Printf("supervisors with no way to open a closed terminal: %d\n\n", len(targets))
for _, t := range targets {
authname := fmt.Sprintf("supervisor.%d@pos.nearle.in", t.Locationid)
password := newPassword()
if mode != "apply" {
fmt.Printf(" %-6d %-18s outlet %-6d %-26s -> %s / %s\n",
t.Userid, t.Fullname, t.Locationid, t.Locationname, authname, password)
continue
}
_, err := repo.UpdatePosUser(t.Tenantid, t.Locationid, models.PosUserRequest{
Userid: t.Userid,
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d FAILED: %v\n", t.Userid, err)
continue
}
// Prove it, rather than assert it — the whole point of this tool is that
// a supervisor who cannot sign in is indistinguishable from one who can
// until somebody stands at a counter and tries.
session, err := repo.PosLogin(models.PosLoginRequest{
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d written, but sign-in still fails: %v\n", t.Userid, err)
continue
}
fmt.Printf(" %-6d %-18s outlet %-6d %-26s\n", t.Userid, t.Fullname, t.Locationid, t.Locationname)
fmt.Printf(" login %s / %s\n", authname, password)
fmt.Printf(" opens as %s at %s, can_manage_staff=%v\n",
session.Role, session.Locationname, session.Canmanagestaff)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
}
}