authz

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 5 Imported by: 0

README

authz

A small, application-agnostic authorization library for Go, built on the classic subject–action–object model of access control: every check answers the question

may this subject perform this action on this object?

You write the answers ("policies") as plain Go code; authz gives you a uniform way to register them, invoke them, and turn denials into errors and log records.

What this library is not:

  • It is not authentication. It never verifies who the subject is — that happened earlier, in your session/token middleware. authz starts after you already have a trusted subject value in hand.
  • It is not a rule engine or a policy language. There is no DSL, no database of permissions, no roles framework. A policy is a Go function; if your application has roles, your policy reads them from your own user type.

Requires Go ≥ 1.23 (generics with method-based inference, log/slog).

Core concepts

Term In the API Meaning
Subject the type parameter S Whoever is acting: your user/principal struct. Chosen by your application.
Resource the type parameter R (comparable) The kind of thing being accessed. Used to dispatch to the right policy.
Action the type parameter A (comparable) What the subject wants to do.
Object ObjectID string or a loaded value The specific thing being accessed: recipe #5, document "doc-123".

All three are chosen by your application and are completely opaque to the library: it carries them from the call site to your policies, which receive them already typed — no interface{}, no type assertions. The subject is never inspected and never logged; R and A are only rendered (with fmt.Sprint) in denial errors and log records. Plain string works for both R and A; see Choosing R and A for stricter options.

Quick start

package main

import (
  "context"

  "github.com/4Sigma/authz"
)

type User struct {
  ID   int64
  Role string
}

func main() {
  // Type parameters: resource type, action type, subject type.
  engine := authz.NewEngine[string, string, *User]()

  // The simplest possible policy: a function.
  engine.RegisterFunc("report", func(ctx context.Context, req authz.Request[string, *User]) (bool, error) {
    return req.Subject.Role == "admin", nil
  })

  user := &User{ID: 42, Role: "admin"}
  if err := engine.Require(context.Background(), user, "report", "read", "monthly-2026-07"); err != nil {
    // err is an *authz.UnauthorizedError (matches errors.Is(err, authz.ErrUnauthorized))
    panic(err)
  }
  // authorized — proceed
}

Require returns nil when allowed and an *UnauthorizedError when denied. If you prefer to branch on a boolean, use Check, which returns (bool, error) and reserves the error for failures (unknown resource, loader error), never for denials.

Choosing R and A: how strict do you want to be?

The resource and action types only need to satisfy Go's built-in comparable constraint — "usable with ==" — which is what lets the engine key its internal map by R and your policies switch on A. That leaves the strictness up to you, and there is a ladder:

  1. Plain string — zero ceremony, zero protection. Any typo compiles and fails at runtime (loudly, see below, but still at runtime).

  2. Named string types (type Action string with constants) — documentation, not enforcement. Go's untyped string literals convert implicitly, so engine.Require(ctx, u, resRecipe, "strat", id) still compiles. You get autocompletion and grep-ability; you do not get a compile error on a typo.

  3. Opaque struct types — literal-proof. Define them once, in one canonical package of your application:

    package appz
    
    type Action struct{ name string }
    
    func (a Action) String() string { return a.name }
    
    var (
      ActionRead  = Action{name: "read"}
      ActionStart = Action{name: "start"}
    )
    

    Outside appz nobody can forge an Action: string literals don't convert, the composite literal Action{...} doesn't compile (unexported field), and a typo like appz.ActionStrat is an undeclared identifier. The canonical values are the only ones in circulation.

Notes on opaque types:

  • A struct with only comparable fields is comparable — no method is required for that. The String() method above is unrelated: it is fmt.Stringer, and it is what makes denial errors and log records render the value as start instead of {start} (they use fmt.Sprint).
  • The zero value (Action{}) can still be constructed, but it is harmless: it matches no case in any policy, so it is denied like every unknown action (fail closed).

Type-safe policies: ResourceChecker + ObjectLoader + Wrap

For real resources you usually need the object itself to decide (ownership checks, state checks). Implement two small interfaces:

// The policy: pure decision logic, fully typed.
type DocumentPolicy struct{}

