fix: console tenant scoping, miler identity spoofing, delivery proof, timezone

Security
- Express console had no tenant scoping at all: LoginAdmin hardcoded tenantid 0
  into every JWT and none of the 85 admin handlers filtered by tenant, so any
  client given a console login would read every other client's bookings,
  customers, pricing and reports. Adds DoormileAuth.Tenantid (nil = Doormile
  staff, unrestricted; set = client, scoped), emits it in the token, and scopes
  reads, guards writes and pins tenantid on create.
- Miler telemetry (/miler/logs, /miler/status, /miler/consignments/logs) took
  userid from the request body, letting any authenticated rider write another
  rider's status and GPS trail — data the dispatch layer reasons over. Identity
  now comes from the token.
- POST /miler/reset-pin was unauthenticated and overwrote a PIN given only a
  phone number, so reset-pin + verify-pin took over any rider account. Now
  requires admin/manager/executive auth.

Correctness
- Date ranges compared the container's UTC clock against timestamps the DB
  writes as IST wall-clock (DSN sets TimeZone=Asia/Kolkata), so "today so far"
  ended 5h30m in the past and silently dropped everything created after noon
  IST from every report. Sets TZ in the image and adds utils.DBNow/DBToday,
  which stay correct regardless of container timezone.
- CreateMiler never set Configid, so console-created riders got the column
  default of 1 while LoginMiler looks up configid 1001 — every such rider was
  unable to log in, reported as "no miler account found".
- Delivery wrote no consignment history row, so a tracking timeline never
  showed the parcel arriving.

Features
- Delivery OTP is now real (crypto/rand, issued to the receiver, verified and
  cleared on delivery) but opt-in per client via Tenant.Requiredeliveryotp,
  defaulting off — friction worth it for a courier parcel, not a food order.
- Express bookings accept pickuplocationid, so the console can name a client
  site (a DailyGrubs kitchen) instead of retyping its address; validated
  against the tenant and carried through to the consignment.
- TenantLocation.Locationname, miler tenantid/hubid, Nagercoil (629) opened.
- PUT /miler/availability accepts both "status" and "availabilitystatus", and
  /miler/location no longer drops speed/heading — both were contract
  mismatches against the doc the Flutter dev was given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-05 18:16:56 +05:30
parent e1fd4dc5d0
commit fd7cf3e35e
14 changed files with 614 additions and 184 deletions

View File

@@ -293,7 +293,8 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if req.Status == "" {
status := req.ResolvedStatus()
if status == "" {
return utils.BadRequest(c, "status is required")
}
@@ -302,7 +303,7 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
return utils.NotFound(c, "miler profile not found")
}
profile.Availabilitystatus = req.Status
profile.Availabilitystatus = status
profile.Updatedat = time.Now()
if err := db.DB.Save(&profile).Error; err != nil {
return utils.Internal(c, "failed to update availability")
@@ -796,9 +797,13 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentTenantID = *booking.Tenantid
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,
@@ -829,6 +834,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
// A hyperlocal parcel goes straight out for delivery, so its receiver OTP has
// to exist before this transaction commits. Anything routed via a hub gets
// its OTP when it actually leaves for the final mile instead. Only issued
// for clients that ask for it — see Tenant.Requiredeliveryotp.
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
}
if err := tx.Create(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
@@ -865,15 +881,18 @@ func BookingPickupComplete(c *fiber.Ctx) error {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Picked Up",
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
// The OTP goes to the receiver and only the receiver — the rider has to
// be told it at the door, which is what makes it proof of handover.
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
@@ -919,6 +938,11 @@ func CreateMilerPeriodicLog(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
// The rider's identity comes from their token, never the body. Trusting a
// client-supplied userid let any authenticated miler write another miler's
// GPS trail, which feeds the location data dispatch reasons over.
log.UserID = c.Locals("userid").(int)
t, err := time.Parse("2006-01-02 15:04:05", log.LogDate)
if err != nil {
return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS")
@@ -987,8 +1011,12 @@ func CreateMilerStatus(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if status.UserID == 0 || status.Status == "" {
return utils.BadRequest(c, "userid and status are required")
// Identity from the token, not the body — otherwise one rider can set
// another rider's live status.
status.UserID = c.Locals("userid").(int)
if status.Status == "" {
return utils.BadRequest(c, "status is required")
}
key := fmt.Sprintf("miler_status:%d", status.UserID)
@@ -1088,10 +1116,16 @@ func PublishConsignmentLogs(c *fiber.Ctx) error {
return utils.Internal(c, "cache service unavailable")
}
milerUserID := c.Locals("userid").(int)
pipe := db.Rdb.TxPipeline()
tx := db.DB.Begin()
for _, item := range input {
// Same rule as the other telemetry writers: the token owns the identity,
// so a batch can't be attributed to some other rider.
item.UserID = milerUserID
logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate)
if err != nil {
logTime = time.Now()