Step 2 — Products and the admin role
A second middleware, because knowing who somebody is does not tell you what they may do.
- Carry a role in the token and write middleware that requires admin.
- Wire public, logged-in and admin-only routes separately.
- Validate prices as positive integer cents and test all three access levels.
What a role is
A role is a label on a user that decides what they are allowed to do. This shop has two: customer and admin. Adding a product is admin-only; browsing is open to everyone.
The existing middleware answers "who are you" and puts the user id in the context. This step adds a second one that answers "may you do this". It has to run after the first, because it needs to know who the user is before it can check their role.
Carry the role in the token
You could look the role up in the database on every admin request. Putting it in the token avoids that query, at a cost: if you demote an admin, their existing token keeps admin rights until it expires, up to fifteen minutes later.
For this project that is an acceptable trade. For a bank it would not be, and there you would take the extra query.
type Claims struct { Role string `json:"role"` jwt.RegisteredClaims}func (s *Service) issueToken(userID int64, role string) (string, error) { claims := Claims{ Role: role, RegisteredClaims: jwt.RegisteredClaims{ Subject: strconv.FormatInt(userID, 10), IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)}The second middleware
package middlewaretype roleKey struct{}// RequireRole must run AFTER RequireAuth, which is what puts the role in the// context in the first place.func RequireRole(role string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got, ok := r.Context().Value(roleKey{}).(string) if !ok { // no role in the context means RequireAuth did not run httpx.Error(w, 401, "unauthorized") return } if got != role { // we know who they are; they simply may not do this httpx.Error(w, 403, "forbidden") return } next.ServeHTTP(w, r) }) }}Wiring public, private and admin routes
protect := middleware.RequireAuth(cfg.JWTSecret)adminOnly := middleware.RequireRole("admin")// public — anyone can browsemux.HandleFunc("GET /v1/products", productH.List)mux.HandleFunc("GET /v1/products/{id}", productH.Get)// admin only — note the order: authenticate first, then check the rolemux.Handle("POST /v1/products", protect(adminOnly(http.HandlerFunc(productH.Create))))mux.Handle("PATCH /v1/products/{id}", protect(adminOnly(http.HandlerFunc(productH.Update))))The product handler
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var in CreateRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { httpx.Error(w, 400, "invalid json") return } p, err := h.svc.Create(r.Context(), in) if err != nil { httpx.FromError(w, err) return } httpx.JSON(w, 201, p)}Prices arrive as cents, and are validated
func (s *Service) Create(ctx context.Context, req CreateRequest) (*Product, error) { if strings.TrimSpace(req.Name) == "" { return nil, fmt.Errorf("%w: name is required", ErrValidation) } if req.PriceCents < 0 { return nil, fmt.Errorf("%w: price cannot be negative", ErrValidation) } if req.PriceCents > 100_000_000 { return nil, fmt.Errorf("%w: price is unrealistically high", ErrValidation) } if req.Stock < 0 { return nil, fmt.Errorf("%w: stock cannot be negative", ErrValidation) } p := &Product{ Name: strings.TrimSpace(req.Name), PriceCents: req.PriceCents, // the API speaks cents, never 12.99 Stock: req.Stock, Active: true, } return p, s.repo.Create(ctx, p)}Test all three levels
# get two tokensADMIN=$(curl -s -X POST localhost:8080/v1/auth/login \ -d '{"email":"admin@shop.test","password":"password123"}' | jq -r .access_token)CUST=$(curl -s -X POST localhost:8080/v1/auth/login \ -d '{"email":"customer@shop.test","password":"password123"}' | jq -r .access_token)# no token at all → 401curl -si -X POST localhost:8080/v1/products \ -d '{"name":"Mug","price_cents":999,"stock":10}' | head -1# customer token → 403curl -si -X POST localhost:8080/v1/products -H "Authorization: Bearer $CUST" \ -d '{"name":"Mug","price_cents":999,"stock":10}' | head -1# admin token → 201curl -si -X POST localhost:8080/v1/products -H "Authorization: Bearer $ADMIN" \ -d '{"name":"Mug","price_cents":999,"stock":10}' | head -1# browsing needs no token at all → 200curl -si localhost:8080/v1/products | head -1- Authentication and authorisation are separate middlewares, and the order matters.
- 401 means we do not know you. 403 means we do, and the answer is no.
- A role in the token saves a query, at the cost of staying valid until the token expires. Write that trade down where the next developer will see it.
- Validate an upper bound on price, not just a lower one.
- 1Swap the wrapping order to
adminOnly(protect(...))and work out what breaks. - 2Demote an admin in psql and see how long their existing token still works.
- 3Add a third role,
staff, that may edit products but not create them. - 4Try to create a product with a price of
-500and with999999999999.