package middleware import ( "encoding/json" "net/http" "os" "strconv" "strings" "time" "nearle/services" "nearle/utils" "github.com/gofiber/fiber/v2" ) // Authorisation for the console. // // The `/web` surface has never had any. The console keeps its login record in // per-tab `sessionStorage` and sends no `Authorization` header, so every // endpoint under `/v1/web` reads `tenantid` off the query string and believes // it. Changing one number in a URL reads another merchant's orders, stock, // staff and takings. // // This is the same hole `posauth.go` was written to close on the POS surface, // and it is closed the same way, in the same order: // // 1. the caller holds a token this server signed, and // 2. the tenant they are naming is the tenant inside that token. // // The second is the one that matters. A valid session is not a licence to name // any tenant — it is a licence to name *your* tenant. // // ── Why this could not wait for the assistant ─────────────────────────────── // // Nearle Buddy answers questions over this same data. Behind REST, reading // another merchant's books takes knowing the endpoints, knowing the fields and // iterating. Behind an assistant it is one sentence — "summarise the top ten // tenants by revenue" — and the model assembles the cross-tenant answer itself, // accurately and helpfully, because the data was in scope. The permission rules // the assistant needs have nothing to stand on until this exists. // // ── What this does NOT yet do ─────────────────────────────────────────────── // // It verifies what a request NAMES: the tenant, and the branch. It does not yet // make handlers derive their scope from the session rather than from the wire. // // It also does not validate `partnerid`, `customerid` or `appuserid`, and that // one is not an oversight — it is blocked. A delivery partner serves several // merchants at once (`insights.ts` records partner 60 answering with deliveries // spanning twelve shops), so scoping a read by partner is a cross-tenant read by // design. Refusing the parameter outright would be wrong: `RiderDrawer` and // `AssignBar` are merchant screens and both send it legitimately, for a partner // assigned to that merchant. // // Closing it properly needs a check this codebase does not have — "is this // partner assigned to this tenant?" — in the shape of `LocationAllowed`, which // answers the same question for branches. Until that exists, a handler scoping // on one of these three is trusting the caller, and the assistant is kept away // from them entirely: no tool accepts any of these as an argument, and the // registry refuses to register one that tries. // WebLocalsKey names where the verified claims are parked for handlers. const WebLocalsKey = "webclaims" // webAuthRequired reports whether a request without a valid token is refused. // // Defaults to ON. It did not always: this shipped defaulting to off, because // the console was live and its sign-in did not yet hand back a token, so // enforcing first would have locked every merchant out of a working product. // // That rollout is finished. Sign-in mints a token, the console sends it on // every call, and it expires cleanly. Leaving the default off after that point // was not caution, it was an open door nobody had got round to shutting — and // it was measured wide open: a `getorders` with no credential at all returned a // real merchant's orders to anyone on the internet. // // ── The way out, if this goes wrong ───────────────────────────────────────── // // `WEB_AUTH_REQUIRED=false` restores the old behaviour, immediately and without // a deploy. That is the escape hatch, and it exists because flipping a default // that can lock people out should always be reversible by one person in one // minute. A token that is SENT is still always verified either way — the flag // only decides what happens to a request carrying none. func webAuthRequired() bool { setting := strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED")) if setting == "" { return true } return !strings.EqualFold(setting, "false") } // publicWebPaths are the endpoints that must work before anybody has a token. // // Sign-in, chiefly: guarding the login route with a session token means nobody // can ever obtain one. Kept as suffixes rather than full paths so the group // prefix can move without silently locking the door. var publicWebPaths = []string{ "/users/applogin", "/users/weblogin", "/tenant/weblogin", // First-password-set runs before a session exists, from a link in the // invitation mail. "/users/setpassword", } func isPublicWebPath(path string) bool { lower := strings.ToLower(path) for _, suffix := range publicWebPaths { if strings.HasSuffix(lower, suffix) { return true } } return false } // webLocationChecker is the only question this middleware asks of the database: // does this tenant own this branch? Narrowed to one method so the guard can be // tested without a database, and so it cannot quietly grow a second dependency. type webLocationChecker interface { LocationAllowed(tenantID, locationID int) (bool, error) } // WebAuth verifies the console session and pins the request to its tenant. func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) } func webAuthWith(locations webLocationChecker) fiber.Handler { return func(c *fiber.Ctx) error { if isPublicWebPath(c.Path()) { return c.Next() } token := webBearerToken(c) if token == "" { if webAuthRequired() { return webUnauthorized(c, "a session token is required; sign in again") } // A console that predates tokens. Allowed through unpinned, which is // exactly the state this middleware exists to end — see // webAuthRequired. return c.Next() } claims, err := utils.ParseWebToken(token, time.Now()) if err != nil { // Always refused, flag or no flag. A token that does not verify is a // stronger signal than no token at all: nothing sends a broken one by // accident. return webUnauthorized(c, err.Error()) } // Nearle's own staff work across every tenant and legitimately name any // of them. Checked once, here, rather than at each test below, so the // exemption is a single visible branch instead of three. if !claims.IsPlatformAccount() { if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid { return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested)) } // A request can also scope by branch alone, naming no tenant at all, // so pinning the tenant is not enough on its own. if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid { allowed, err := locations.LocationAllowed(claims.Tenantid, requested) if err != nil { return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{ "code": http.StatusServiceUnavailable, "status": false, "message": "could not verify branch access", }) } if !allowed { return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested)) } } } c.Locals(WebLocalsKey, claims) return c.Next() } } // webBearerToken reads the session out of the request. // // `Authorization: Bearer …` only. The POS reader next door also accepts // `X-Pos-Token`, because shop routers between a till and this server strip // Authorization headers on plain HTTP and a terminal that cannot authenticate // is a shop that cannot trade. The console has no such problem — it is a // browser on HTTPS — so it gets the one form, and a second accepted header is // a second thing to get wrong. func webBearerToken(c *fiber.Ctx) string { header := strings.TrimSpace(c.Get("Authorization")) if header == "" { return "" } if after, found := strings.CutPrefix(header, "Bearer "); found { return strings.TrimSpace(after) } if !strings.Contains(header, " ") { return header } return "" } // requestedTenant reads the tenant a request is naming, from wherever it put it. // // Query first, because that is where every `/web` list endpoint carries it, then // the body, because the writes do not: `createdeliveries`, `publishproduct` and // the rest post JSON. Checking only the query would leave every call that // CHANGES another tenant's data unguarded, which is the wrong half to skip. func requestedTenant(c *fiber.Ctx) int { for _, key := range []string{"tenantid", "tenant_id"} { if raw := strings.TrimSpace(c.Query(key)); raw != "" { if id, err := strconv.Atoi(raw); err == nil && id > 0 { return id } } } return bodyScopeID(c, "tenantid", "tenant_id") } // requestedWebLocation reads the branch a request is naming. // // Separate from the POS reader's `requestedLocation` because the two surfaces // spell it differently: POS routes use `store_id`, the console uses // `locationid`. Both spellings are read here anyway — a shared endpoint is // cheaper to allow for than to discover. func requestedWebLocation(c *fiber.Ctx) int { for _, key := range []string{"locationid", "location_id", "store_id"} { if raw := strings.TrimSpace(c.Query(key)); raw != "" { if id, err := strconv.Atoi(raw); err == nil && id > 0 { return id } } } return bodyScopeID(c, "locationid", "location_id", "store_id") } // bodyScopeID pulls a scoping id out of a JSON request body. // // Decoded loosely rather than into a request type, on purpose: this runs before // the handler and must not refuse anything the handler would have accepted. A // body that will not parse here is left for the handler to reject with its own // message, and a request shape that changes later must not silently stop being // authorised. // // `c.Body()` returns buffered bytes, so reading here does not consume the // stream the handler goes on to parse. // // An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming // another tenant in its elements is precisely the call worth guarding, and a // probe that only understood objects would wave it through. func bodyScopeID(c *fiber.Ctx, keys ...string) int { body := c.Body() if len(body) == 0 || len(body) > 8<<20 { return 0 } var raw json.RawMessage = body trimmed := strings.TrimLeft(string(body), " \t\r\n") if strings.HasPrefix(trimmed, "[") { var elements []json.RawMessage if err := json.Unmarshal(body, &elements); err != nil { return 0 } for _, element := range elements { if id := scopeIDFromObject(element, keys); id > 0 { return id } } return 0 } return scopeIDFromObject(raw, keys) } func scopeIDFromObject(raw json.RawMessage, keys []string) int { var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil { return 0 } for _, key := range keys { if id := asScopeID(fields[key]); id > 0 { return id } } return 0 } // asScopeID reads an id that may have been sent as a number or as a string. // // Both spellings are on the wire today — the console sends numbers, some app // callers send strings — and a probe that understood only one would return 0 // for the other, which reads as "named no tenant" and waves the request past // the check. func asScopeID(raw json.RawMessage) int { if len(raw) == 0 { return 0 } var number int if err := json.Unmarshal(raw, &number); err == nil { return number } var text string if err := json.Unmarshal(raw, &text); err == nil { if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil { return id } } return 0 } func webUnauthorized(c *fiber.Ctx, message string) error { return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ "code": http.StatusUnauthorized, "status": false, "message": message, }) } func webForbidden(c *fiber.Ctx, message string) error { return c.Status(http.StatusForbidden).JSON(fiber.Map{ "code": http.StatusForbidden, "status": false, "message": message, }) } // WebClaimsFrom returns the verified session on a request, if it carried one. // // The second return distinguishes "no token" from "a token claiming tenant 0", // which is a platform account and a real answer. A handler that treated the two // alike would give an unauthenticated caller the one session that reads // everything. func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) { claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims) return claims, ok }