Compare commits
2 Commits
cd2459dbb6
...
c0a7fbc1b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a7fbc1b1 | ||
|
|
f5e16b54cc |
@@ -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
|
accounts carry an id that is not in the table at all — any mapping written on
|
||||||
the terminal would be wrong.
|
the terminal would be wrong.
|
||||||
|
|
||||||
Back-office roles 1–6 also count as supervisors: somebody who already
|
### The till and Nearle Daily do not share accounts
|
||||||
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
|
`app_users` is the only thing the two products have in common. An account
|
||||||
nobody set one, and it grants nothing.
|
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:
|
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 is inactive; contact your administrator`
|
||||||
- `this account has no password set; set one in the web console first`
|
- `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`
|
- `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
|
Live data has the same address twice. We refuse rather than pick one, because
|
||||||
picking wrong means billing into another tenant's books.
|
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
|
### Authenticated routes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
@@ -393,21 +393,37 @@ func PosRoleFromName(name string) int {
|
|||||||
return 0
|
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.
|
// PosRoleCanManageStaff reports whether a role may create and edit till users.
|
||||||
//
|
//
|
||||||
// Supervisors, plus the back office's own admin and manager roles — somebody
|
// Supervisors, and nobody else.
|
||||||
// who can already administer the shop from a browser is not made less
|
|
||||||
// privileged by standing at the counter.
|
|
||||||
//
|
//
|
||||||
// A cashier is never included, and neither is roleid 0. Zero is not a role: it
|
// This used to include the back office's own roles 1 to 6, on the reasoning
|
||||||
// is what an account carries when nobody set one, and live data has riders and
|
// that somebody who can already administer a shop from a browser is not made
|
||||||
// shop accounts sharing it.
|
// 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 {
|
func PosRoleCanManageStaff(roleID int) bool {
|
||||||
switch roleID {
|
return roleID == PosRoleSupervisor
|
||||||
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PosUser is a person who signs in at a till.
|
// PosUser is a person who signs in at a till.
|
||||||
|
|||||||
@@ -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
|
// reaches more than one outlet. It is checked against that set, never trusted
|
||||||
// on its own.
|
// on its own.
|
||||||
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
|
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 {
|
if row.Tenantid <= 0 {
|
||||||
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
|
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.
|
// errPosLoginRejected is the single answer to a bad email and a bad password.
|
||||||
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
|
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
|
// 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
|
// the controller can answer 401 for those and 500 for a database fault without
|
||||||
// matching on message text.
|
// matching on message text.
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
|
|||||||
args = append(args, pin)
|
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 != "" {
|
if password := strings.TrimSpace(req.Password); password != "" {
|
||||||
sets = append(sets, "password = ?")
|
sets = append(sets, "password = ?")
|
||||||
args = append(args, password)
|
args = append(args, password)
|
||||||
|
|||||||
@@ -106,11 +106,32 @@ func TestOnlyRealRolesCanManageStaff(t *testing.T) {
|
|||||||
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
|
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
|
||||||
t.Error("a supervisor was refused staff management, which is their whole purpose")
|
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
|
// A Nearle Daily role is not a POS role. This once granted staff management
|
||||||
// privileged by standing at the counter.
|
// 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} {
|
for _, role := range []int{1, 2, 3, 4, 5, 6} {
|
||||||
if !models.PosRoleCanManageStaff(role) {
|
if models.PosRoleCanManageStaff(role) {
|
||||||
t.Errorf("back-office role %d was refused staff management", 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,7 +325,8 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
FROM app_users a
|
FROM app_users a
|
||||||
INNER JOIN tenantlocations b ON a.locationid = b.locationid
|
INNER JOIN tenantlocations b ON a.locationid = b.locationid
|
||||||
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
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 {
|
if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
|
|||||||
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
||||||
WHERE 1=1`)
|
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 {
|
if roleID != 0 {
|
||||||
queryBuilder.WriteString(" AND a.roleid = ?")
|
queryBuilder.WriteString(" AND a.roleid = ?")
|
||||||
params = append(params, roleID)
|
params = append(params, roleID)
|
||||||
@@ -119,14 +131,16 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
|
|||||||
|
|
||||||
var q string
|
var q string
|
||||||
if user.Authname != "" {
|
if user.Authname != "" {
|
||||||
q = `SELECT a.userid FROM app_users a
|
q = `SELECT a.userid FROM app_users a
|
||||||
WHERE a.authname = ? AND a.configid = ?`
|
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 {
|
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
|
||||||
return models.UserInfo{}, err
|
return models.UserInfo{}, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
q = `SELECT a.userid FROM app_users a
|
q = `SELECT a.userid FROM app_users a
|
||||||
WHERE a.contactno = ? AND a.configid = ?`
|
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 {
|
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
|
||||||
return models.UserInfo{}, err
|
return models.UserInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -159,12 +173,16 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
|
|||||||
var query string
|
var query string
|
||||||
|
|
||||||
if authname != "" {
|
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 {
|
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
|
||||||
return 0, err
|
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
|
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) {
|
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
||||||
var uid int
|
var uid int
|
||||||
var password, status string
|
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)
|
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
||||||
return 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) {
|
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
||||||
var uid int
|
var uid int
|
||||||
var password, status string
|
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)
|
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
||||||
return 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(`
|
query := fmt.Sprintf(`
|
||||||
SELECT userid, password, status, roleid
|
SELECT userid, password, status, roleid
|
||||||
FROM app_users
|
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)
|
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
||||||
|
|
||||||
|
|||||||
214
scratch/poslogins/main.go
Normal file
214
scratch/poslogins/main.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// Reports who can actually sign in at an outlet, and by which of the two ways.
|
||||||
|
//
|
||||||
|
// Read-only. Answers the question a shop asks on day one — "what do I type into
|
||||||
|
// the till?" — by separating the two credentials that exist, because they are
|
||||||
|
// not interchangeable:
|
||||||
|
//
|
||||||
|
// - a password opens a *closed* terminal, and only a supervisor's does
|
||||||
|
// anything useful, because the shell it opens is decided by the account;
|
||||||
|
//
|
||||||
|
// - a PIN switches operator on a terminal that is *already open*, and is
|
||||||
|
// useless on its own.
|
||||||
|
//
|
||||||
|
// go run ./scratch/poslogins 1087 1135
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Userid int
|
||||||
|
Fullname, Authname, Contactno string
|
||||||
|
Password string
|
||||||
|
Pin int64
|
||||||
|
Roleid, Configid int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tenantID, locationID := 1087, 1135
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
tenantID, _ = strconv.Atoi(os.Args[1])
|
||||||
|
locationID, _ = strconv.Atoi(os.Args[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `top` ranks outlets by what a till would actually have to sell, so a demo
|
||||||
|
// is pointed at a shop with a catalogue rather than at one that opens empty.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "top" {
|
||||||
|
type outletRow struct {
|
||||||
|
Tenantid, Locationid int
|
||||||
|
Tenantname, Locname string
|
||||||
|
Products, Withpasswd int
|
||||||
|
}
|
||||||
|
var top []outletRow
|
||||||
|
db.Raw(`
|
||||||
|
SELECT t.tenantid, l.locationid,
|
||||||
|
COALESCE(t.tenantname,'') AS tenantname,
|
||||||
|
COALESCE(l.locationname,'') AS locname,
|
||||||
|
COUNT(DISTINCT a.productid) AS products,
|
||||||
|
(SELECT COUNT(*) FROM app_users u
|
||||||
|
WHERE u.tenantid = t.tenantid
|
||||||
|
AND COALESCE(u.locationid,0) IN (l.locationid, 0)
|
||||||
|
AND COALESCE(u.password,'') <> ''
|
||||||
|
AND LOWER(COALESCE(u.status,'')) <> 'inactive') AS withpasswd
|
||||||
|
FROM tenantlocations l
|
||||||
|
JOIN tenants t ON t.tenantid = l.tenantid
|
||||||
|
JOIN productlocations b ON b.locationid = l.locationid AND b.tenantid = l.tenantid
|
||||||
|
JOIN products a ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
GROUP BY t.tenantid, l.locationid, t.tenantname, l.locationname
|
||||||
|
ORDER BY products DESC LIMIT 400`).Scan(&top)
|
||||||
|
|
||||||
|
fmt.Printf("%-8s %-10s %-22s %-26s %-9s %s\n",
|
||||||
|
"tenant", "outlet", "tenant name", "outlet name", "products", "can sign in")
|
||||||
|
shown := 0
|
||||||
|
for _, o := range top {
|
||||||
|
// Only outlets a person can actually open. A big catalogue behind a
|
||||||
|
// till nobody can sign in to is not a candidate for anything.
|
||||||
|
if o.Withpasswd == 0 || shown >= 12 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shown++
|
||||||
|
fmt.Printf("%-8d %-10d %-22s %-26s %-9d %d\n",
|
||||||
|
o.Tenantid, o.Locationid, trunc(o.Tenantname, 22),
|
||||||
|
trunc(o.Locname, 26), o.Products, o.Withpasswd)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenant, outlet string
|
||||||
|
db.Raw(`SELECT COALESCE(tenantname,'') FROM tenants WHERE tenantid=?`, tenantID).Scan(&tenant)
|
||||||
|
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
|
||||||
|
locationID, tenantID).Scan(&outlet)
|
||||||
|
if outlet == "" {
|
||||||
|
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted the way /pos/catalogue counts them — products joined to this
|
||||||
|
// outlet — rather than per tenant. A tenant with a full catalogue can still
|
||||||
|
// have an outlet that stocks none of it, and that outlet's till opens empty.
|
||||||
|
var products int64
|
||||||
|
db.Raw(`SELECT COUNT(*)
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b
|
||||||
|
ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
WHERE a.tenantid = ? AND b.locationid = ?`, tenantID, locationID).Scan(&products)
|
||||||
|
|
||||||
|
fmt.Printf("tenant %d %s\noutlet %d %s\nproducts stocked at this outlet: %d\n",
|
||||||
|
tenantID, tenant, locationID, outlet, products)
|
||||||
|
|
||||||
|
// Everyone the outlet can see. locationid 0 is a tenant-wide account — a
|
||||||
|
// proprietor who is not pinned to one shop — and those can open any of
|
||||||
|
// their outlets, so they belong in this list too.
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`
|
||||||
|
SELECT userid,
|
||||||
|
TRIM(COALESCE(firstname,'') || ' ' || COALESCE(lastname,'')) AS fullname,
|
||||||
|
COALESCE(authname,'') AS authname,
|
||||||
|
COALESCE(contactno,'') AS contactno,
|
||||||
|
COALESCE(password,'') AS password,
|
||||||
|
COALESCE(pin,0) AS pin,
|
||||||
|
COALESCE(roleid,0) AS roleid,
|
||||||
|
COALESCE(configid,0) AS configid,
|
||||||
|
COALESCE(status,'') AS status
|
||||||
|
FROM app_users
|
||||||
|
WHERE tenantid = ?
|
||||||
|
AND COALESCE(locationid,0) IN (?, 0)
|
||||||
|
AND LOWER(COALESCE(status,'')) <> 'inactive'
|
||||||
|
ORDER BY userid`, tenantID, locationID).Scan(&rows)
|
||||||
|
|
||||||
|
fmt.Printf("\n=== PASSWORD SIGN-IN (POST /v1/pos/login) — opens a closed terminal ===\n")
|
||||||
|
fmt.Printf("%-7s %-24s %-30s %-12s %-6s %s\n",
|
||||||
|
"userid", "name", "authname (the username)", "role", "shell", "password")
|
||||||
|
any := false
|
||||||
|
for _, r := range rows {
|
||||||
|
if strings.TrimSpace(r.Password) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
any = true
|
||||||
|
shell := "cashier"
|
||||||
|
if models.PosRoleCanManageStaff(r.Roleid) {
|
||||||
|
shell = "SUPER"
|
||||||
|
}
|
||||||
|
role := models.PosRoleName(r.Roleid)
|
||||||
|
if role == "" {
|
||||||
|
role = fmt.Sprintf("(roleid %d)", r.Roleid)
|
||||||
|
}
|
||||||
|
id := r.Authname
|
||||||
|
if id == "" {
|
||||||
|
id = r.Contactno + " (phone)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-7d %-24s %-30s %-12s %-6s %s\n",
|
||||||
|
r.Userid, trunc(r.Fullname, 24), trunc(id, 30), role, shell, r.Password)
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
fmt.Println(" (nobody at this outlet has a password — the till cannot be opened)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== PIN SIGN-IN (POST /v1/pos/login/pin) — switches operator, terminal already open ===\n")
|
||||||
|
fmt.Printf("%-7s %-24s %-12s %-6s %s\n", "userid", "name", "role", "shell", "pin")
|
||||||
|
any = false
|
||||||
|
seen := map[int64]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Pin == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.Pin]++
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Pin == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
any = true
|
||||||
|
shell := "cashier"
|
||||||
|
if models.PosRoleCanManageStaff(r.Roleid) {
|
||||||
|
shell = "SUPER"
|
||||||
|
}
|
||||||
|
role := models.PosRoleName(r.Roleid)
|
||||||
|
if role == "" {
|
||||||
|
role = fmt.Sprintf("(roleid %d)", r.Roleid)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
if seen[r.Pin] > 1 {
|
||||||
|
note = " <- DUPLICATE, refused at sign-in"
|
||||||
|
}
|
||||||
|
if r.Pin < 1000 {
|
||||||
|
note = " <- under 4 digits, cannot be typed"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-7d %-24s %-12s %-6s %04d%s\n",
|
||||||
|
r.Userid, trunc(r.Fullname, 24), role, shell, r.Pin, note)
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
fmt.Println(" (nobody at this outlet has a PIN)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
151
scratch/posseparation/main.go
Normal file
151
scratch/posseparation/main.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -29,6 +29,24 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"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() {
|
func main() {
|
||||||
mode, tenantID, locationID := "plan", 1087, 1135
|
mode, tenantID, locationID := "plan", 1087, 1135
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
@@ -78,8 +96,29 @@ func main() {
|
|||||||
return
|
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{
|
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()},
|
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
|
||||||
}
|
}
|
||||||
for wanted[0].Pin == wanted[1].Pin {
|
for wanted[0].Pin == wanted[1].Pin {
|
||||||
@@ -88,7 +127,11 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nwould create:")
|
fmt.Println("\nwould create:")
|
||||||
for _, w := range wanted {
|
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" {
|
if mode != "apply" {
|
||||||
@@ -102,8 +145,12 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("creating %s: %v", w.Fullname, err)
|
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)
|
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?
|
// The point of the exercise: does the till now see real staff?
|
||||||
|
|||||||
128
scratch/possupervisorlogin/main.go
Normal file
128
scratch/possupervisorlogin/main.go
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user