pundit

package module
v0.0.0-...-19df78d Latest Latest
Warning

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

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 2 Imported by: 0

README

go-ruby-pundit/pundit

pundit — go-ruby-pundit

Docs License Go Coverage

A pure-Go (no cgo) model of the authorization ENGINE of Ruby's Pundit gem — faithful to the observable behaviour of Pundit 2.x on MRI 4.0.5. It owns the parts of Pundit that are pure logic: resolving a record to its policy and scope class names (PolicyFinder), the authorize / policy / policy_scope protocol and its return contract, and the Pundit::Error hierarchy — without any Ruby runtime. The Ruby-specific work of actually invoking a policy predicate or running a scope is delegated through injectable seams.

It is the pundit backend for go-embedded-ruby, but is a standalone, reusable module with no dependency on the Ruby runtime — a sibling of go-ruby-set and go-ruby-connection-pool.

MRI-faithful, not Composition-Oriented. Every naming and protocol decision matches what MRI 4.0.5 + pundit does, verified by a differential oracle that runs the real gem side by side with this package.

The engine owns resolution + protocol; Ruby method bodies are the seam

Pundit's job splits cleanly. The parts that are pure Go here:

  • Policy resolution. PolicyFinder maps a record to a policy class name (PostPostPolicy) and a scope class name (PostPolicy::Scope), with the gem's exact inflection: namespaced arrays ([:admin, post]Admin::PostPolicy), symbols (:blog_postBlogPostPolicy), classes, model_name (ActiveModel) and policy_class overrides.
  • Protocol. authorize resolves the policy, checks the query, and — on success — returns the record (Pundit's contract); on a false result it raises the modeled NotAuthorizedError. policy_scope resolves and runs the scope.
  • Errors. NotAuthorizedError, NotDefinedError, AuthorizationNotPerformedError (and PolicyScopingNotPerformedError).

The one Ruby-specific part — running a policy's predicate method or a scope's resolve — is a seam the host plugs in. The go-embedded-ruby binding supplies Ruby send here, so include Pundit::Authorization behaves exactly like MRI.

// Call a policy predicate (e.g. "update?") on the named policy class.
type Dispatch func(policyClass, query string, user, record any) (bool, error)

// Run a scope class's resolve for user over scope.
type ResolveScope func(scopeClass string, user, scope any) (any, error)

// Model safe_constantize: does a policy/scope class of this name exist?
type Defined func(className string) bool

Subjects are reflective views, not Go values

A record cannot be a bare Go value because Pundit's resolution asks Ruby-specific questions of it (is it an Array? a Symbol? does it respond to model_name or policy_class?). A Subject captures exactly those answers, so the naming logic stays pure Go. The host builds one by reflecting on a real Ruby object; Go callers use the constructors:

pundit.Object("Post")                          // a Post instance          -> PostPolicy
pundit.Class("Post")                           // the Post class           -> PostPolicy
pundit.Symbol("blog_post")                     // :blog_post               -> BlogPostPolicy
pundit.Array(pundit.Symbol("admin"),
             pundit.Object("Post"))            // [:admin, post]           -> Admin::PostPolicy
pundit.Subject{ModelName: "Post"}              // responds to :model_name  -> PostPolicy
pundit.Subject{PolicyClass: "CustomPolicy"}    // responds to :policy_class -> CustomPolicy

Install

go get github.com/go-ruby-pundit/pundit

Usage

package main

import (
	"fmt"

	"github.com/go-ruby-pundit/pundit"
)

func main() {
	// The host plugs Ruby method dispatch into the seam. Here a Go fake stands
	// in: alice may update? a Post but not destroy? it.
	e := &pundit.Engine{
		Dispatch: func(policyClass, query string, user, record any) (bool, error) {
			return query == "update?", nil
		},
		ResolveScope: func(scopeClass string, user, scope any) (any, error) {
			return []string{"visible post"}, nil
		},
	}

	// authorize returns the record on success (Pundit's contract) ...
	rec, err := e.Authorize("alice", pundit.Object("Post"), "update?")
	fmt.Println(rec.Name, err) // Post <nil>

	// ... and a *NotAuthorizedError on a false query.
	_, err = e.Authorize("alice", pundit.Object("Post"), "destroy?")
	fmt.Println(err) // not allowed to PostPolicy#destroy? this Post

	// policy_scope resolves PostPolicy::Scope and runs it via the seam.
	scope, _ := e.PolicyScope("alice", pundit.Object("Post"))
	fmt.Println(scope) // [visible post]

	// Namespaced lookups: [:admin, post] -> Admin::PostPolicy.
	fmt.Println(pundit.NewPolicyFinder(
		pundit.Array(pundit.Symbol("admin"), pundit.Object("Post"))).PolicyName())
}

API

// Resolution (pure naming — Pundit::PolicyFinder)
func NewPolicyFinder(object Subject) *PolicyFinder
func (f *PolicyFinder) PolicyName() string   // "PostPolicy"
func (f *PolicyFinder) ScopeName() string    // "PostPolicy::Scope"

// Subjects
func Object(className string) Subject
func Class(name string) Subject
func Symbol(text string) Subject
func Array(elems ...Subject) Subject

// Seams
type Dispatch     func(policyClass, query string, user, record any) (bool, error)
type ResolveScope func(scopeClass string, user, scope any) (any, error)
type Defined      func(className string) bool

// Engine (Pundit protocol)
type Engine struct { Dispatch; ResolveScope; Defined }
func (e *Engine) Policy(record Subject) (string, bool)        // policy   (nil if undefined)
func (e *Engine) PolicyBang(record Subject) (string, error)  // policy!  (NotDefinedError)
func (e *Engine) Scope(record Subject) (string, bool)        // scope
func (e *Engine) ScopeBang(record Subject) (string, error)   // scope!
func (e *Engine) Authorize(user any, record Subject, query string) (Subject, error)
func (e *Engine) PolicyScope(user any, scope Subject) (any, error)       // nil if undefined
func (e *Engine) PolicyScopeBang(user any, scope Subject) (any, error)   // NotDefinedError

// Controller-hook helpers
func VerifyAuthorized(performed bool) error   // AuthorizationNotPerformedError
func VerifyPolicyScoped(performed bool) error // PolicyScopingNotPerformedError

// Errors (the Pundit::Error subtree)
type NotAuthorizedError struct { Query string; Record Subject; Policy string; Msg string }
type NotDefinedError struct { Msg string }
type AuthorizationNotPerformedError struct { Msg string }
type PolicyScopingNotPerformedError struct { AuthorizationNotPerformedError } // is-a AuthorizationNotPerformed

Authorize returns the record (the pundit_model — the last element of a namespaced Array subject, or the record itself), matching Pundit's contract of always returning the passed object on success. Policy / Scope return ("", false) when the class is undefined (mirroring safe_constantize returning nil); the bang variants return a *NotDefinedError instead.

Fidelity basis

The naming is verified byte-for-byte against the real gem (pundit 2.5.2 on MRI 4.0.5) by oracle_test.go: the derived policy/scope names for instances, classes, symbols (underscore and slash inflection), and namespaced arrays are compared to Pundit::PolicyFinder#find; the NotAuthorizedError message is compared to a real failing Pundit.authorize (for both an instance and a Class record, which render differently); and the NotDefinedError name portion is compared to policy! / scope!. The gem interpolates object.inspect (an unstable object address) into the not-defined message — this engine models the naming, not Ruby's inspect, so it renders the record by class name and the oracle compares only the stable, derived portion.

Tests & coverage

The deterministic, gem-free tests alone hold coverage at 100% (so the qemu cross-arch and Windows lanes pass the gate), paired with the differential MRI oracle that skips itself where the pundit gem is absent.

COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 100.0%

CGO-free, dependency-free, gofmt + go vet clean, and green across the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x) and three OSes (Linux, macOS, Windows).

