Documentation
¶
Overview ¶
Package gate authorizes the authenticated user with plain Go rules.
A rule is a func(ctx, U) (bool, error): true allows, false yields ErrForbidden, an error is returned as is. Authorize runs rules in order and stops at the first denial; Require does so as a middleware. There are no combinators and no registry: compose rules with ordinary Go if and ||.
The free functions publish nothing. To observe denials, build a Gate with New and Builder.Events and call its Gate.Authorize and Gate.Require methods, which behave the same.
Wiring ¶
r := router.New()
r.Use(session.Middleware(store))
r.Use(csrf.Middleware())
identity := auth.NewSessionIdentity(loadUser)
r.Use(auth.Middleware(identity.Authenticator()))
r.Post("/login", login) // see package auth
r.With(auth.Require[User]()).Get("/me", me)
policy := &Policy{db: db}
r.With(gate.Require(policy.Admin)).Get("/admin", dashboard)
r.Put("/posts/{id}", update) // checks policy.CanEdit in the handler
Rules ¶
Rules are plain functions, or methods on a struct that holds the database; avoid globals. A rule about one resource closes over its identifier, which is only known inside the handler, so check it there with Authorize:
type Policy struct{ db *database.Database }
func (policy *Policy) Admin(_ context.Context, user User) (bool, error) {
return user.Role == "admin", nil
}
func (policy *Policy) CanEdit(postID int) func(context.Context, User) (bool, error) {
return func(ctx context.Context, user User) (bool, error) {
if user.Role == "admin" {
return true, nil
}
_, err := policy.db.Find[Post](ctx, query.Table("posts", func(statement *query.Builder) {
statement.Where("id", postID)
statement.Where("author_id", user.ID)
}))
if errors.Is(err, database.ErrNoRows) {
return false, nil
}
return err == nil, err
}
}
func update(w http.ResponseWriter, r *http.Request) error {
id, err := request.Param[int](r, "id")
if err != nil {
return err
}
if err := gate.Authorize(r.Context(), policy.CanEdit(id)); err != nil {
return err
}
// ...
}
Resource access belongs in queries ¶
Abilities say what a user may do; which rows they may see is a query concern. Put it in a scope so a handler cannot forget it:
func VisibleTo(user User) query.Scope {
return func(predicate *query.PredicateBuilder) {
predicate.Where("author_id", user.ID)
}
}
post, err := db.Find[Post](ctx, query.Table("posts", func(statement *query.Builder) {
statement.Where("id", id)
statement.Scope(VisibleTo(user))
}))
Invariants need conditional writes ¶
A check followed by a write races with other requests. Make the write conditional, and lock the parent row when the invariant spans rows (lock the group row FOR UPDATE inside a transaction):
n, err := db.Exec(ctx, query.Raw(
`DELETE FROM members WHERE group_id = ? AND user_id = ?
AND EXISTS (SELECT 1 FROM members WHERE group_id = ? AND user_id <> ?)`,
groupID, userID, groupID, userID))
if err == nil && n == 0 {
return problem.Conflict() // last member
}
Events ¶
With Builder.Events, a Gate publishes Denied ("http.gate.denied") when a rule returns false, with the request method (empty for Gate.Authorize) and the zero-based index of the denying rule. Paths, users, and causes are omitted; event contexts still retain request context values. Clients control the rate of denials, so listeners must be cheap and a rate limiter should sit in front of the gate.
Which status ¶
- 401: no user (auth.ErrUnauthenticated).
- 403: the user is known and the action is not allowed, and the resource's existence is not secret.
- 404: the user must not learn the resource exists; a scoped query that finds no row returns this.
- 409: the request is allowed but conflicts with current state, such as a conditional write affecting 0 rows.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Denied = event.NewTopic[DeniedEvent]("http.gate.denied")
Denied is published when a rule of a Gate returns false and the call therefore fails with ErrForbidden. A rule that returns an error, a missing user, and allowed calls publish nothing. The free functions Authorize and Require publish nothing: only a Gate built with Builder.Events does.
Every denial publishes: there is no deduplication, so a client controls the rate. Listeners must be cheap, and a rate limiter such as the ratelimit middleware should sit in front of the gate.
Example:
unsubscribe, err := events.Listen(gate.Denied, onDenied)
if err != nil {
return err
}
defer unsubscribe()
var ErrForbidden = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/auth/gate:forbidden") details.Title("Forbidden") details.Detail("You do not have permission to perform this action.") details.Status(http.StatusForbidden) })
ErrForbidden is the problem returned when a rule denies the user.
Example:
if err := gate.Authorize(ctx, isAdmin); err != nil {
return err // renders as 403 for non-admins
}
Functions ¶
func Authorize ¶
Authorize checks the rules against the user of type U in ctx. It returns auth.ErrUnauthenticated when there is no user, ErrForbidden at the first rule that returns false, and the error of the first rule that fails. Rules run in order and later ones are skipped after a denial.
Example:
func publish(w http.ResponseWriter, r *http.Request) error {
if err := gate.Authorize(r.Context(), isEditor); err != nil {
return err
}
return nil
}
func Require ¶
Require returns a middleware that runs Authorize with rules before the handler. It panics when no rule is given; use auth.Require to only require sign-in.
Require copies the rule slice before returning, so replacing caller-owned entries cannot change the policy. Functions and their captured state remain shared.
Example:
admin := r.With(gate.Require(isAdmin))
Types ¶
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder configures New before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.
Example:
policy := gate.New(func(access *gate.Builder) {
access.Events(event.New())
})
admin := router.New()
admin.Use(policy.Require(isAdmin))
func (*Builder) Events ¶
Events receives Denied. The default, nil, publishes nothing.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
policy := gate.New(func(access *gate.Builder) {
access.Events(event.New())
})
admin := router.New()
admin.Use(policy.Require(isAdmin))
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc edits one Gate draft synchronously. Callbacks run directly once in order; nil entries panic and expanded nil/empty slices omit edits. Publication detaches the draft's settings while keeping the event bus shared.
Example:
var policy gate.BuilderFunc = func(access *gate.Builder) {
access.Events(events)
}
permissions := gate.New(policy)
r.Use(permissions.Require(isAdmin))
type DeniedEvent ¶
type DeniedEvent struct {
// Method is the request method. It is empty when the denial came from
// [Gate.Authorize], which has no request.
// Custom methods are client-controlled and must not contain secrets.
//
// Example:
// logger.InfoContext(ctx, "access denied", "method", denied.Method)
Method string
// Rule is the zero-based index of the rule that returned false, in the
// order the rules were given.
//
// Example:
// logger.InfoContext(ctx, "access denied", "rule", denied.Rule)
Rule int
}
DeniedEvent identifies the denying rule by position, without a resource path, user, query, or cause. Paths can contain identifiers or credentials. The event context retains request context values; listeners must select safe attributes.
Example:
unsubscribe, err := events.Listen(gate.Denied, func(ctx context.Context, denied gate.DeniedEvent) error {
logger.InfoContext(ctx, "access denied", "method", denied.Method, "rule", denied.Rule)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type Gate ¶
type Gate struct {
// contains filtered or unexported fields
}
Gate runs rules like Authorize and Require and publishes Denied when a rule denies. Create it with New; it is immutable and safe for concurrent use.
Example:
policy := gate.New(func(access *gate.Builder) {
access.Events(app.Events())
})
admin := r.With(policy.Require(isAdmin))
func New ¶
func New(configure ...BuilderFunc) *Gate
New returns a Gate configured by callbacks run directly once in order on one fresh draft. Nil entries panic; omitted or nil/empty expanded slices supply no callbacks. The published settings are independent of retained drafts. New does no I/O and cannot fail through a returned error.
Example:
policy := gate.New(func(access *gate.Builder) {
access.Events(app.Events())
})
admin := router.New()
admin.Use(policy.Require(isAdmin))
func (*Gate) Authorize ¶
func (gate *Gate) Authorize[U any](ctx context.Context, rules ...func(context.Context, U) (bool, error)) error
Authorize works like the function Authorize and also publishes Denied when a rule returns false. The event has no method because there is no request; use Gate.Require when listeners need HTTP method metadata. Neither form publishes resource paths, identities, or rule error causes.
Example:
if err := policy.Authorize(r.Context(), canEdit); err != nil {
return err
}
func (*Gate) Require ¶
Require works like the function Require and also publishes Denied, with the method of the request, when a rule returns false. Resource paths can carry credentials and are omitted. It panics when no rule is given. Like Require, it copies the rule slice before returning; functions and their captured state remain shared.
Example:
admin := r.With(policy.Require(isAdmin))