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 on | Put it in |
|---|---|
| The route alone | A guard on the router, declared where it is included |
| The caller's identity or role | A guard or provider that reads the resolved caller |
| The specific record being touched | The 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.
A check declared outside cannot be removed by a declaration of the same type inside. Every
provider along a route's chain runs, outermost first, so an app.Include(admin, muzak.Needs(RequireAdmin))
keeps rejecting non-admins even when the admin router, or a route in it, declares
Needs(CurrentUser) of the same type. Until 0.2.8 the nearer declaration silently replaced the
outer one, and the outer authorization never ran. muzak.From still returns the innermost value.
The same chain, guards and providers alike, protects the files a Frontend or Static mount
serves and the handler a Mount serves: each runs the rate limit, the verifying security
schemes, the guards and every inherited provider of the router it was registered on. Put assets
meant to be public on a router without those options.
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
}
Authorizing on a scope
When the caller is proved by a verifying security scheme, a JWT bearer token or an API key the application checks itself, the scopes a route needs are part of its requirement, and they are checked before anything of the route runs:
app.Include(routers.Admin(),
muzak.WithPrefix("/admin"),
// A token without the admin scope is refused with 403 before any guard,
// provider or handler beneath this router runs.
muzak.WithSecurity(muzak.Require("oidc", "admin")),
)
A credential that is missing or invalid is answered 401, and one that is valid but lacks a
scope 403, whose bearer challenge says insufficient_scope. The verified caller reaches the route as a
*muzak.Claims or a *muzak.APIKeyPrincipal, so a provider that decides on a role, as
GetAdmin does above, can start from it rather than from a header. See
Authentication for declaring the scheme.
401 against 403
| Status | Means |
|---|---|
401 Unauthorized | No usable credential was presented, or the one presented could not be verified |
403 Forbidden | The 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:
- Middleware. The allowed-hosts check, the HTTPS redirect, the CORS policy and the
cross-origin check, each when it is configured, run in front of anything installed with
App.Use; a health probe is answered before all of them. - The rate limit count, unless the policy defers it.
- The route's
Timeoutdeadline starts, when it has one. - The verifying security schemes the route's
WithSecuritynames. - The body a
CaptureBodyroute keeps is read. - Guards, outermost first.
- Providers, in declaration order.
- The rate limit count, if the policy deferred it.
Depfields are filled, then binding.- Validation.
- The handler.
Guards running before validation is what keeps an unauthenticated caller from learning your schema by sending it garbage, and a verifying scheme judged before the body is read is what keeps a caller without a valid credential from making the server buffer one.
Rate limiting running before the guards is what makes a rejected request still cost the caller something. See Rate Limiting, and Dependencies for what follows the handler.
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. The probes a platform sends need no route of your own:
AppOptions.Health answers them ahead of routing, unauthenticated by design and out of the
document, and says no more than whether the service is up unless ReportChecks asks for the
name of each check. See Health Checks.
Nor is a guard a way to hide that a path exists. OPTIONS and 405 answers come from the route
table alone, as net/http.ServeMux answers them, so they skip a route's security schemes,
guards and rate limit:
an unauthenticated client learns which paths exist and which methods they take, and never a
route's data. Middleware installed with App.Use still covers them, so put a guard or a limit
there when the existence of a path is itself a secret.
Percent-encoding cannot route a request around a guard either. Static segments are compared
percent-decoded, so GET /users/%61dmin reaches the guarded /users/admin and is not answered
by a public /users/{id} with id set to admin.
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.