From c0a7fbc1b1bcf71fe87d707cfd4a6fa06f7e8706 Mon Sep 17 00:00:00 2001 From: Suriya Date: Fri, 7 Aug 2026 11:52:23 +0530 Subject: [PATCH] Stop the till and Nearle Daily from sharing accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../CATALOGUE_IMPORT_INTEGRATION.md | 0 .../MOBILE_ORDER_VERIFICATION.md | 0 POS_API.md => docs/POS_API.md | 0 POS_LOGIN.md => docs/POS_LOGIN.md | 50 +++++- .../POS_TERMINAL_INGEST.md | 0 .../SECURITY_HANDOFF.md | 0 models/pos.go | 38 +++-- repositories/posAuthRepository.go | 23 +++ repositories/posUserRepository.go | 17 ++ repositories/posUserRepository_test.go | 29 +++- repositories/tenantRepository.go | 3 +- repositories/userRepository.go | 48 ++++-- scratch/posseparation/main.go | 151 ++++++++++++++++++ scratch/posstaffsetup/main.go | 53 +++++- scratch/possupervisorlogin/main.go | 128 +++++++++++++++ 15 files changed, 508 insertions(+), 32 deletions(-) rename CATALOGUE_IMPORT_INTEGRATION.md => docs/CATALOGUE_IMPORT_INTEGRATION.md (100%) rename MOBILE_ORDER_VERIFICATION.md => docs/MOBILE_ORDER_VERIFICATION.md (100%) rename POS_API.md => docs/POS_API.md (100%) rename POS_LOGIN.md => docs/POS_LOGIN.md (88%) rename POS_TERMINAL_INGEST.md => docs/POS_TERMINAL_INGEST.md (100%) rename SECURITY_HANDOFF.md => docs/SECURITY_HANDOFF.md (100%) create mode 100644 scratch/posseparation/main.go create mode 100644 scratch/possupervisorlogin/main.go diff --git a/CATALOGUE_IMPORT_INTEGRATION.md b/docs/CATALOGUE_IMPORT_INTEGRATION.md similarity index 100% rename from CATALOGUE_IMPORT_INTEGRATION.md rename to docs/CATALOGUE_IMPORT_INTEGRATION.md diff --git a/MOBILE_ORDER_VERIFICATION.md b/docs/MOBILE_ORDER_VERIFICATION.md similarity index 100% rename from MOBILE_ORDER_VERIFICATION.md rename to docs/MOBILE_ORDER_VERIFICATION.md diff --git a/POS_API.md b/docs/POS_API.md similarity index 100% rename from POS_API.md rename to docs/POS_API.md diff --git a/POS_LOGIN.md b/docs/POS_LOGIN.md similarity index 88% rename from POS_LOGIN.md rename to docs/POS_LOGIN.md index fcccaa9..e2829d4 100644 --- a/POS_LOGIN.md +++ b/docs/POS_LOGIN.md @@ -228,10 +228,45 @@ four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most accounts carry an id that is not in the table at all — any mapping written on the terminal would be wrong. -Back-office roles 1–6 also count as supervisors: somebody who already -administers the shop from a browser is not made less privileged by standing at -the counter. **`role_id` 0 is not a role** — it is what an account carries when -nobody set one, and it grants nothing. +### The till and Nearle Daily do not share accounts + +`app_users` is the only thing the two products have in common. An account +belongs to one or the other, never to both: + +| | Nearle Daily app + console | POS terminal | +|---|---|---| +| roles | `1`–`6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier | +| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** | +| `POST /v1/pos/login` | **403** | yes | +| listed by `/getallusers`, `/getstaffs` | yes | **hidden** | + +A Nearle Daily **Super admin is not the administrator of anybody's POS.** The +back office reaches a till by *provisioning* a Supervisor from the console; it +never becomes one by signing in. + +This was the other way round until it was measured. Roles 1–6 counted as +supervisors, on the reasoning that somebody who already administers a shop from +a browser is not made less privileged by standing at the counter. That handed +till-supervisor powers to **68 live accounts, 59 of them platform Super +admins**, while the actual shop accounts carry `roleid 0` and were refused. + +Both directions are now closed in the queries themselves rather than in a check +each call site has to remember — a till account is not *rejected* by the app +login, it is simply not found. + +**`role_id` 0 is not a role.** It is what an account carries when nobody set +one, 22 live accounts have it including a delivery rider, and it grants nothing +on either side. + +### A Supervisor needs a password, a Cashier does not + +A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a session that +already exists. So a Supervisor is provisioned with an `authname` **and** a +`password` as well as a PIN, and a Cashier gets only a PIN: a cashier signs on +at a counter a Supervisor has already opened, so a second password would be one +more credential to leak for no capability gained. + +Provisioning a Cashier and nobody else leaves an outlet with no way in at all. --- @@ -423,6 +458,7 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o The `403` messages, verbatim: +- `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console` - `this account is inactive; contact your administrator` - `this account has no password set; set one in the web console first` - `this account is not attached to a tenant and cannot open a till` @@ -434,6 +470,12 @@ That last one is real, not theoretical: `authname` is not unique in this schema. Live data has the same address twice. We refuse rather than pick one, because picking wrong means billing into another tenant's books. +The **first** one is the common case now, and it is deliberately specific where a +bad password is deliberately vague. By the time it fires the caller has already +proved the credential, so naming the reason leaks nothing they did not just +demonstrate — and the vague answer would send a shop owner hunting for a +password that was never wrong. + ### Authenticated routes | Code | Meaning | diff --git a/POS_TERMINAL_INGEST.md b/docs/POS_TERMINAL_INGEST.md similarity index 100% rename from POS_TERMINAL_INGEST.md rename to docs/POS_TERMINAL_INGEST.md diff --git a/SECURITY_HANDOFF.md b/docs/SECURITY_HANDOFF.md similarity index 100% rename from SECURITY_HANDOFF.md rename to docs/SECURITY_HANDOFF.md diff --git a/models/pos.go b/models/pos.go index 4f8606e..40aaa08 100644 --- a/models/pos.go +++ b/models/pos.go @@ -393,21 +393,37 @@ func PosRoleFromName(name string) int { return 0 } +// PosRoleEligible reports whether a role may open a till at all. +// +// The terminal and the Nearle Daily application share one `app_users` table, +// and that is the only thing they share. An account belongs to one product or +// the other and never to both: a person who administers a shop from a browser +// does not thereby get a cash drawer, and a cashier does not thereby get the +// back office. +// +// Eligibility is therefore granted explicitly — by provisioning a Supervisor or +// a Cashier from the console — and is never inherited from a back-office role. +// Anything else is refused at sign-in, including roleid 0, which is not a role +// but the absence of one. +func PosRoleEligible(roleID int) bool { + return roleID == PosRoleSupervisor || roleID == PosRoleCashier +} + // PosRoleCanManageStaff reports whether a role may create and edit till users. // -// Supervisors, plus the back office's own admin and manager roles — somebody -// who can already administer the shop from a browser is not made less -// privileged by standing at the counter. +// Supervisors, and nobody else. // -// A cashier is never included, and neither is roleid 0. Zero is not a role: it -// is what an account carries when nobody set one, and live data has riders and -// shop accounts sharing it. +// This used to include the back office's own roles 1 to 6, on the reasoning +// that somebody who can already administer a shop from a browser is not made +// less privileged by standing at the counter. That was wrong, and live data +// showed how wrong: 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. The actual shop accounts carry roleid 0 and were refused. +// +// The back office reaches the till by *provisioning* a supervisor from the +// console, not by becoming one at the counter. func PosRoleCanManageStaff(roleID int) bool { - switch roleID { - case PosRoleSupervisor, 1, 2, 3, 4, 5, 6: - return true - } - return false + return roleID == PosRoleSupervisor } // PosUser is a person who signs in at a till. diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go index a33f53e..85389b8 100644 --- a/repositories/posAuthRepository.go +++ b/repositories/posAuthRepository.go @@ -101,6 +101,18 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession // reaches more than one outlet. It is checked against that set, never trusted // on its own. func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) { + // The till is not the back office, and one account is never both. An + // account reaches a terminal only by having been provisioned for one — + // Supervisor or Cashier, created from the console — and never by carrying a + // Nearle Daily role that happens to sound senior. + // + // Checked here rather than in PosLogin so that the PIN route is covered by + // the same line. Both ways in build their session through this function, and + // a gate on only one of them would be a gate on neither. + if !models.PosRoleEligible(row.Roleid) { + return nil, errPosRoleIneligible + } + if row.Tenantid <= 0 { return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till") } @@ -324,6 +336,17 @@ func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, erro // errPosLoginRejected is the single answer to a bad email and a bad password. var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised") +// errPosRoleIneligible is the answer to a correct credential on an account that +// is not a till account. +// +// Deliberately specific, where a bad password is deliberately vague. By the +// time this fires the caller has already proved the credential, so naming the +// reason leaks nothing they did not just demonstrate — and the vague answer +// would send a shop owner hunting for a password that was never wrong. It +// names the fix, because the fix is somebody else's screen. +var errPosRoleIneligible = fmt.Errorf( + "this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console") + // PosLoginRejected reports whether an error is a failed credential check, so // the controller can answer 401 for those and 500 for a database fault without // matching on message text. diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go index ca82502..1e475d8 100644 --- a/repositories/posUserRepository.go +++ b/repositories/posUserRepository.go @@ -189,6 +189,23 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs args = append(args, pin) } + // The username a supervisor opens a closed terminal with. + // + // Editable because a password on its own is unusable: sign-in matches on + // `authname` or `contactno`, so an account given a password and no username + // cannot be reached by either. This was missing, and the failure was silent + // — the update reported success, wrote the password, dropped the username, + // and the supervisor was refused at the counter with "not recognised". + if authname := strings.TrimSpace(req.Authname); authname != "" { + sets = append(sets, "authname = ?") + args = append(args, authname) + } + + if contactno := strings.TrimSpace(req.Contactno); contactno != "" { + sets = append(sets, "contactno = ?") + args = append(args, contactno) + } + if password := strings.TrimSpace(req.Password); password != "" { sets = append(sets, "password = ?") args = append(args, password) diff --git a/repositories/posUserRepository_test.go b/repositories/posUserRepository_test.go index 24ee422..56dcc7c 100644 --- a/repositories/posUserRepository_test.go +++ b/repositories/posUserRepository_test.go @@ -106,11 +106,32 @@ func TestOnlyRealRolesCanManageStaff(t *testing.T) { if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) { t.Error("a supervisor was refused staff management, which is their whole purpose") } - // Somebody who already administers the shop from a browser is not made less - // privileged by standing at the counter. + // A Nearle Daily role is not a POS role. This once granted staff management + // to 1 through 6, on the reasoning that a browser administrator loses + // nothing by standing at the counter — which handed till-supervisor powers + // to 68 live accounts, 59 of them platform Super admins, not one of them + // anybody's POS administrator. The back office provisions a supervisor; it + // does not become one. for _, role := range []int{1, 2, 3, 4, 5, 6} { - if !models.PosRoleCanManageStaff(role) { - t.Errorf("back-office role %d was refused staff management", role) + if models.PosRoleCanManageStaff(role) { + t.Errorf("back-office role %d was granted till staff management", role) + } + } +} + +// The till and the Nearle Daily application share one table and nothing else. +// Eligibility is provisioned, never inherited. +func TestOnlyPosRolesCanOpenATill(t *testing.T) { + for _, role := range []int{models.PosRoleSupervisor, models.PosRoleCashier} { + if !models.PosRoleEligible(role) { + t.Errorf("POS role %d was refused a till", role) + } + } + // Zero matters most: it is not a role but the absence of one, and 22 live + // accounts carry it, including a delivery rider. + for _, role := range []int{0, 1, 2, 3, 4, 5, 6, 9, 99, -1} { + if models.PosRoleEligible(role) { + t.Errorf("non-POS role %d was allowed to open a till", role) } } } diff --git a/repositories/tenantRepository.go b/repositories/tenantRepository.go index b3c31f7..7d58a4a 100644 --- a/repositories/tenantRepository.go +++ b/repositories/tenantRepository.go @@ -325,7 +325,8 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) { FROM app_users a INNER JOIN tenantlocations b ON a.locationid = b.locationid LEFT JOIN app_roles c ON c.roleid = a.roleid - WHERE a.tenantid = ?` + WHERE a.tenantid = ? + AND COALESCE(a.roleid, 0) NOT IN (7, 8)` if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil { return nil, err diff --git a/repositories/userRepository.go b/repositories/userRepository.go index 39dabb4..e077d0c 100644 --- a/repositories/userRepository.go +++ b/repositories/userRepository.go @@ -55,6 +55,18 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key LEFT JOIN ridershifts c ON a.shiftid = c.shiftid WHERE 1=1`) + // Till accounts are not Nearle Daily users and must not be listed as though + // they were. The two products share this table and nothing else: a cashier + // has no app login, no rider shift and no back-office screen, so a row + // returned here is one every action on the page would fail against. + // + // Asking for 7 or 8 by name still works, so the POS console can read its own + // people through the same endpoint — this hides them from the general list, + // it does not make them unreachable. + if roleID != models.PosRoleSupervisor && roleID != models.PosRoleCashier { + queryBuilder.WriteString(" AND COALESCE(a.roleid, 0) NOT IN (7, 8)") + } + if roleID != 0 { queryBuilder.WriteString(" AND a.roleid = ?") params = append(params, roleID) @@ -119,14 +131,16 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) { var q string if user.Authname != "" { - q = `SELECT a.userid FROM app_users a - WHERE a.authname = ? AND a.configid = ?` + q = `SELECT a.userid FROM app_users a + WHERE a.authname = ? AND a.configid = ? + AND COALESCE(a.roleid, 0) NOT IN (7, 8)` if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil { return models.UserInfo{}, err } } else { - q = `SELECT a.userid FROM app_users a - WHERE a.contactno = ? AND a.configid = ?` + q = `SELECT a.userid FROM app_users a + WHERE a.contactno = ? AND a.configid = ? + AND COALESCE(a.roleid, 0) NOT IN (7, 8)` if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil { return models.UserInfo{}, err } @@ -159,12 +173,16 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i var query string if authname != "" { - query = `SELECT a.userid FROM app_users a WHERE a.authname = ? AND a.configid = ?` + query = `SELECT a.userid FROM app_users a + WHERE a.authname = ? AND a.configid = ? + AND COALESCE(a.roleid, 0) NOT IN (7, 8)` if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil { return 0, err } } else { - query = `SELECT a.userid FROM app_users a WHERE a.contactno = ? AND a.configid = ?` + query = `SELECT a.userid FROM app_users a + WHERE a.contactno = ? AND a.configid = ? + AND COALESCE(a.roleid, 0) NOT IN (7, 8)` if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil { return 0, err } @@ -189,10 +207,19 @@ func (r *userRepository) UpdateStaff(user models.User) error { return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error } +// A till account is not a Nearle Daily user. The two products share this table +// and nothing else, so every way into the application excludes roles 7 and 8 in +// the lookup itself: a cashier is not "refused", they are simply not found. +// +// Doing it in the query rather than after it is deliberate. A check bolted on +// afterwards has to be repeated at each of these call sites and is one edit away +// from being forgotten at one of them, and that one would be the hole. func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) { var uid int var password, status string - query := `SELECT userid, password, status FROM app_users WHERE authname = ? AND configid = ?` + query := `SELECT userid, password, status FROM app_users + WHERE authname = ? AND configid = ? + AND COALESCE(roleid, 0) NOT IN (7, 8)` r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status) return uid, password, status } @@ -200,7 +227,9 @@ func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) { var uid int var password, status string - query := `SELECT userid, password, status FROM app_users WHERE contactno = ? AND configid = ?` + query := `SELECT userid, password, status FROM app_users + WHERE contactno = ? AND configid = ? + AND COALESCE(roleid, 0) NOT IN (7, 8)` r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status) return uid, password, status } @@ -282,7 +311,8 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s query := fmt.Sprintf(` SELECT userid, password, status, roleid FROM app_users - WHERE %s = ? AND configid = ?`, field) + WHERE %s = ? AND configid = ? + AND COALESCE(roleid, 0) NOT IN (7, 8)`, field) r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid) diff --git a/scratch/posseparation/main.go b/scratch/posseparation/main.go new file mode 100644 index 0000000..a489805 --- /dev/null +++ b/scratch/posseparation/main.go @@ -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") +} diff --git a/scratch/posstaffsetup/main.go b/scratch/posstaffsetup/main.go index b754257..c774278 100644 --- a/scratch/posstaffsetup/main.go +++ b/scratch/posstaffsetup/main.go @@ -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? diff --git a/scratch/possupervisorlogin/main.go b/scratch/possupervisorlogin/main.go new file mode 100644 index 0000000..9d1f214 --- /dev/null +++ b/scratch/possupervisorlogin/main.go @@ -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.") + } +}