Authorisation

A role says who someone is, a permission says what they may do, and the check must sit where the data is loaded.

Syntaxfunc loadNote(callerID, noteID string) (Note, error)

Authentication asks who you are. Authorisation asks what you may do. They are separate steps, and the second one is where data actually leaks.

A role is a name attached to a person: viewer, editor, admin. A permission is one action: note.read, note.delete. Checking roles directly scatters policy through the code, because every handler then encodes its own idea of what admin means. Map roles to permissions in one place, then let the code ask a single question: may this role do this exact thing. Changing what editor covers becomes one edit.

Where the check belongs

A permission check at the HTTP edge answers "may this caller delete notes at all". It cannot answer "may this caller delete this note". The second question needs the record, so the check belongs where the record is loaded. Pass the caller's id into the lookup and make ownership part of it, so no code path can return something the caller does not own. In SQL that means the owner sits in the WHERE clause, as in WHERE id = $1 AND owner_id = $2, rather than being compared afterwards.

This is the common bug. A handler that reads an id from the URL and loads it hands any signed-in user everybody else's records, one changed number at a time. A role check does not stop it.

401 or 403

401 means the server does not know who you are: the credential was missing, malformed or expired. 403 means it knows, and the answer is still no. Sending 401 for a permission failure invites a client to retry signing in forever.

Example

Example · go
package main

import (
	"errors"
	"fmt"
)

type note struct {
	ID      string
	OwnerID string
	Body    string
}

var notes = map[string]note{
	"n1": {ID: "n1", OwnerID: "u_42", Body: "shopping list"},
	"n2": {ID: "n2", OwnerID: "u_43", Body: "not yours"},
}

var errNoNote = errors.New("note not found")

// loadNote takes the caller's id and makes it part of the lookup. There is no
// path through this function that returns a note the caller does not own.
func loadNote(callerID, noteID string) (note, error) {
	n, ok := notes[noteID]
	if !ok || n.OwnerID != callerID {
		// The same answer either way, so the reply does not confirm
		// that n2 exists.
		return note{}, errNoNote
	}
	return n, nil
}

func main() {
	n, err := loadNote("u_42", "n1")
	fmt.Println(n.Body, err)

	_, err = loadNote("u_42", "n2")
	fmt.Println("someone else's note:", errors.Is(err, errNoNote))
}

// Output:
// shopping list <nil>
// someone else's note: true

When to use it

  • Stop a signed-in user reading another user's record by editing the id in the URL.
  • Give support staff read access to orders without the ability to issue refunds.
  • Return 403 rather than 401 so a client stops retrying the sign-in flow.
  • Change what the editor role may do in one table instead of across many handlers.

More examples

Roles mapped to permissions, in one place

One table, one function, and handlers that never mention role names. When product asks for a role that may write but not delete, you add a line here and nothing else changes. The two spellings of the lookup are worth knowing both ways: older code predates the slices package and writes the loop by hand. Note what a missing key gives you: reading an absent key from a map returns the zero value, a nil slice here, which contains nothing, so an unrecognised role is denied by default.

Example · go
package main

import (
	"fmt"
	"slices"
)

// permissions is the whole policy. Roles are names, permissions are actions.
var permissions = map[string][]string{
	"viewer": {"note.read"},
	"editor": {"note.read", "note.write"},
	"admin":  {"note.read", "note.write", "note.delete", "user.manage"},
}

// can answers one question, and every caller asks it the same way.
func can(role, perm string) bool {
	return slices.Contains(permissions[role], perm) // slices is Go 1.21
}

// canLonghand is what slices.Contains stands for. Identical result.
func canLonghand(role, perm string) bool {
	for _, p := range permissions[role] {
		if p == perm {
			return true
		}
	}
	return false
}

func main() {
	fmt.Println(can("editor", "note.write"), canLonghand("editor", "note.write"))
	fmt.Println(can("editor", "note.delete"), canLonghand("editor", "note.delete"))

	// An unknown role gives an empty list, not a panic, so the answer is no.
	fmt.Println("unknown role:", can("ghost", "note.read"))
}

// Output:
// true true
// false false
// unknown role: false

A middleware per permission, and the right status code

requirePerm is a function that returns a middleware, so one line declares the rule for a route: requirePerm("note.delete")(handler). The three status codes in the output are the three distinct answers an API owes a client. This is the right place for a coarse check that saves work, and the wrong place for ownership, which still has to happen in the lookup that loads the note.

Example · go
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"slices"
)

type roleKey struct{}

var permissions = map[string][]string{
	"editor": {"note.read", "note.write"},
	"admin":  {"note.read", "note.write", "note.delete"},
}

// requirePerm builds a middleware for one permission. The auth middleware has
// already put the role in the context.
func requirePerm(perm string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			role, ok := r.Context().Value(roleKey{}).(string)
			if !ok {
				http.Error(w, "unauthorised", http.StatusUnauthorized) // no identity
				return
			}
			if !slices.Contains(permissions[role], perm) {
				http.Error(w, "forbidden", http.StatusForbidden) // known, not allowed
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	deleted := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	})
	h := requirePerm("note.delete")(deleted)

	for _, role := range []string{"admin", "editor"} {
		req := httptest.NewRequest("DELETE", "/notes/n1", nil)
		req = req.WithContext(context.WithValue(req.Context(), roleKey{}, role))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		fmt.Println(role, rec.Code)
	}

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("DELETE", "/notes/n1", nil))
	fmt.Println("anonymous", rec.Code)
}

// Output:
// admin 204
// editor 403
// anonymous 401

Return a different shape, not a filtered one

Authorisation is not only about whole records. A list of users shown to an ordinary member should not carry email addresses at all, and hiding a field in the page template leaves it sitting in the JSON for anyone who opens the network tab. Two types make the difference impossible to get wrong by accident. Reach for this on any endpoint whose response differs by who is asking.

Example · go
package main

import "fmt"

type user struct {
	ID    string
	Email string
	Notes int
}

// publicView is the same record with less in it. Deciding what to send beats
// sending everything and trusting the client to hide a field.
type publicView struct {
	ID    string
	Notes int
}

// any is the modern spelling of interface{}; they are the same type.
func viewFor(u user, canSeeEmail bool) any {
	if canSeeEmail {
		return u
	}
	return publicView{ID: u.ID, Notes: u.Notes}
}

func main() {
	u := user{ID: "u_42", Email: "[email protected]", Notes: 3}

	fmt.Printf("%+v\n", viewFor(u, false))
	fmt.Printf("%+v\n", viewFor(u, true))
}

// Output:
// {ID:u_42 Notes:3}
// {ID:u_42 Email:[email protected] Notes:3}

Discussion

  • Be the first to comment on this lesson.