Muzak logomuzak
v0.2.00
You are reading the 0.1.1 documentation. The current release is 0.2.0. Read this page for it →

Authorization

Authentication answers "who is this". Authorization answers "may they do this". In Muzak the second is a guard, a provider, or a check in the handler, depending on what the answer depends on.

The decision depends onPut it in
The route aloneA guard on the router, declared where it is included
The caller's identity or roleA guard or provider that reads the resolved caller
The specific record being touchedThe handler, after the record is loaded

Authorizing a whole subtree

The clearest place for a subtree decision is where the subtree is mounted.

// The admin router is written without a prefix or a guard. Both are applied
// here, which is what keeps that router reusable and puts the security decision
// somewhere a reviewer will find it.
app.Include(routers.Admin(),
    muzak.WithPrefix("/admin"),
    muzak.WithTags("admin"),
    muzak.WithDependencies(core.GetTokenHeader(settings)),
    muzak.WithResponseDoc(http.StatusForbidden, "The caller is not an administrator"),
)

Guards run in declaration order, outermost first: those declared on the application run before those declared at the include, which run before the route's own. The first guard to return an error stops the chain and produces the response.

app := muzak.New(muzak.AppOptions{Title: settings.AppName},
    // Runs for every route in the application, including the admin subtree.
    muzak.WithDependencies(core.GetQueryToken),
)

Authorizing on a role

Every guard on a request runs before any provider does, whatever scope each was declared on. A guard therefore cannot read a value a provider produced: muzak.From would panic and muzak.TryFrom would report that nothing was resolved.

That is not a limitation to work around. A role check is part of resolving the caller, so it belongs in the provider, and the result is a type that only exists when the check passed.

// Admin is a caller who has been shown to hold the admin role.
//
// Having one is proof of the check, because the only thing that produces one is
// the provider below.
type Admin struct {
    Username string
}

// GetAdmin resolves the caller and insists they are an administrator.
func GetAdmin(ctx *muzak.Context) (Admin, error) {
    user, err := GetCurrentUser(ctx)
    if err != nil {
        return Admin{}, err
    }
    if !slices.Contains(user.Roles, "admin") {
        return Admin{}, muzak.Forbidden("this action requires the admin role")
    }
    return Admin{Username: user.Username}, nil
}

Needs on a router applies to every route beneath it, so the whole subtree is covered by one line.

When a check does not need the caller's identity, a guard is the simpler tool, and it can read anything on the request directly:

// RequireMaintenanceWindow refuses a destructive route outside the window it is
// allowed to run in. It reads nothing a provider produced, so a guard is enough.
func RequireMaintenanceWindow(ctx *muzak.Context) error {
    if !inMaintenanceWindow(time.Now()) {
        return muzak.Forbidden("this action is only available during the maintenance window")
    }
    return nil
}

401 against 403

StatusMeans
401 UnauthorizedNo usable credential was presented, or the one presented could not be verified
403 ForbiddenThe credential was understood, and it is not enough

The distinction matters to a client: a 401 says "sign in", a 403 says "signing in again will not help". Muzak classifies them as unauthorized and forbidden, which is the code a client should branch on rather than the status.

Where the status alone is too coarse, add a classifier of your own:

return muzak.Forbidden("this workspace is on the free plan").
    WithCode("plan_upgrade_required")

Authorizing a specific record

Ownership is not knowable until the record is loaded, so it belongs in the handler.

func UpdateItem(ctx *muzak.Context, in schemas.ItemUpdateIn) (schemas.ItemOut, error) {
    store := muzak.From[*core.ItemStore](ctx)
    user := muzak.From[core.CurrentUser](ctx)

    item, err := store.Get(in.ID)
    if err != nil {
        return schemas.ItemOut{}, asHTTPError(err)
    }
    if item.Owner != user.Username {
        // The same answer a missing item gets. Telling a caller that an item
        // exists but is not theirs turns the endpoint into a way to enumerate
        // other people's identifiers.
        return schemas.ItemOut{}, muzak.NotFound("Item not found")
    }

    updated, err := store.Rename(in.ID, in.Name)
    if err != nil {
        return schemas.ItemOut{}, asHTTPError(err)
    }
    return schemas.ItemOut{ID: updated.ID, Name: updated.Name, Owner: user.Username}, nil
}

Answering 404 rather than 403 for someone else's record is a deliberate choice: a 403 confirms the record exists. Use it where the existence of the record is not itself a secret, and 404 where it is.

Authorization and the response body

The strongest guarantee here is not a check at all. The handler's return type is the response model, so a field a caller must not see cannot be returned by accident: it is not part of the type.

// The store's own type. Adding a field here changes nothing a client can see.
type Item struct {
    ID           string
    Name         string
    Owner        string
    InternalNote string
}

// What a client receives. Owner is present only where the route resolves the
// caller, and InternalNote has no way to be written at all.
type ItemOut struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Owner string `json:"owner,omitzero"`
}

Where two audiences need two shapes, write two output types and two routes rather than one type with a filtering step.

Ordering, once more

For every request:

  1. Middleware.
  2. The rate limit count, unless the policy defers it.
  3. Guards, outermost first.
  4. Providers, in declaration order.
  5. Binding.
  6. Validation.
  7. The handler.

Guards running before validation is what keeps an unauthenticated caller from learning your schema by sending it garbage.

Rate limiting running before the guards is what makes a rejected request still cost the caller something. See Rate Limiting.

Hiding an endpoint is not authorizing it

r.Get("/healthz", handlers.Health, muzak.Hidden(), muzak.SkipRateLimit())

Hidden leaves a route fully routable and merely absent from the document. It is right for a health check or an internal endpoint whose existence is uninteresting; it is not a substitute for a guard.

Testing an authorization rule

func TestAdminActionRequiresTheRole(t *testing.T) {
    client := testclient.New(t, buildApp(), authorizedAs("editor"))

    res := client.Post("/admin/", testclient.JSON(map[string]string{"name": "plumbus"}))

    res.AssertStatus(http.StatusForbidden)
    res.AssertErrorCode(muzak.CodeForbidden)
}

func TestUpdatingSomebodyElsesItem(t *testing.T) {
    client := testclient.New(t, buildApp(), authorizedAs("morty"))

    res := client.Put("/items/ricks-portal-gun", testclient.JSON(map[string]string{"name": "mine now"}))

    res.AssertStatus(http.StatusNotFound)
}

A test per rule is cheap here, because the client exercises the whole chain: middleware, guards, providers, binding and the handler.

Where to go next

Authentication covers producing the identity these rules read, and Safe Defaults covers what the framework refuses without being asked.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.