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.") + } +}