License

BSD-3-Clause — see LICENSE. Copyright the go-ruby-pundit/pundit authors.

WebAssembly

Being pure Go (CGO=0), this library also compiles to WebAssembly — both GOOS=js GOARCH=wasm (browser / Node.js) and GOOS=wasip1 GOARCH=wasm (WASI). CI builds both targets on every push, alongside the six 64-bit native/qemu arches.

GOOS=js     GOARCH=wasm go build ./...   # browser / Node
GOOS=wasip1 GOARCH=wasm go build ./...   # WASI (wasmtime, wasmer, wasmedge, …)

Documentation

Overview

Package pundit is a pure-Go (no cgo) model of the authorization ENGINE of Ruby's Pundit gem, faithful to the observable behaviour of Pundit 2.x on MRI 4.0.5. It owns the parts of Pundit that are pure logic — resolving a record to its policy and scope class names (PolicyFinder), the authorize / policy / policy_scope protocol, and the Pundit::Error hierarchy — while the Ruby-specific work of actually invoking a policy's predicate method or running a scope is delegated through injectable seams. The host (rbgo) plugs its Ruby method dispatch into those seams, so `include Pundit::Authorization` can behave exactly like MRI without this package embedding a Ruby runtime.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func VerifyAuthorized

func VerifyAuthorized(performed bool) error

VerifyAuthorized returns a *AuthorizationNotPerformedError when performed is false, modeling Pundit's verify_authorized controller hook, which raises unless authorize (or skip_authorization) ran during the action.

func VerifyPolicyScoped

func VerifyPolicyScoped(performed bool) error

