Dev Skills

Step 2 — Products and the admin role

A second middleware, because knowing who somebody is does not tell you what they may do.

Topic 76 of 98 · 10 min to read · 50 min to build
After this topic you can
  • 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.

internal/adapter/auth/jwt.go — add the role claim
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

internal/adapter/handler/http/middleware/role.go — create this file
package middleware
type 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)
})
}
}
401 when we do not know you, 403 when we do and the answer is still no. Two different problems, two different codes.

Wiring public, private and admin routes

cmd/api/main.go
protect := middleware.RequireAuth(cfg.JWTSecret)
adminOnly := middleware.RequireRole("admin")
// public — anyone can browse
mux.HandleFunc("GET /v1/products", productH.List)
mux.HandleFunc("GET /v1/products/{id}", productH.Get)
// admin only — note the order: authenticate first, then check the role
mux.Handle("POST /v1/products",
protect(adminOnly(http.HandlerFunc(productH.Create))))
mux.Handle("PATCH /v1/products/{id}",
protect(adminOnly(http.HandlerFunc(productH.Update))))
In protect(adminOnly(handler)) the outermost wrapper runs first: protect, then adminOnly, then the handler. Getting that order backwards means the role check runs before anyone has been identified.

The product handler

internal/adapter/handler/http/product.go — create this file
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

internal/core/service/product.go — create this file
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)
}
The upper bound catches typos. Without it, a few extra zeros create a product priced in the billions, and it stays in the catalogue until someone notices.

Test all three levels

terminal
# get two tokens
ADMIN=$(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 → 401
curl -si -X POST localhost:8080/v1/products \
-d '{"name":"Mug","price_cents":999,"stock":10}' | head -1
# customer token → 403
curl -si -X POST localhost:8080/v1/products -H "Authorization: Bearer $CUST" \
-d '{"name":"Mug","price_cents":999,"stock":10}' | head -1
# admin token → 201
curl -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 → 200
curl -si localhost:8080/v1/products | head -1
Those four requests cover the feature. Run them every time you touch the middleware.
The short version
  • 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.
Remember: Knowing who somebody is tells you nothing about what they may do. Those are two questions and they deserve two checks.
Build this before moving on
Add the role claim, the role middleware, and product create/update. Then run all four curl checks and confirm you get 401, 403, 201 and 200.
Then try these in your own editor
  1. 1Swap the wrapping order to adminOnly(protect(...)) and work out what breaks.
  2. 2Demote an admin in psql and see how long their existing token still works.
  3. 3Add a third role, staff, that may edit products but not create them.
  4. 4Try to create a product with a price of -500 and with 999999999999.
Official documentation
Check yourselfTake the Project 3 — Small shop test