A PIN cannot open a closed terminal. The PIN route needs a session that already exists, so a PIN-only account works only while somebody else is standing there to unlock the till first. For a supervisor that was an outright deadlock and was fixed last commit. For a cashier it is subtler and just as wrong: the shop cannot open until two people have arrived, and whoever gets in at seven is as often the cashier as the supervisor. So every till account now gets a username and a password, and the role decides the shell rather than the credential deciding it. A cashier signs in exactly the way a supervisor does and is still held to billing only, because that comes from roleid 8 and not from how they got in. The earlier reasoning — that a second password is one more credential to leak for no capability gained — was measuring the wrong thing. It counted the cost of the credential and not the cost of the shop that cannot open without one. CreatePosUser generates both when the request omits them, so provisioning is one call per person and nobody has to invent a naming scheme. An explicit value always wins. A generated name that collides walks to the next free one, because a second cashier at one counter is ordinary rather than an error; a name the caller supplied is refused instead, because silently signing somebody in as another person's address is worse than a message. Uniqueness is checked against authname and email together, since the insert writes the same value to both and app_users_email_unique would otherwise fail the transaction rather than return something anyone can act on. The password comes back exactly once, in the creation response. Listing till users still reports only has_password, so an admin who loses it reissues rather than looks it up — the right shape even while the column behind it is plaintext. The domain is deliberately unroutable. These are till credentials, never a mailbox, and an address that looks deliverable invites somebody to try sending a reset to it. Verified against live rows by scratch/posseparation, which now checks the cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and comes back can_manage_staff=false. All five outlets that stock products have both accounts, each proved by an actual sign-in. Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was writing the whole SQL statement to stderr on every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
6.0 KiB
Go
180 lines
6.0 KiB
Go
// 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))
|
|
}
|
|
|
|
// The same, for a cashier. A cashier opening a till on their own credentials
|
|
// is the point of this: a shop should not need two people present before it
|
|
// can sell anything.
|
|
var cash struct {
|
|
Userid int
|
|
Authname, Password string
|
|
}
|
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password
|
|
FROM app_users
|
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
|
ORDER BY userid LIMIT 1`, models.PosRoleCashier).Scan(&cash)
|
|
|
|
if cash.Userid == 0 {
|
|
check("a cashier has their own login", false, "no cashier has an authname")
|
|
} else {
|
|
cs, err := pos.PosLogin(models.PosLoginRequest{
|
|
Authname: cash.Authname, Password: cash.Password,
|
|
})
|
|
check(fmt.Sprintf("cashier %s opens a closed terminal alone", cash.Authname),
|
|
err == nil && cs != nil,
|
|
fmt.Sprintf("err=%v", err))
|
|
if cs != nil {
|
|
check("and is held to the billing-only shell",
|
|
!cs.Canmanagestaff && cs.Roleid == models.PosRoleCashier,
|
|
fmt.Sprintf("role=%s can_manage_staff=%v", cs.Role, cs.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")
|
|
}
|