Help us shape the future of Go! Take the 2026 Go Developer Survey

gate

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 7 Imported by: 0

README

Gate

arkoren.dev/core/http/auth/gate authorizes the user stored by auth.Middleware with plain rules of the form func(ctx, U) (bool, error).

After authentication, use your application's policy.IsAdmin rule to protect dashboard on an existing r router:

Import gate from arkoren.dev/core/http/auth/gate. Rules must accept the exact same user type as the authenticator; User and *User do not match.

r.With(gate.Require(policy.IsAdmin)).Get("/admin", dashboard)

For an operation outside route middleware, check your policy.CanEdit(postID) rule before making the protected change:

if err := gate.Authorize(ctx, policy.CanEdit(postID)); err != nil {
	return err
}

No user returns auth.ErrUnauthenticated (401), a denied rule returns gate.ErrForbidden (403), and a rule error is returned as is. Row-level access belongs in query scopes and invariants in conditional writes; see the package documentation.

See the authorization guide and gate API reference.

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

View Source
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()
View Source
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

func Authorize[U any](ctx context.Context, rules ...func(context.Context, U) (bool, error)) error

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

func Require[U any](rules ...func(context.Context, U) (bool, error)) router.Middleware

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

func (access *Builder) Events(bus *event.Bus) *Builder

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

func (gate *Gate) Require[U any](rules ...func(context.Context, U) (bool, error)) router.Middleware

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))

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL