authorization

package
v9.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2026 License: AGPL-3.0 Imports: 7 Imported by: 0

Documentation

Overview

Package authorization answers "may this principal do this thing".

Authentication establishes who is calling; this establishes what they may do. The module has had the first half for a long time (see authentication) and none of the second, which left the most consequential security decision in every consuming service to be re-implemented per service.

The seam

Authorization is two operations that look like one, and conflating them is the mistake this package is shaped to avoid:

resolve   role names -> permission set   once per session   may do I/O
check     permission in set              many per request   never does

Only resolution is pluggable. PolicyResolver has two implementations — authorization/static compiles the policy in, authorization/database stores it in SQL — and both return the same *PermissionSet. Everything downstream is identical whichever is configured, so moving between them is a configuration change with no code change at any call site.

Checking is Grants.Has: one or two map lookups, no context, no error, no allocation. That it cannot fail is a property worth defending. The obvious alternative interface —

Authorize(ctx, principal, action, resource) (bool, error)

— makes a map lookup and a network round trip indistinguishable at the call site, which is how a permission check inside a loop becomes N round trips. It adds an error branch with no cause, and the tempting way to handle "engine unavailable" is to allow. And it takes a resource that nothing passes: instance scoping is an indexed predicate in the query that was going to run anyway (WHERE belongs_to_account = ...), not a second system to consult. A parameter every caller passes zero to cannot be removed later; a method can always be added.

Principals

There is no Principal type here, because enforcement needs authority rather than identity. Deriving authority from identity requires a store, which is what drags I/O onto the check; that derivation is the session's job and happens once, at login.

Consumers bridge their own session type with a GrantsExtractor. This is also where a multi-scope model collapses: a service that separates service-wide authority from per-tenant authority hands both sets to NewGrants and gets the OR of them, without the platform needing to know that "tenant" exists.

func extract(ctx context.Context) (authorization.Grants, bool) {
	s, err := sessions.FromContext(ctx)
	if err != nil {
		return authorization.Grants{}, false
	}

	return authorization.NewGrants(
		s.ServicePermissions,                    // may be nil
		s.AccountPermissions[s.ActiveAccountID], // absent key -> nil
	), true
}

NewGrants drops nil sets, so an administrator acting on a tenant they do not belong to simply carries one set instead of two. That case needs no branch anywhere, which is the point: it is the case most likely to be forgotten.

Choosing a backend

Start with authorization/static. It needs no database, no migrations, and no configuration, and it is what an empty Provider selects.

Move to authorization/database when roles must become editable data — when an operator has to define a new role, or change what one grants, without shipping a release. Reassigning which roles a principal holds does not require it: role assignments belong to the consumer in both cases, because they reference the consumer's own users and tenants. This package owns policy, not assignment.

The same []Role seeds either one. authorization/database.Seed takes exactly what static.NewResolver takes, and ValidateRoles runs in both, so a policy rejected in one is rejected in the other and a code-side policy cannot quietly drift from a database-side one.

No embedded policy engine and no external authorization service ships here. An engine buys a policy language, which is worth having when policy has conditions and wildcards, and is pure ceremony over role-to-permission tables that have neither. A relationship service answers questions about resource graphs, which is worth a network hop when resources are reachable through arbitrary relationships, and is not when ownership is one indexed column. Either could sit behind PolicyResolver later — that is what putting the seam there buys — but shipping an adapter with no user is how a package ends up unused.

Fail closed

An unconfigured resolver grants nothing: a static policy with no roles resolves every role to the empty set, so the default configuration denies. Unknown role names contribute nothing rather than erroring, so a principal still assigned a role the policy has dropped loses that authority instead of losing the ability to make requests.

Enforcement inverts the usual default too. In authorization/grpc an *undeclared* method is denied, so forgetting to register a method fails closed; a method that genuinely needs no authorization is declared Public, which is a statement rather than an absence. That guarantee is not available over HTTP — route patterns are not known before the mux matches, so authorization/http declares requirements at registration instead and a route with no middleware is simply unguarded. See that package for why, and for what to assert in a test instead.

The one deliberate hole is audit-only mode, which evaluates and records every decision but denies nothing. Turning enforcement on across a service that never had it is otherwise a coin flip on a large hand-written table. It is a code-level option rather than configuration precisely because it disables enforcement: that belongs in a diff, not in an environment variable.

Where a permission set may live

A resolved PermissionSet may live in a cache. It may never live in a credential.

That is the invariant the whole design protects, and the reason resolution rather than checking is the pluggable part. A cache entry that fails to decode after a deploy degrades to a query; a session or token that fails to decode logs its holder out. authorization/cached is therefore the only component whose encoding can break on a version skew, and the only one where breaking is free — its keys carry a format version and a decode failure is treated as a miss. Nothing else here serializes a PermissionSet, which makes changing the representation a non-event rather than a migration.

Denials

Denials surface as ErrPermissionDenied, whose canonical declaration is in the root errors package so that errors/http and errors/grpc can map it without importing this one. Both already do: it becomes HTTP 403 with code E110, and codes.PermissionDenied. A handler that returns it — wrapped or bare — gets the right status with no status construction of its own.

The message a client sees is the constant "permission denied". Which permission was missing goes to the span and the log and stops there; naming it in the response discloses the permission taxonomy to a caller who just failed to authorize.

What is not here

Answering "which resources may this principal act on" is out of scope, and not merely deferred. Doing it requires either mirroring every resource's ownership into the authorization layer — dual writes, a consistency window against your own transaction — or emitting SQL fragments, which couples this package to column names and join shapes. Today that question is answered by a predicate inside a query that already runs. Revisit only when a resource becomes reachable by someone who is not a member of its owner, at which point the shape is a filter fragment and it belongs next to the query, not here.

Example
package main

import (
	"context"
	"fmt"

	"github.com/primandproper/platform-go/v9/authorization"
	"github.com/primandproper/platform-go/v9/authorization/static"
)

// Permissions are ordinary constants in the consuming package. A consumer that
// already has its own Permission type adopts this one with a type alias, which
// leaves every existing constant compiling unchanged.
const (
	readRecipes   authorization.Permission = "read.recipes"
	writeRecipes  authorization.Permission = "write.recipes"
	deleteRecipes authorization.Permission = "delete.recipes"
)

func main() {
	// Policy declared in code. The same []Role would seed the database backend,
	// which is what keeps the two from drifting apart.
	resolver, err := static.NewResolver([]authorization.Role{
		{Name: "member", Permissions: []authorization.Permission{readRecipes}},
		{Name: "admin", Permissions: []authorization.Permission{writeRecipes}, Inherits: []string{"member"}},
		{Name: "owner", Permissions: []authorization.Permission{deleteRecipes}, Inherits: []string{"admin"}},
	})
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	// Resolved once, when a session is built — this is the half that may do I/O.
	perms, err := resolver.PermissionsForRoles(ctx, "admin")
	if err != nil {
		panic(err)
	}

	// Checked many times per request, against a value that cannot fail.
	grants := authorization.NewGrants(perms)

	fmt.Println("write:", grants.Has(writeRecipes))
	fmt.Println("read (inherited):", grants.Has(readRecipes))
	fmt.Println("delete:", grants.Has(deleteRecipes))

	// Evaluate answers a batch at once, which is what a client needs to decide
	// which controls to render.
	for _, perm := range []authorization.Permission{readRecipes, writeRecipes, deleteRecipes} {
		fmt.Printf("%s=%t ", perm, grants.Evaluate(readRecipes, writeRecipes, deleteRecipes)[perm])
	}
	fmt.Println()

}
Output:
write: true
read (inherited): true
delete: false
read.recipes=true write.recipes=true delete.recipes=false

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrEmptyRoleName indicates a role was declared without a name.
	ErrEmptyRoleName = errors.New("role name is empty")
	// ErrDuplicateRole indicates the same role name was declared twice.
	ErrDuplicateRole = errors.New("duplicate role name")
	// ErrUnknownParentRole indicates a role inherits from a role that is not defined.
	ErrUnknownParentRole = errors.New("role inherits from an unknown role")
	// ErrInheritanceCycle indicates role inheritance forms a cycle.
	ErrInheritanceCycle = errors.New("role inheritance cycle")
	// ErrSelfInheritance indicates a role names itself in its Inherits list.
	ErrSelfInheritance = errors.New("role inherits from itself")
)
View Source
var ErrPermissionDenied = platformerrors.ErrPermissionDenied

ErrPermissionDenied indicates the requester lacks the authority to perform the action.

It is an alias for the platform sentinel rather than a new error, so that errors.Is matches whichever one a caller reaches for. The canonical declaration lives in the root errors package because errors/http and errors/grpc must map it, and they cannot import this package — it imports them.

Handlers should return this (optionally wrapped) rather than constructing a status or an HTTP code by hand; the platform mappers already turn it into 403 with code E110 and into codes.PermissionDenied.

Functions

func ExpandInheritance

func ExpandInheritance(roles ...Role) (map[string]*PermissionSet, error)

ExpandInheritance resolves every role to its effective permission set, with inheritance applied transitively. It validates first, so a malformed policy is rejected here rather than producing a partially-expanded result.

It is the reference semantics for inheritance. authorization/static uses it directly; authorization/database expands in SQL instead, and a test asserts the two agree — which is the only way to know the recursive CTE and this function mean the same thing.

func ValidateRoles

func ValidateRoles(roles ...Role) error

ValidateRoles reports whether roles form a well-formed policy: every role named, no duplicates, every parent defined, and no inheritance cycles.

Both backends call it, so a policy rejected in a static build is rejected on its way into the database too.

Types

type Grants

type Grants struct {
	// contains filtered or unexported fields
}

Grants is a principal's effective authority for a single request: one or more granted permission sets, OR'd together.

It is a struct rather than an interface because it sits on the hot path. Has is one or two map lookups with no allocation and no dynamic dispatch, which is what allows the check to stay synchronous and infallible while the policy behind it is pluggable and may do I/O.

It holds a slice of sets rather than a materialized union deliberately. Merging a service-wide set and an account-scoped set of a few hundred permissions each would allocate a map of their combined size on every request; OR-ing at lookup allocates nothing.

The zero value denies everything, so a Grants that was never populated — a missing extractor, a failed authentication, a struct field nobody set — is safe by construction rather than by remembering to check.

Example (ZeroValue)

The zero value denies everything, so authority that was never populated is safe by construction rather than by remembering to check.

package main

import (
	"fmt"

	"github.com/primandproper/platform-go/v9/authorization"
)

// Permissions are ordinary constants in the consuming package. A consumer that
// already has its own Permission type adopts this one with a type alias, which
// leaves every existing constant compiling unchanged.
const readRecipes authorization.Permission = "read.recipes"

func main() {
	var grants authorization.Grants

	fmt.Println("read:", grants.Has(readRecipes))
	fmt.Println("empty:", grants.IsEmpty())

}
Output:
read: false
empty: true

func AllowAll

func AllowAll() Grants

AllowAll returns Grants that permit everything.

It exists for tests and local development, and it is deliberately a function call at a call site rather than a configurable provider: turning authorization off should be visible in code review, not reachable by setting an environment variable in production.

func DenyAll

func DenyAll() Grants

DenyAll returns Grants that permit nothing. It is the zero value, named.

func NewGrants

func NewGrants(sets ...*PermissionSet) Grants

NewGrants builds Grants from one or more permission sets. Sets that grant nothing — nil or empty — are dropped.

Dropping them is what makes the awkward case structural rather than conditional: a service administrator acting on an account they are not a member of simply has one set instead of two. Callers do not check for it, and cannot forget to. Empty is dropped alongside nil because the two mean the same thing to every method here, and keeping empties would make IsEmpty and Has walk sets that can never match.

Example

A principal with authority in more than one scope hands each set to NewGrants. Nil sets are dropped, so an administrator acting on a tenant they do not belong to needs no special case.

package main

import (
	"fmt"

	"github.com/primandproper/platform-go/v9/authorization"
)

// Permissions are ordinary constants in the consuming package. A consumer that
// already has its own Permission type adopts this one with a type alias, which
// leaves every existing constant compiling unchanged.
const (
	readRecipes authorization.Permission = "read.recipes"

	deleteRecipes authorization.Permission = "delete.recipes"
)

func main() {
	serviceWide := authorization.NewPermissionSet(deleteRecipes)

	var tenantScoped *authorization.PermissionSet // no membership in this tenant

	grants := authorization.NewGrants(serviceWide, tenantScoped)

	fmt.Println("delete:", grants.Has(deleteRecipes))
	fmt.Println("read:", grants.Has(readRecipes))

}
Output:
delete: true
read: false

func (Grants) Evaluate

func (g Grants) Evaluate(perms ...Permission) map[Permission]bool

Evaluate reports the outcome for each permission in perms.

This is the shape a "what can I do" introspection endpoint needs in order to tell a client which controls to render. The returned map is never nil and always has an entry for every requested permission, including the false ones — a caller distinguishing "denied" from "not asked" needs that distinction to survive.

func (Grants) Has

func (g Grants) Has(p Permission) bool

Has reports whether any of the granted sets contains p.

func (Grants) HasAll

func (g Grants) HasAll(perms ...Permission) bool

HasAll reports whether every permission in perms is granted.

Calling it with no permissions is vacuously true, and a list that reached zero length by accident therefore authorizes everyone. PermissionSet.HasAll documents why that answer is the right one here, which of the enforcement paths guard it, and what a caller assembling a list dynamically has to check first. Read it before calling this from anything that decides access.

func (Grants) HasAny

func (g Grants) HasAny(perms ...Permission) bool

HasAny reports whether any permission in perms is granted.

func (Grants) IsEmpty

func (g Grants) IsEmpty() bool

IsEmpty reports whether these Grants permit nothing at all.

type GrantsExtractor

type GrantsExtractor func(ctx context.Context) (Grants, bool)

GrantsExtractor pulls a principal's authority out of a request context.

It is a function rather than an interface so that platform-go never needs to know how a consumer represents a session. The consumer writes the adapter over whatever its authentication layer put in the context, and that adapter is where a multi-scope model collapses into the flat "these sets, OR'd" the platform understands.

Returning false means "no authority could be determined", which every enforcement path treats as a denial — not as an error, and never as a pass.

type Permission

type Permission string

Permission names an action a principal may be authorized to perform. It is a bare string so that consumers can declare their own vocabulary as ordinary constants:

const CreateRecipesPermission authorization.Permission = "create.recipes"

A consumer with an existing Permission type adopts this one with a type alias (`type Permission = authorization.Permission`), which leaves every existing constant, map key, and switch compiling unchanged.

type PermissionSet

type PermissionSet struct {
	// contains filtered or unexported fields
}

PermissionSet is an immutable set of permissions.

Every method is safe on a nil receiver, and a nil *PermissionSet grants nothing. That is load-bearing rather than defensive: a principal with no membership in some scope is represented by a nil set instead of an absent map entry, so "no grants here" needs no special case at any call site.

func NewPermissionSet

func NewPermissionSet(perms ...Permission) *PermissionSet

NewPermissionSet returns a set containing perms. Duplicates collapse and empty permissions are dropped. The set copies its input, so mutating the caller's slice afterwards cannot change it.

func (*PermissionSet) All

func (s *PermissionSet) All() iter.Seq[Permission]

All iterates the set in sorted order. The order is deterministic so that encodings, golden files, and equality checks over serialized forms are stable across runs.

func (*PermissionSet) Equal

func (s *PermissionSet) Equal(other *PermissionSet) bool

Equal reports whether s and other contain exactly the same permissions. A nil set and an empty set are equal, because both grant nothing.

func (*PermissionSet) GobDecode

func (s *PermissionSet) GobDecode(data []byte) error

GobDecode decodes a set encoded by GobEncode.

func (*PermissionSet) GobEncode

func (s *PermissionSet) GobEncode() ([]byte, error)

GobEncode encodes the set as a sorted slice of permissions.

PermissionSet's only field is unexported, so gob cannot encode it structurally. This matters because cache.Cache's default codec is gob, and authorization/cached stores resolved sets — without these methods the cache silently round-trips an empty set.

func (*PermissionSet) Has

func (s *PermissionSet) Has(p Permission) bool

Has reports whether p is in the set.

func (*PermissionSet) HasAll

func (s *PermissionSet) HasAll(perms ...Permission) bool

HasAll reports whether every permission in perms is in the set.

HasAll with no permissions is vacuously true, which is the mathematically honest answer and also a hazard: a requirement that accidentally resolves to zero permissions would authorize everyone.

Set algebra wins here and the guard belongs at the declaration site, so the three places a permission list is declared each answer the empty case themselves, and they do not answer it the same way:

PermissionSet.HasAll(), Grants.HasAll()   true   — set algebra
http.Enforcer.Require()                   denies — an empty list is a bug
grpc.RequirementsBuilder.Require()        errors — refuses to build

That is deliberate, but it means "empty means allow" is only ever safe with a list you constructed literally. Anything derived from configuration, a database, or a map lookup must be checked for emptiness before it reaches here. Enforcement code should not call this at all — use the Enforcer for its transport, which already guards; see authorization/http for why HTTP cannot do that check at boot the way gRPC does.

func (*PermissionSet) HasAny

func (s *PermissionSet) HasAny(perms ...Permission) bool

HasAny reports whether any permission in perms is in the set. HasAny with no permissions is false: there is no witness.

func (*PermissionSet) IsEmpty

func (s *PermissionSet) IsEmpty() bool

IsEmpty reports whether the set grants nothing.

func (*PermissionSet) IsSubsetOf

func (s *PermissionSet) IsSubsetOf(other *PermissionSet) bool

IsSubsetOf reports whether every permission in s is also in other. The empty set is a subset of everything.

func (*PermissionSet) Len

func (s *PermissionSet) Len() int

Len returns the number of permissions in the set.

func (*PermissionSet) MarshalJSON

func (s *PermissionSet) MarshalJSON() ([]byte, error)

MarshalJSON encodes the set as a sorted array of strings.

The error branch is unreachable — the argument is a []Permission, and encoding/json cannot fail on a slice of a string type — and stays only because the interface requires the return and errcheck requires the handling. The same is true of GobEncode below.

func (*PermissionSet) Slice

func (s *PermissionSet) Slice() []Permission

Slice returns the set's permissions in sorted order.

func (*PermissionSet) String

func (s *PermissionSet) String() string

String is deliberately a summary rather than a listing. A set can hold hundreds of permissions, and this type ends up attached to logs and spans — dumping the whole policy into telemetry on every request would be both noisy and a disclosure.

func (*PermissionSet) Union

func (s *PermissionSet) Union(others ...*PermissionSet) *PermissionSet

Union returns a new set containing everything in s and in others. Nil sets contribute nothing.

func (*PermissionSet) UnmarshalJSON

func (s *PermissionSet) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a JSON array of strings into the set.

type PolicyInvalidator

type PolicyInvalidator interface {
	// Invalidate drops the memoized resolution for an exact set of roles.
	Invalidate(ctx context.Context, roles ...string) error

	// InvalidateAll makes every resolution this instance memoized unreachable.
	// It is process-local: other replicas wait out their TTL.
	InvalidateAll()
}

PolicyInvalidator is the optional half of a PolicyResolver that memoizes.

Only authorization/cached implements it; the other backends hold nothing to drop. It is declared here, apart from PolicyResolver, so that a caller wired through authorizationcfg can reach invalidation without knowing which concrete type it was handed — whether a cache sits in the chain is a configuration decision, and the position of the cached decorator inside the returned resolver is an implementation detail:

if inv, ok := resolver.(authorization.PolicyInvalidator); ok {
	inv.InvalidateAll()
}

The process that edits policy is exactly the one that needs this, and it is the one least likely to know how its resolver was assembled.

type PolicyResolver

type PolicyResolver interface {
	// PermissionsForRoles returns the effective permissions of the named roles
	// with inheritance expanded, which is the union of what each role grants.
	// Unknown role names contribute nothing rather than erroring: a policy that
	// no longer defines a role a principal is still assigned must fail closed,
	// not fail the request. Use Roles to detect that case deliberately.
	//
	// Calling it with no roles returns an empty set, not an error.
	PermissionsForRoles(ctx context.Context, roles ...string) (*PermissionSet, error)

	// Roles returns every role the policy defines, for introspection and admin
	// tooling. The order is unspecified.
	Roles(ctx context.Context) ([]Role, error)
}

PolicyResolver answers "what can these roles do".

This is the only fallible, context-taking part of the package, and the only one with more than one implementation — which is the whole design. Resolving policy may hit a database; checking a permission never does. Callers resolve once when they build a session and check many times per request against the resulting Grants.

Implementations must be safe for concurrent use.

type Role

type Role struct {
	// Name identifies the role. It is the string a principal's role assignments
	// refer to, and it must be unique within a policy.
	Name string `json:"name,omitempty" yaml:"name,omitempty"`
	// Description is human-facing documentation, surfaced by Roles for admin
	// tooling. It has no effect on resolution.
	Description string `json:"description,omitempty" yaml:"description,omitempty"`
	// Permissions are the permissions this role grants directly, before
	// inheritance is applied.
	Permissions []Permission `json:"permissions,omitempty" yaml:"permissions,omitempty"`
	// Inherits names the roles this role inherits from. Inheritance is
	// transitive: a role receives the permissions of its parents, its parents'
	// parents, and so on. It is not an ordering — a role may inherit from
	// several, and the result is their union.
	Inherits []string `json:"inherits,omitempty" yaml:"inherits,omitempty"`
}

Role is a named grant of permissions, optionally inheriting from other roles.

The same []Role value seeds either backend: authorization/static compiles it in, and authorization/database.Seed writes it to tables. That is what makes the two interchangeable, and it is the fix for the failure mode where a code-side role table and a database seed drift apart because nothing checks them against each other.

Directories

Path Synopsis
Package cached wraps any authorization.PolicyResolver in a cache.
Package cached wraps any authorization.PolicyResolver in a cache.
Package authorizationcfg selects and builds an authorization.PolicyResolver from configuration.
Package authorizationcfg selects and builds an authorization.PolicyResolver from configuration.
Package database stores authorization policy in SQL tables.
Package database stores authorization policy in SQL tables.
migrations
Package migrations supplies the authorization policy tables' DDL, rendered for a dialect and table prefix.
Package migrations supplies the authorization policy tables' DDL, rendered for a dialect and table prefix.
Package grpc enforces authorization on gRPC methods.
Package grpc enforces authorization on gRPC methods.
Package http enforces authorization on HTTP routes.
Package http enforces authorization on HTTP routes.
Package authorizationmock provides moq-generated mock implementations of interfaces in the authorization package.
Package authorizationmock provides moq-generated mock implementations of interfaces in the authorization package.
Package static provides an authorization.PolicyResolver whose policy is fixed at construction.
Package static provides an authorization.PolicyResolver whose policy is fixed at construction.

Jump to

Keyboard shortcuts

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