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
|
||||
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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -120,13 +132,15 @@ 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 = ?`
|
||||
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 = ?`
|
||||
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)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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?
|
||||
|
||||
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