func (DocumentPolicy) Can(ctx context.Context, user *User, action string, doc Document) (bool, error) {
  switch action {
  case "read":
    return doc.IsPublic || doc.OwnerID == user.ID, nil
  case "update", "delete":
    return doc.OwnerID == user.ID, nil
  }
  return false, nil // unknown action → deny
}

// The loader: fetches the object behind a Request's ObjectID.
type DocumentLoader struct{ repo *DocumentRepo }

func (l DocumentLoader) Load(ctx context.Context, id string) (Document, error) {
  docID, err := strconv.ParseInt(id, 10, 64)
  if err != nil {
    return Document{}, err
  }
  return l.repo.GetByID(ctx, docID)
}

and register them together:

engine.Register("document", authz.Wrap(DocumentPolicy{}, DocumentLoader{repo}))

Now engine.Require(ctx, user, "document", "update", "17") loads document 17 through your loader and asks your policy. Loader errors (e.g. not found) pass through unchanged, so the caller can still tell a missing object (→ HTTP 404) apart from a denial (→ HTTP 403).

Why is ObjectID a string? Because that is how identifiers arrive at an application's boundary: URL path segments, message fields, form values. The loader is exactly the place where your application converts it (to int64, UUID, ...). This keeps the engine usable from generic middleware that only sees the URL.

If you don't want to define types, WrapFunc accepts plain functions:

engine.Register("document", authz.WrapFunc(
  func(ctx context.Context, user *User, action string, doc Document) (bool, error) {
    return doc.OwnerID == user.ID, nil
  },
  loader.Load, // any func(ctx, string) (Document, error)
))

Collection-level actions: WrapHybrid, WrapCollectionOnly

Actions like create and list have no object yet. They are expressed as requests with an empty ObjectID and are decided by a CollectionChecker:

type DocumentCollectionPolicy struct{}

func (DocumentCollectionPolicy) CanCollection(ctx context.Context, user *User, action string) (bool, error) {
  switch action {
  case "list":
    return true, nil
  case "create":
    return user.Role != "guest", nil
  }
  return false, nil
}

Register both levels with WrapHybrid:

engine.Register("document", authz.WrapHybrid(
  DocumentPolicy{},           // object-level actions (ObjectID != "")
  DocumentCollectionPolicy{}, // collection-level actions (ObjectID == "")
  DocumentLoader{repo},
))

engine.Require(ctx, user, "document", "create", "") // → collection policy
engine.Require(ctx, user, "document", "update", "17") // → loader + object policy

For resources that only have collection-level actions, use authz.WrapCollectionOnly(policy).

Mismatches are reported honestly instead of being silently denied:

  • an object-level request reaching a checker with no loader path returns ErrMissingObjectID / ErrUnexpectedObjectID;
  • WrapHybrid panics at wiring time if you pass a nil collection checker — a misconfigured resource should fail at startup, not at request time.

Skipping the loader: CheckObject / RequireObject

Inside a service layer you typically already have the object — you loaded it to work on it. Re-loading it just to authorize would be wasteful, so the engine can check a loaded value directly:

func (s *DocumentService) Update(ctx context.Context, user *User, id int64, patch DocumentPatch) error {
  doc, err := s.repo.GetByID(ctx, id)
  if err != nil {
    return err
  }
  if err := s.authz.RequireObject(ctx, user, "document", "update", doc); err != nil {
    return err // *authz.UnauthorizedError
  }
  // ... apply patch
}

This works with every checker built by Wrap, WrapFunc and WrapHybrid (they all implement the ObjectChecker interface). Passing a value of the wrong type returns ErrObjectType; using it on a checker registered with RegisterFunc or WrapCollectionOnly returns ErrNoObjectChecker.

Handling denials

Require/RequireObject return an *UnauthorizedError carrying the request context, so a transport layer can build a meaningful response:

err := engine.Require(ctx, user, "document", "update", "17")

var denied *authz.UnauthorizedError
switch {
case err == nil:
  // allowed
case errors.As(err, &denied):
  // denied.Resource == "document", denied.Action == "update", denied.ObjectID == "17"
  http.Error(w, denied.Error(), http.StatusForbidden)
default:
  // infrastructure failure: unknown resource, loader error, ...
  http.Error(w, "internal error", http.StatusInternalServerError)
}

