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. It does not yet make handlers derive their // scope from the session instead of from the wire, and it does not validate // `partnerid`, `customerid` or `appuserid`, which are the other scoping ids // some list endpoints accept. Those are the next step, and until they land a // handler that scopes on one of them is still trusting the caller. // 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 OFF, for the same reason POS enforcement does: the console is in // use by real merchants right now, and its sign-in does not yet hand back a // token. Switching enforcement on before the console sends one would lock every // user out of a working product. // // So the order is: this middleware ships, sign-in starts issuing tokens, the // console starts sending them, and `WEB_AUTH_REQUIRED=true` closes the door. // While it is off a token is still VERIFIED when one is sent, and a request // carrying a token for the wrong tenant is still refused — the flag only // decides what happens to a request carrying none. // // This is a temporary state and should be short. An unauthenticated `/web` // surface is the most serious thing in this codebase. func webAuthRequired() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED")), "true") } // 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 }