VerifyPolicyScoped returns a *PolicyScopingNotPerformedError when performed is false, modeling Pundit's verify_policy_scoped controller hook, which raises unless policy_scope (or skip_policy_scope) ran during the action.

Types

type AuthorizationNotPerformedError

type AuthorizationNotPerformedError struct{ Msg string }

AuthorizationNotPerformedError mirrors Pundit::AuthorizationNotPerformedError. VerifyAuthorized returns it when an authorization check was never performed, exactly as Pundit's controller hook raises it when neither authorize nor skip_authorization ran.

func (*AuthorizationNotPerformedError) Error

type Defined

type Defined func(className string) bool

Defined reports whether a policy or scope class of the given name is defined. It models Ruby's String#safe_constantize: the host answers from its constant table so the engine can distinguish "no such policy" (Pundit#policy returning nil, Pundit#policy! raising NotDefinedError) from a genuine authorization result. When an Engine's Defined seam is nil every derived name is treated as defined, which suits hosts that have already validated their policies.

type Dispatch

type Dispatch func(policyClass, query string, user, record any) (bool, error)

Dispatch invokes a policy query method (e.g. "update?") on the policy class named policyClass, for the given user and record, and reports the boolean the method returned. It is the seam through which the engine performs the `policy.public_send(query)` step of Pundit#authorize: the host (rbgo) instantiates the Ruby policy with (user, record) and sends it the query. The record passed is the pundit_model Subject (see Engine.Authorize).

type Engine

type Engine struct {
	// Dispatch performs the policy predicate call. Required by Authorize.
	Dispatch Dispatch
	// ResolveScope runs a policy scope. Required by PolicyScope / PolicyScopeBang.
	ResolveScope ResolveScope
	// Defined models safe_constantize; when nil, all derived names are defined.
	Defined Defined
}

Engine is the Pundit authorization engine. Construct it with the seams the host supplies; the zero value with only Dispatch set is enough to authorize.

func (*Engine) Authorize

func (e *Engine) Authorize(user any, record Subject, query string) (Subject, error)

Authorize resolves the policy for record, dispatches query against it and, following Pundit#authorize, returns the record (the pundit_model) on a true result. It returns a *NotDefinedError when no policy is defined, a *NotAuthorizedError when the query answered false, or whatever error Dispatch itself reports. For a namespaced Array subject the returned record is its last element, exactly as Pundit#pundit_model unwraps it.

func (*Engine) Policy

func (e *Engine) Policy(record Subject) (string, bool)

Policy resolves the policy class name for record, returning ("", false) when no such policy is defined. It mirrors Pundit#policy, which returns nil in that case.

func (*Engine) PolicyBang

func (e *Engine) PolicyBang(record Subject) (string, error)

PolicyBang resolves the policy class name for record or returns a *NotDefinedError, mirroring Pundit#policy!.

func (*Engine) PolicyScope

func (e *Engine) PolicyScope(user any, scope Subject) (any, error)

PolicyScope resolves the scope for scope and runs it through ResolveScope, returning nil when no scope is defined — mirroring Pundit#policy_scope, which returns nil in that case.

func (*Engine) PolicyScopeBang

func (e *Engine) PolicyScopeBang(user any, scope Subject) (any, error)

PolicyScopeBang resolves the scope for scope and runs it, or returns a *NotDefinedError when no scope is defined — mirroring Pundit#policy_scope!.

func (*Engine) Scope

func (e *Engine) Scope(record Subject) (string, bool)

Scope resolves the scope class name for record, returning ("", false) when no such scope is defined, mirroring Pundit::PolicyFinder#scope.

func (*Engine) ScopeBang

func (e *Engine) ScopeBang(record Subject) (string, error)

ScopeBang resolves the scope class name for record or returns a *NotDefinedError, mirroring Pundit::PolicyFinder#scope!.

type NotAuthorizedError

type NotAuthorizedError struct {
	// Query is the policy predicate that was checked (e.g. "update?").
	Query string
	// Record is the pundit_model that was checked (the Subject authorized, or
	// the last element of a namespaced Array subject).
	Record Subject
	// Policy is the name of the policy class that answered the query.
	Policy string
	// Msg is the rendered message, matching MRI's NotAuthorizedError#message.
	Msg string
}

NotAuthorizedError mirrors Pundit::NotAuthorizedError. Authorize returns it when the resolved policy's query method answered false. Like the gem it carries the Query that was checked, the Record that was checked, and the Policy class name that produced the answer, and its message reads "not allowed to <Policy>#<query> this <Record class>".

func (*NotAuthorizedError) Error

func (e *NotAuthorizedError) Error() string

type NotDefinedError

type NotDefinedError struct{ Msg string }