errors.Is(err, authz.ErrUnauthorized) also matches, for callers that don't need the details.

UnauthorizedError.Resource and .Action are always string, whatever R and A are: the engine renders the typed values with fmt.Sprint when it builds the error. Give your identifier types a String() method to control that rendering.

Error reference
Error Returned by Meaning
*UnauthorizedError Require, RequireObject The policy said no. Matches errors.Is(_, ErrUnauthorized).
ErrNoChecker all check methods No checker registered for that resource kind (see below).
ErrMissingObjectID Wrap-built checkers Object-level checker got a request with an empty ObjectID.
ErrUnexpectedObjectID WrapCollectionOnly Collection-only checker got a request with an ObjectID.
ErrNoObjectChecker CheckObject/RequireObject The registered checker cannot take already-loaded objects.
ErrObjectType CheckObject/RequireObject The loaded object is not the type the checker handles.
(your loader's errors) Check/Require Passed through unchanged — e.g. map "not found" to HTTP 404.

Unknown resources: fail loud or fail closed

By default, checking a resource kind nobody registered returns ErrNoChecker — a loud failure that catches typos and missing wiring during development.

For production you may prefer fail closed: treat unknown resources as plain denials.

engine := authz.NewEngine[string, string, *User](authz.WithDenyUnknownResources())

There is deliberately no fail-open mode (unknown → allow): in an authorization library that is a foot-gun — one typo in a resource name and an endpoint is silently open to everyone.

You can assert your wiring at startup:

for _, r := range []string{"document", "report", "recipe"} {
  if !engine.HasChecker(r) {
    log.Fatalf("authz: no policy registered for %q", r)
  }
}

Logging denials

Pass a *slog.Logger to have every denial from Require/RequireObject logged at Warn level with resource, action and object_id attributes. The subject is never logged (it is your user object; keeping it out of the logs is the library's job, deciding what is loggable is yours).

engine := authz.NewEngine[string, string, *User](authz.WithLogger(slog.Default()))
With zerolog

Applications that log with zerolog can bridge their existing logger through the zerologadapter subpackage, which wraps a *zerolog.Logger as a slog.Handler:

import (
  "log/slog"

  "github.com/4Sigma/authz"
  "github.com/4Sigma/authz/zerologadapter"
)

engine := authz.NewEngine[string, string, *User](
  authz.WithLogger(slog.New(zerologadapter.Handler(&zlogger))),
)

The adapter lives in its own package so that applications not using zerolog never link it. Timestamps come from the wrapped zerolog logger's own configuration (e.g. .Timestamp()); slog groups are flattened into dot-separated keys (req.id).

Configuration

NewEngine takes zero or more ConfigModifier values — functions that each apply one setting; NewEngine folds them, in order, over the default configuration:

Modifier Effect
WithDenyUnknownResources() Unknown resource kinds → plain denial instead of ErrNoChecker.
WithLogger(l *slog.Logger) Log denials from Require/RequireObject at Warn level.

Defaults (no modifiers): unknown resources are loud errors, nothing is logged.

Engine API summary

Method Use when
Register(resource, checker) Wiring a policy at startup (replaces any previous one).
RegisterFunc(resource, fn) Same, for a plain function policy.
Check(ctx, subject, resource, action, objectID) You want a bool and will branch yourself (e.g. to hide a button).
Require(ctx, subject, resource, action, objectID) You want denial-as-error (the common case in handlers).
CheckObject(ctx, subject, resource, action, obj) Same as Check, but the object is already loaded.
RequireObject(ctx, subject, resource, action, obj) Same as Require, but the object is already loaded.
HasChecker(resource) / Resources() Boot-time wiring assertions and diagnostics.

All methods are safe for concurrent use.

Design notes

  • Subject, resource and action are type parameters, not any/string. Your policies receive your user type already typed, and with opaque R/A types a forged identifier is a compile error, not a runtime surprise. How strict to be is the application's choice, not the library's.
  • One type-parameter order everywhere: identifier types first (R, then A), then the subject S, then the object type T — e.g. Engine[R, A, S], Request[A, S], ResourceChecker[A, S, T]. In practice you spell them out only at NewEngine; everything else is inferred.
  • Denials are not errors in Check. Check returns (false, nil) for "no"; errors are reserved for infrastructure problems. Require is the convenience that converts "no" into *UnauthorizedError.
  • Loader errors are yours. The library does not define what "not found" means; whatever your loader returns is what the caller sees.
  • No policy storage, no caching. Policies run on every check. If a policy is expensive, caching belongs inside your loader or policy, where you know the invalidation rules.
  • Zero dependencies in the core package (stdlib only). The optional zerologadapter subpackage is the only place depending on zerolog.

Documentation

Overview

Package authz is a small, application-agnostic authorization layer built on the classic subject–action–object model of access control: "may this subject perform this action on this object?".

All three sides of the question are type parameters chosen by the application. The subject type S is typically its user or principal struct; the resource type R and the action type A are comparable identifier types — plain strings work, opaque struct types make forged literals impossible (see the README, "Choosing R and A"). The package never inspects any of them: it only carries them from the call site to the policies, which receive them already typed.

Basic usage, with strings for both identifier types:

engine := authz.NewEngine[string, string, *User]()
engine.RegisterFunc("document", func(ctx context.Context, req authz.Request[string, *User]) (bool, error) {
	return req.Subject.Role == "admin", nil
})
err := engine.Require(ctx, user, "document", "read", "doc-123")

Type-safe policies with ResourceChecker and an ObjectLoader that fetches the object behind a Request's ObjectID:

type DocumentPolicy struct{}

func (DocumentPolicy) Can(ctx context.Context, user *User, action string, doc Document) (bool, error) {
	switch action {
	case "read":
		return doc.IsPublic || doc.OwnerID == user.ID, nil
	case "update", "delete":
		return doc.OwnerID == user.ID, nil
	}
	return false, nil
}

engine.Register("document", authz.Wrap(DocumentPolicy{}, documentLoader))

For resources that also have collection-level actions (create, list, ...) use WrapHybrid; for resources that only have collection-level actions use WrapCollectionOnly.

When the caller has already loaded the object (the common case inside a service layer), Engine.CheckObject and Engine.RequireObject authorize it directly, skipping the ObjectLoader round-trip. Every checker built with Wrap or WrapHybrid supports this.

Denials returned by Require and RequireObject match errors.Is(err, ErrUnauthorized) and carry the request context as an *UnauthorizedError. Pass a *slog.Logger with WithLogger to have denials logged; applications that log with zerolog can bridge it through the zerologadapter subpackage.

Throughout the package, generic types share one parameter order: identifier types first (R, then A), then the subject S, then the object type T.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoChecker is returned by Check* when no checker is registered for
	// the requested resource kind (unless WithDenyUnknownResources is set).
	ErrNoChecker = errors.New("authz: no policy checker registered for resource")

	// ErrUnauthorized is the errors.Is target for denials returned by
	// Require and RequireObject.
	ErrUnauthorized = errors.New("authz: unauthorized")

	// ErrMissingObjectID is returned when an object-level checker receives a
	// request without an ObjectID.
	ErrMissingObjectID = errors.New("authz: action requires an object ID")

	// ErrUnexpectedObjectID is returned when a collection-only checker
	// receives a request with an ObjectID.
	ErrUnexpectedObjectID = errors.New("authz: collection-only checker received an object ID")

	// ErrNoObjectChecker is returned by CheckObject when the registered
	// checker cannot authorize already-loaded objects.
	ErrNoObjectChecker = errors.New("authz: checker cannot authorize already-loaded objects")

	// ErrObjectType is returned by CheckObject when the object is not of the
	// type the registered checker handles.
	ErrObjectType = errors.New("authz: object type does not match registered checker")
)

Functions

This section is empty.

Types

type CollectionChecker

type CollectionChecker[A comparable, S any] interface {
	CanCollection(ctx context.Context, subject S, action A) (bool, error)
}

CollectionChecker decides collection-level actions (create, list, ...), which have no object.

type CollectionCheckerFunc

type CollectionCheckerFunc[A comparable, S any] func(ctx context.Context, subject S, action A) (bool, error)

CollectionCheckerFunc adapts a function to CollectionChecker.

func (CollectionCheckerFunc[A, S]) CanCollection

func (f CollectionCheckerFunc[A, S]) CanCollection(ctx context.Context, subject S, action A) (bool, error)

type ConfigModifier

type ConfigModifier func(*engineConfig)

A ConfigModifier mutates one setting of an engineConfig. Each With* constructor returns one; NewEngine folds them, in order, over the default configuration.

func WithDenyUnknownResources

func WithDenyUnknownResources() ConfigModifier

WithDenyUnknownResources makes checks on resource kinds with no registered checker return a plain denial (fail closed) instead of ErrNoChecker.

func WithLogger

func WithLogger(l *slog.Logger) ConfigModifier

WithLogger enables Warn-level logging of the denials returned by Require and RequireObject. The subject is never logged.

type Engine

type Engine[R, A comparable, S any] struct {
	// contains filtered or unexported fields
}

Engine dispatches authorization checks to the PolicyChecker registered for each resource kind. The resource type R and the action type A are comparable identifier types chosen by the application: plain strings work, named string types document intent, opaque struct types make forged literals impossible (see the README). The subject type S is the application's user or principal type. The engine never inspects any of them; R and A are rendered with fmt.Sprint only in denial errors and logs, so give them a String method for readable output.

func NewEngine

func NewEngine[R, A comparable, S any](modifiers ...ConfigModifier) *Engine[R, A, S]

func (*Engine[R, A, S]) Check

func (e *Engine[R, A, S]) Check(ctx context.Context, subject S, resource R, action A, objectID string) (bool, error)

Check reports whether subject may perform action on the object of the given resource kind identified by objectID (empty for collection-level actions).

func (*Engine[R, A, S]) CheckObject

func (e *Engine[R, A, S]) CheckObject(ctx context.Context, subject S, resource R, action A, obj any) (bool, error)

CheckObject is Check for an object the caller has already loaded, skipping the ObjectLoader round-trip. The registered checker must implement ObjectChecker (checkers built with Wrap and WrapHybrid do).

func (*Engine[R, A, S]) HasChecker

func (e *Engine[R, A, S]) HasChecker(resource R) bool

HasChecker reports whether a checker is registered for the resource kind, e.g. for boot-time wiring assertions.

func (*Engine[R, A, S]) Register

func (e *Engine[R, A, S]) Register(resource R, checker PolicyChecker[A, S])

Register binds a checker to a resource kind, replacing any previous one.

func (*Engine[R, A, S]) RegisterFunc

func (e *Engine[R, A, S]) RegisterFunc(resource R, fn func(ctx context.Context, req Request[A, S]) (bool, error))

RegisterFunc is Register for a plain function.

func (*Engine[R, A, S]) Require

func (e *Engine[R, A, S]) Require(ctx context.Context, subject S, resource R, action A, objectID string) error

Require is Check returning *UnauthorizedError on denial instead of a bool.

func (*Engine[R, A, S]) RequireObject

func (e *Engine[R, A, S]) RequireObject(ctx context.Context, subject S, resource R, action A, obj any) error

RequireObject is CheckObject returning *UnauthorizedError on denial instead of a bool.

func (*Engine[R, A, S]) Resources

func (e *Engine[R, A, S]) Resources() []R

Resources returns the registered resource kinds, in no particular order.

type ObjectChecker

type ObjectChecker[A comparable, S any] interface {
	CanObject(ctx context.Context, subject S, action A, obj any) (bool, error)
}

ObjectChecker is implemented by checkers that can also decide on an already-loaded object, letting Engine.CheckObject skip the ObjectLoader round-trip. Checkers built with Wrap and WrapHybrid implement it.

type ObjectLoader

type ObjectLoader[T any] interface {
	Load(ctx context.Context, id string) (T, error)
}

ObjectLoader fetches the object a Request's ObjectID refers to, converting the boundary-level string ID to whatever the application uses internally.

type ObjectLoaderFunc

type ObjectLoaderFunc[T any] func(ctx context.Context, id string) (T, error)

ObjectLoaderFunc adapts a function to ObjectLoader.

func (ObjectLoaderFunc[T]) Load

func (f ObjectLoaderFunc[T]) Load(ctx context.Context, id string) (T, error)

type PolicyChecker

type PolicyChecker[A comparable, S any] interface {
	Can(ctx context.Context, req Request[A, S]) (bool, error)
}

PolicyChecker decides authorization requests for one resource kind.

func Wrap

func Wrap[A comparable, S, T any](checker ResourceChecker[A, S, T], loader ObjectLoader[T]) PolicyChecker[A, S]

Wrap builds a PolicyChecker for a resource that only has object-level actions. Loader errors are returned as-is, so callers can tell "object not found" apart from a denial.

func WrapCollectionOnly

func WrapCollectionOnly[A comparable, S any](collection CollectionChecker[A, S]) PolicyChecker[A, S]

WrapCollectionOnly builds a PolicyChecker for a resource that only has collection-level actions.

func WrapFunc

func WrapFunc[A comparable, S, T any](
	checker func(ctx context.Context, subject S, action A, obj T) (bool, error),
	loader func(ctx context.Context, id string) (T, error),
) PolicyChecker[A, S]

WrapFunc is Wrap for plain functions.

func WrapHybrid

func WrapHybrid[A comparable, S, T any](resource ResourceChecker[A, S, T], collection CollectionChecker[A, S], loader ObjectLoader[T]) PolicyChecker[A, S]

WrapHybrid builds a PolicyChecker for a resource with both object-level and collection-level actions: requests without an ObjectID go to collection, the others load the object and go to resource. It panics on a nil collection so that a miswired resource fails at startup rather than silently denying; use Wrap for object-only resources.

type PolicyCheckerFunc

type PolicyCheckerFunc[A comparable, S any] func(ctx context.Context, req Request[A, S]) (bool, error)

PolicyCheckerFunc adapts a function to PolicyChecker.

func (PolicyCheckerFunc[A, S]) Can

func (f PolicyCheckerFunc[A, S]) Can(ctx context.Context, req Request[A, S]) (bool, error)

type Request

type Request[A comparable, S any] struct {
	Subject  S
	Action   A
	ObjectID string
}

Request is a single authorization question: may Subject perform Action on the object identified by ObjectID? The resource kind is not part of the request: a policy is registered for exactly one resource kind, so it already knows which one it is deciding for.

ObjectID is a string because that is how object identifiers arrive at an application's boundary (URL path segments, message fields); the ObjectLoader given to Wrap converts it to whatever the application uses internally. It is empty for collection-level actions such as create or list.

type ResourceChecker

type ResourceChecker[A comparable, S, T any] interface {
	Can(ctx context.Context, subject S, action A, obj T) (bool, error)
}

ResourceChecker decides object-level actions on objects of type T.

type ResourceCheckerFunc

type ResourceCheckerFunc[A comparable, S, T any] func(ctx context.Context, subject S, action A, obj T) (bool, error)

ResourceCheckerFunc adapts a function to ResourceChecker.

func (ResourceCheckerFunc[A, S, T]) Can

func (f ResourceCheckerFunc[A, S, T]) Can(ctx context.Context, subject S, action A, obj T) (bool, error)

type UnauthorizedError

type UnauthorizedError struct {
	Resource string
	Action   string
	ObjectID string // empty for collection-level actions and RequireObject
}

UnauthorizedError is the denial returned by Require and RequireObject. It matches errors.Is(err, ErrUnauthorized) and carries the request context for callers that map denials to transport responses (e.g. HTTP 403 bodies). Resource and Action hold the fmt.Sprint rendering of the typed values the engine was called with; give R and A a String method to control it.

func (*UnauthorizedError) Error

func (e *UnauthorizedError) Error() string

func (*UnauthorizedError) Is

func (e *UnauthorizedError) Is(target error) bool

Directories

Path Synopsis
Package zerologadapter bridges zerolog into log/slog, so applications that log with zerolog can plug their logger into slog-based libraries (such as authz) without a second logging stack.
Package zerologadapter bridges zerolog into log/slog, so applications that log with zerolog can plug their logger into slog-based libraries (such as authz) without a second logging stack.

Jump to

Keyboard shortcuts

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