NotDefinedError mirrors Pundit::NotDefinedError. The bang resolvers (PolicyBang / ScopeBang and therefore Authorize / PolicyScopeBang) return it when no policy or scope class of the derived name is defined.

func (*NotDefinedError) Error

func (e *NotDefinedError) Error() string

type PolicyFinder

type PolicyFinder struct {
	// Object is the subject being resolved.
	Object Subject
}

PolicyFinder derives the policy and scope class names for a Subject. It is a faithful port of Pundit::PolicyFinder — pure string/naming logic, matching the gem's inflection exactly.

func NewPolicyFinder

func NewPolicyFinder(object Subject) *PolicyFinder

NewPolicyFinder returns a PolicyFinder for the given subject, mirroring PolicyFinder.new(object).

func (*PolicyFinder) PolicyName

func (f *PolicyFinder) PolicyName() string

PolicyName returns the policy class name for the subject, e.g. "PostPolicy". It mirrors the string that Pundit::PolicyFinder#policy would constantize.

func (*PolicyFinder) ScopeName

func (f *PolicyFinder) ScopeName() string

ScopeName returns the scope class name for the subject, e.g. "PostPolicy::Scope", mirroring Pundit::PolicyFinder#scope ("#{policy}::Scope").

type PolicyScopingNotPerformedError

type PolicyScopingNotPerformedError struct{ AuthorizationNotPerformedError }

PolicyScopingNotPerformedError mirrors Pundit::PolicyScopingNotPerformedError, which in the gem is a subclass of AuthorizationNotPerformedError. It embeds that type here to model the same is-a relationship. VerifyPolicyScoped returns it when a scoping check was never performed.

type ResolveScope

type ResolveScope func(scopeClass string, user, scope any) (any, error)

ResolveScope runs the scope class named scopeClass for the given user over the scope subject and returns the resolved collection. It is the seam for `PolicyScope::Scope.new(user, scope).resolve` — the host instantiates and resolves the Ruby scope. The scope passed is the pundit_model Subject.

type Subject

type Subject struct {
	// Name is the subject's class name for a plain object or a Class subject
	// (e.g. "Post"), or the symbol text for a Symbol subject (e.g. "blog_post").
	Name string
	// IsClass reports that the subject is itself a Ruby Class; Name is that
	// class's own name.
	IsClass bool
	// IsSymbol reports that the subject is a Ruby Symbol; Name is its text and
	// is camelized during resolution.
	IsSymbol bool
	// Elements, when non-nil, marks the subject as a Ruby Array used for
	// namespaced lookups such as [:admin, post]; the last element is the record
	// and the earlier ones form the module context. Must be non-empty.
	Elements []Subject
	// PolicyClass, when non-empty, means the object responds to :policy_class
	// and returns this policy class name, overriding name derivation entirely.
	PolicyClass string
	// ClassPolicyClass, when non-empty, means the object's class responds to
	// :policy_class and returns this name (checked after the object itself).
	ClassPolicyClass string
	// ModelName, when non-empty, means the object responds to :model_name
	// (ActiveModel) and returns this class name.
	ModelName string
	// ClassModelName, when non-empty, means the object's class responds to
	// :model_name and returns this name (checked after the object itself).
	ClassModelName string
	// Ruby is an opaque payload the host may attach — typically the underlying
	// Ruby object — so the Dispatch and ResolveScope seams can reach it. The
	// engine never inspects it.
	Ruby any
}

Subject is the reflective view of a Ruby object that PolicyFinder inspects to derive a policy or scope class name. It captures exactly the questions the gem's PolicyFinder#find and #find_class_name ask of a subject, so the naming logic here can be pure Go with no Ruby runtime.

The host (rbgo) builds a Subject by reflecting on a real Ruby object; plain Go callers use the constructors Object, Class, Symbol and Array. The precedence among the fields matches the gem: an Array is handled first, then a :policy_class override (on the object, then its class), otherwise the class name is derived from :model_name (object, then class), a Class, a Symbol (camelized) or the object's class in turn.

func Array

func Array(elems ...Subject) Subject

Array returns a namespaced Subject, e.g. Array(Symbol("admin"), Object("Post")) resolves to "Admin::PostPolicy". It must be given at least one element; the last is the record and the earlier ones form the module context.

func Class

func Class(name string) Subject

Class returns a Subject for a Ruby Class itself, e.g. Class("Post") (the Post class, not an instance) — it too resolves to "PostPolicy".

func Object

func Object(className string) Subject

Object returns a Subject for a plain instance of the named class, e.g. Object("Post") resolves to the policy "PostPolicy".

func Symbol

func Symbol(text string) Subject

Symbol returns a Subject for a Ruby Symbol, e.g. Symbol("blog_post"); the text is camelized, resolving to "BlogPostPolicy".

Jump to

Keyboard shortcuts

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