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

permission

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 10 Imported by: 0

README

go-permission

CI Go Reference

Role-based access control for multi-tenant SaaS applications, with typed resource policies. Group permissions into roles, assign them to users or API keys per tenant or team, and ask Can(ctx, user, "invoices.create", tenant). Rules that depend on the resource itself ("authors may edit their own posts") live in typed policy functions that can call the RBAC checks. Effective permissions are cached and invalidated across every instance of your application through PostgreSQL LISTEN/NOTIFY.

Install

go get github.com/byteminus/go-permission

Requires Go 1.26 or later. The PostgreSQL store supports PostgreSQL 16 and later.

Quick start

package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

type Post struct {
	AuthorID string
	TeamID   string
}

func main() {
	ctx := context.Background()
	authz, err := permission.New(memstore.New(), permission.Options{SuperAdminRole: "superadmin"})
	if err != nil {
		log.Fatal(err)
	}

	// Roles are defined once and assigned per team.
	if _, err := authz.CreateRole(ctx, "editor", "Writes and publishes posts"); err != nil {
		log.Fatal(err)
	}
	if err := authz.GrantToRole(ctx, "editor", "posts.*"); err != nil {
		log.Fatal(err)
	}
	alice := permission.Subject{Type: "user", ID: "alice"}
	if err := authz.AssignRole(ctx, alice, "editor", "team-1"); err != nil {
		log.Fatal(err)
	}

	ok, err := authz.Can(ctx, alice, "posts.publish", "team-1")
	fmt.Println(ok, err) // true <nil>

	// A policy for one action on one resource type.
	permission.MustRegisterPolicy(authz, "update", func(ctx context.Context, actor permission.Subject, p *Post) (bool, error) {
		if actor.Type == "user" && actor.ID == p.AuthorID {
			return true, nil
		}
		return authz.Can(ctx, actor, "posts.update", p.TeamID)
	})

	bob := permission.Subject{Type: "user", ID: "bob"}
	err = authz.Authorize(ctx, bob, "update", &Post{AuthorID: "carol", TeamID: "team-1"})
	var denied *permission.ForbiddenError
	if errors.As(err, &denied) {
		fmt.Println(denied.Reason) // policy denied action "update" on *main.Post
	}
}

Concepts

Permissions are dotted strings of lowercase letters, digits, _ and -: posts.publish, billing.invoices.refund. A grant may end in *, matched by whole segments: posts.* covers posts.publish and posts.comments.delete, but neither posts itself nor postsecret.read; * alone covers every permission. A * anywhere else (*.read, posts.*.edit, posts*) is rejected. Names are case-sensitive and lowercase only: Posts.read is rejected with ErrInvalidPermission, never folded into posts.read. Empty and malformed names are rejected everywhere, and checks always ask about a concrete permission.

Roles group permissions. Role names use lowercase letters, digits and _ . : - (admin, billing-manager, org:owner).

Subjects are whoever acts: Subject{Type: "user", ID: "42"}, Subject{Type: "api_key", ID: "k_live_..."}. The type namespaces the ID, both fields are compared exactly, and a Subject marshals to JSON as {"type":"user","id":"42"}. The zero Subject is the anonymous actor: it holds nothing, and policies receive it so they can allow public reads. A Subject with only one field set is rejected with ErrInvalidSubject. String() renders type:id for logs only; caches and stores key on the two fields, so {"a:b", "c"} and {"a", "b:c"} never collide.

Scopes are usually tenant or team IDs, compared exactly. Every role assignment and direct grant has one:

Granted in Check in "" (global) Check in team-1 Check in team-2
"" (global) applies applies applies
team-1 no applies no

A scoped grant never leaks to the global scope or to another scope, so a check in the global scope sees only global grants and never allows more than a check in any tenant. That is why a missing scope falls back to the global scope instead of failing open.

Checks: Can, HasRole, HasAnyRole, HasAllRoles, RolesFor and PermissionsFor (the effective set: every permission and pattern the subject holds, through roles or directly, globally or in the scope).

Super-admins: Options.SuperAdminRole names a role whose holders pass every Can check, and the Authorize check of resources, in the scopes where they hold it: assigned in team-1 it works only in team-1; assigned in the global scope it works everywhere. Options.SuperAdmin is a function for anything else (for example, internal staff accounts). HasRole and PermissionsFor report facts and ignore the bypass.

Failing closed: every check denies on any error (invalid input, a store or super-admin failure, a policy error, a missing policy), returns the error and passes it once to Options.OnError. Plain denials are not errors.

Policies attach a typed function to an action and a resource type:

permission.MustRegisterPolicy(authz, "delete", func(ctx context.Context, actor permission.Subject, c *Comment) (bool, error) {
	if c.Locked {
		return false, permission.Deny("comment thread is locked")
	}
	return actor.ID == c.AuthorID, nil
})

Authorize returns nil or a *ForbiddenError (matching ErrForbidden) with the actor, action, resource type and reason. A resource type or action with no policy is denied, even for super-admins, with an error that also matches ErrNoPolicy, so a forgotten registration shows up in tests rather than as a silent hole. Lookups use the exact dynamic type: a policy for *Post does not apply to Post. Allows is the boolean form; it fails closed and passes unexpected errors to Options.OnError. The super-admin bypass in Authorize uses the resource's own scope, never the request's: a resource that implements ScopedResource (PermissionScope() string) is bypassed by a super-admin of that scope; any other resource only by a global super-admin. A tenant owner who requests another tenant's post through their own tenant's route therefore still meets the policy, while a policy that calls authz.Can(ctx, actor, "posts.update", post.TeamID) lets a scoped super-admin of the post's team through.

Caching: effective sets are cached per subject and scope (default TTL one minute, 10,000 entries; a full cache evicts an expired entry or the one closest to expiry). Writes through the Authorizer invalidate the entries they can affect before they return: subject writes drop that subject, role and permission writes drop everything. A cache fill that started before a write is discarded, so a revocation made outside a transaction takes effect immediately on the instance that made it. A revocation made inside a caller's transaction (see WithConn) is not immediate: the local cache is invalidated when the statement runs, and a concurrent request can cache the old state again before the commit. Run a pgxstore.Listener, whose notification arrives at commit, or call authz.Invalidate after committing. Another instance's writes reach the cache through Authorizer.Invalidate, which pgxstore.Listener calls. Reads made inside a caller's transaction bypass the cache, so uncommitted or rolled-back grants never leak into it.

Wiring the context once: components that read the subject or the scope from the context take hooks with the same names and signatures as permission.SubjectFrom and permission.ScopeFrom. A scope is usually the tenant, so a tenant lookup plugs in directly:

mw, err := httpperm.New(authz, httpperm.Options{
	ScopeFrom: tenancy.TenantIDFrom, // func(context.Context) (string, bool)
	SubjectFrom: func(ctx context.Context) (permission.Subject, bool) {
		u, ok := myapp.UserFrom(ctx)
		return permission.Subject{Type: "user", ID: u.ID}, ok
	},
})

HTTP middleware (httpperm) reads the subject and scope through Options.SubjectFrom and Options.ScopeFrom; nil hooks read what permission.WithSubject and permission.WithScope stored. A tenant taken from the URL is copied into the context by a small wrapper:

mw, err := httpperm.New(authz, httpperm.Options{
	SubjectFrom: func(ctx context.Context) (permission.Subject, bool) {
		user, ok := auth.UserFrom(ctx) // your authentication
		return permission.Subject{Type: "user", ID: user.ID}, ok
	},
})
tenantFromPath := func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r.WithContext(permission.WithScope(r.Context(), r.PathValue("tenant"))))
	})
}
mux.Handle("POST /t/{tenant}/invoices", tenantFromPath(mw.Require("invoices.create")(createInvoice)))
mux.Handle("GET /t/{tenant}/admin", tenantFromPath(mw.RequireRole("admin")(adminPage)))

No subject answers 401, a denial 403, and a failed check or a half-empty subject (a broken authentication hook, so a server bug) 500; none of them reaches the handler; each response is replaceable through Options. A request without a scope is checked in the global scope. Requests that pass carry the subject and, when known, the scope in their context (permission.SubjectFrom, permission.ScopeFrom).

PostgreSQL

store, err := pgxstore.New(pool, pgxstore.WithNotifyChannel("perm_invalidate"))
if err != nil { ... }
if err := store.CreateSchema(ctx); err != nil { ... }
authz, err := permission.New(store, permission.Options{})

listener, err := pgxstore.NewListener(pgxstore.ListenerConfig{
	Connect: func(ctx context.Context) (*pgx.Conn, error) { return pgx.Connect(ctx, databaseURL) },
	Channel: "perm_invalidate",
	Handler: authz.Invalidate,
	OnError: func(err error) { log.Printf("permission listener: %v", err) },
})
go listener.Run(ctx)

Every write is a single statement that also sends the invalidation notice, so the notice commits or rolls back with the write. The listener holds its own connection and pings it after HealthCheckInterval (default 30 seconds) without a notice, so a connection that died silently is replaced. Notices sent while it is not listening are lost, so it degrades safely: it drops the whole cache when the connection is lost, before every reconnection attempt (jittered exponential backoff, at most MaxBackoff, default 30 seconds) and when it listens again, and it reports every failure to OnError. A connection that fails with an error is noticed at once; one that stalls silently is noticed by the health check within HealthCheckInterval + Timeout (40 seconds by default), and writes from other instances in that window can stay cached until it ends (or CacheTTL, if shorter). Once a loss is noticed, no cached entry outlives MaxBackoff. Connecting, LISTEN and each ping are bounded by Timeout (default 10 seconds).

Tables are named with a prefix, default perm_; use pgxstore.WithTablePrefix("authz.perm_") for another prefix or schema. store.Schema() returns the DDL for migration tools. With the default prefix:

CREATE TABLE IF NOT EXISTS "perm_roles" (
    id          uuid PRIMARY KEY,
    name        text NOT NULL UNIQUE
                CHECK (octet_length(name) <= 64 AND name ~ '^[a-z0-9][a-z0-9_.:-]*$'),
    description text NOT NULL DEFAULT ''
                CHECK (octet_length(description) <= 1024),
    created_at  timestamptz NOT NULL
);

CREATE TABLE IF NOT EXISTS "perm_permissions" (
    id         uuid PRIMARY KEY,
    name       text NOT NULL UNIQUE
               CHECK (octet_length(name) <= 255 AND name ~ '^(\*|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*(\.\*)?)$'),
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS "perm_role_permissions" (
    role_id       uuid NOT NULL REFERENCES "perm_roles" (id) ON DELETE CASCADE,
    permission_id uuid NOT NULL REFERENCES "perm_permissions" (id) ON DELETE CASCADE,
    created_at    timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS "perm_role_permissions_permission_idx" ON "perm_role_permissions" (permission_id);

CREATE TABLE IF NOT EXISTS "perm_subject_roles" (
    subject_type text NOT NULL
                 CHECK (octet_length(subject_type) <= 64 AND subject_type ~ '^[a-z0-9][a-z0-9_.:-]*$'),
    subject_id   text NOT NULL
                 CHECK (octet_length(subject_id) BETWEEN 1 AND 255),
    scope        text NOT NULL DEFAULT ''
                 CHECK (octet_length(scope) <= 255),
    role_id      uuid NOT NULL REFERENCES "perm_roles" (id) ON DELETE CASCADE,
    created_at   timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (subject_type, subject_id, scope, role_id)
);
CREATE INDEX IF NOT EXISTS "perm_subject_roles_members_idx" ON "perm_subject_roles" (role_id, scope, subject_type COLLATE "C", subject_id COLLATE "C");

CREATE TABLE IF NOT EXISTS "perm_subject_permissions" (
    subject_type  text NOT NULL
                  CHECK (octet_length(subject_type) <= 64 AND subject_type ~ '^[a-z0-9][a-z0-9_.:-]*$'),
    subject_id    text NOT NULL
                  CHECK (octet_length(subject_id) BETWEEN 1 AND 255),
    scope         text NOT NULL DEFAULT ''
                  CHECK (octet_length(scope) <= 255),
    permission_id uuid NOT NULL REFERENCES "perm_permissions" (id) ON DELETE CASCADE,
    created_at    timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (subject_type, subject_id, scope, permission_id)
);
CREATE INDEX IF NOT EXISTS "perm_subject_permissions_permission_idx" ON "perm_subject_permissions" (permission_id);
Joining transactions

WithConn resolves the connection per call. Return the transaction your request carries and the permission write commits or rolls back with the rest of its work; return nil to use the pool.

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil
}))

err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
	txCtx := context.WithValue(ctx, txKey{}, tx)
	if err := createTeam(txCtx, team); err != nil {
		return err
	}
	return authz.AssignRole(txCtx, owner, "owner", team.ID)
})

Checks made with a context that resolves to a transaction read through it (so they see the transaction's own writes) and bypass the cache. The local cache is invalidated when the write statement runs, not at commit, so another request on this instance can cache the old state between the statement and the commit. Other instances, and this one when it runs a Listener, are notified at commit; run a Listener (or call authz.Invalidate after committing) when revocations made inside transactions must take effect immediately.

API overview

Area API
Setup New(store, Options), Options{CacheTTL, CacheMaxEntries, SuperAdminRole, SuperAdmin, Now, OnError}
Checks Can, HasRole, HasAnyRole, HasAllRoles, RolesFor, PermissionsFor
Policies ScopedResource, RegisterPolicy[T], MustRegisterPolicy[T], PolicyFunc[T], Authorize, Allows, Deny, ForbiddenError
Roles CreateRole, Role, ListRoles, DeleteRole, GrantToRole, RevokeFromRole, RolePermissions
Subjects AssignRole, UnassignRole, SubjectsWithRole (paged: SubjectsOptions, SubjectPage, DefaultPageSize, MaxPageSize), Grant, Revoke
Permissions ListPermissions, DeletePermission, ValidatePermission, Match
Cache Invalidate(Invalidation), CacheBypass
Context WithSubject, SubjectFrom, WithScope, ScopeFrom
Validation ValidateSubject, ValidateRole, ValidateScope, Role.Validate, SubjectQuery.Validate
Errors ErrInvalid, ErrNotFound, ErrConflict (family sentinels), ErrInvalidSubject, ErrInvalidPermission, ErrInvalidRole, ErrInvalidScope, ErrInvalidPolicy, ErrRoleNotFound, ErrRoleExists, ErrPermissionNotFound, ErrForbidden, ErrNoPolicy
pgxstore New, WithConn, WithTablePrefix, WithNotifyChannel, Store.Schema, Store.CreateSchema, NewListener, ListenerConfig{Connect, Channel, Handler, MinBackoff, MaxBackoff, HealthCheckInterval, Timeout, OnError}, Listener.Run
memstore New
storetest Run(t, newStore)
httpperm New, Options{SubjectFrom, ScopeFrom, Unauthorized, Forbidden, Error}, Middleware.Require, Middleware.RequireRole

Writes are idempotent where repeating them is harmless: granting a held permission, revoking an absent one, repeating an assignment or removing an absent one all succeed. Operations on a missing role return ErrRoleNotFound (so typos surface), creating a taken role name returns ErrRoleExists, and deleting an unknown permission returns ErrPermissionNotFound. Every error matches one of the family sentinels ErrInvalid, ErrNotFound or ErrConflict, so one mapping to HTTP statuses covers this package and its siblings.

SubjectsWithRole grows with your users, so it is paged with a keyset on (Type, ID): pass the previous page's Next as SubjectsOptions.After until Next is zero. ListRoles and ListPermissions return curated sets whole.

Testing

go test -race ./...
TEST_DATABASE_URL='postgres://postgres:postgres@localhost:5432/permission?sslmode=disable' \
  go test -race -count=1 ./...

Integration tests create and drop their own schemas and run in parallel. To check your own store:

func TestMyStore(t *testing.T) {
	storetest.Run(t, func(t *testing.T) permission.Store { return newMyStore(t) })
}

Roadmap

  • Listing a subject's raw assignments and direct grants per scope (today RolesFor and PermissionsFor return the effective, merged view).
  • Role hierarchies (roles that include other roles).
  • Explicit deny rules.
  • Audit hooks for management writes.
  • A Redis-backed invalidation transport for deployments without PostgreSQL.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT. See LICENSE.

Documentation

Overview

Package permission is role-based access control for SaaS applications, with resource-level policies.

Model

Permissions are dotted, lowercase strings such as "posts.publish". A grant may end in a wildcard, matched by whole segments: "posts.*" covers "posts.publish" and "posts.comments.delete" but neither "posts" nor "postsecret.read", and "*" covers every permission. Names are case-sensitive and lowercase only; anything else is rejected with ErrInvalidPermission. Roles group permissions. Subjects (users, API keys, service accounts) are identified by a type and an ID, and receive roles and direct permissions.

Scopes

Every grant and assignment has a scope, typically a tenant or team ID, compared exactly. A grant in GlobalScope ("") applies in every scope; a grant in "team-1" applies only to checks made in "team-1", never in GlobalScope or another scope. A check in GlobalScope therefore sees only global grants and never allows more than a check in any other scope, which is why a missing scope falls back to it. The super-admin role follows the same rule: held in "team-1" it bypasses checks only in "team-1"; held in GlobalScope it bypasses them everywhere. Authorize applies the bypass in the resource's scope (see ScopedResource), never in the request's, so a super-admin of one tenant meets the policy of another tenant's resource.

Checks

An Authorizer answers Can, HasRole, HasAnyRole, HasAllRoles, RolesFor and PermissionsFor. Every check fails closed: on any error it denies, returns the error and passes it to Options.OnError.

Effective sets are cached per subject and scope with a TTL. Writes made through the Authorizer invalidate what they affect before they return, and a cache fill that raced with a write is discarded, so a revocation made outside a transaction is immediate on the instance that made it. A revocation made inside a caller's transaction is invalidated when its statement runs, before the commit, so a concurrent check can cache the old state again; run the pgxstore Listener, notified at commit, or call Invalidate after committing. Writes made by other instances arrive through Authorizer.Invalidate, which the pgxstore Listener calls from PostgreSQL notifications; while the Listener is disconnected it keeps dropping the cache, so once it notices a loss, staleness stays bounded by its backoff.

Policies

RegisterPolicy attaches a typed function to an action and a resource type, such as "update" on *Post. Authorize returns nil or a *ForbiddenError that matches ErrForbidden and explains the denial; a resource type without a policy is denied and also matches ErrNoPolicy. Allows is the boolean form.

Context

WithSubject/SubjectFrom and WithScope/ScopeFrom carry the acting subject and the scope. Components that read them take hooks of the same name and signature in their Options (httpperm.Options), so an application can supply its own lookups, such as a tenant package's TenantIDFrom, once at startup. The Authorizer itself reads neither: checks take the subject and scope as arguments.

Errors

Errors match ErrInvalid, ErrNotFound or ErrConflict, shared by the Byte Minus packages, as well as a specific sentinel such as ErrRoleNotFound.

Storage

The Store interface is implemented by the memstore (tests, examples) and pgxstore (PostgreSQL) subpackages; storetest verifies third-party stores. The httpperm subpackage provides net/http middleware.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

func main() {
	ctx := context.Background()
	authz, err := permission.New(memstore.New(), permission.Options{SuperAdminRole: "superadmin"})
	if err != nil {
		panic(err)
	}

	// Define a role once, then assign it per team.
	if _, err := authz.CreateRole(ctx, "editor", "Writes and publishes posts"); err != nil {
		panic(err)
	}
	if err := authz.GrantToRole(ctx, "editor", "posts.*", "comments.moderate"); err != nil {
		panic(err)
	}
	alice := permission.Subject{Type: "user", ID: "alice"}
	if err := authz.AssignRole(ctx, alice, "editor", "team-1"); err != nil {
		panic(err)
	}

	inTeam, _ := authz.Can(ctx, alice, "posts.publish", "team-1")
	elsewhere, _ := authz.Can(ctx, alice, "posts.publish", "team-2")
	fmt.Println(inTeam, elsewhere)
}
Output:
true false

Index

Examples

Constants

View Source
const (
	// DefaultCacheTTL is the cache TTL when Options.CacheTTL is zero.
	DefaultCacheTTL = time.Minute
	// DefaultCacheMaxEntries is the cache size when Options.CacheMaxEntries
	// is zero.
	DefaultCacheMaxEntries = 10000
)

Default cache settings used when Options leaves them zero.

View Source
const (
	// DefaultPageSize is the page size when a limit of zero is given.
	DefaultPageSize = 100
	// MaxPageSize caps the page size; larger limits are reduced to it.
	MaxPageSize = 1000
)

Page sizes for Authorizer.SubjectsWithRole.

View Source
const (
	// MaxNameLength is the longest role name, subject type or policy action,
	// in bytes.
	MaxNameLength = 64
	// MaxPermissionLength is the longest permission string, in bytes.
	MaxPermissionLength = 255
	// MaxSubjectIDLength is the longest subject ID, in bytes.
	MaxSubjectIDLength = 255
	// MaxScopeLength is the longest scope, in bytes.
	MaxScopeLength = 255
	// MaxDescriptionLength is the longest role description, in bytes.
	MaxDescriptionLength = 1024
)

Limits enforced by the validators. The PostgreSQL store mirrors them in CHECK constraints, so changing one is a schema change.

View Source
const GlobalScope = ""

GlobalScope is the scope of grants that apply in every scope. Checks made with GlobalScope see only global grants, so a check in GlobalScope never allows more than the same check in any other scope.

Variables

View Source
var (
	// ErrNotFound reports that a record does not exist.
	ErrNotFound = errors.New("permission: not found")
	// ErrInvalid reports input that failed validation.
	ErrInvalid = errors.New("permission: invalid input")
	// ErrConflict reports a uniqueness violation, or a record whose state no
	// longer allows the operation.
	ErrConflict = errors.New("permission: conflict")
)

Family sentinels. Every Byte Minus package exports these three, so an application maps all of them to HTTP statuses the same way. The package-specific errors below wrap the matching one.

View Source
var (
	// ErrInvalidSubject reports a Subject whose type or ID is malformed, or
	// a half-empty Subject. It matches ErrInvalid.
	ErrInvalidSubject error = &kindError{msg: "permission: invalid subject", kind: ErrInvalid}
	// ErrInvalidPermission reports a malformed permission string. It
	// matches ErrInvalid.
	ErrInvalidPermission error = &kindError{msg: "permission: invalid permission", kind: ErrInvalid}
	// ErrInvalidRole reports a malformed role name or role record. It
	// matches ErrInvalid.
	ErrInvalidRole error = &kindError{msg: "permission: invalid role", kind: ErrInvalid}
	// ErrInvalidScope reports a malformed scope. It matches ErrInvalid.
	ErrInvalidScope error = &kindError{msg: "permission: invalid scope", kind: ErrInvalid}
	// ErrInvalidPolicy reports a policy registration that cannot be accepted:
	// a malformed action, a nil function, an interface resource type or a
	// duplicate registration. It matches ErrInvalid.
	ErrInvalidPolicy error = &kindError{msg: "permission: invalid policy", kind: ErrInvalid}
	// ErrRoleNotFound reports that a named role does not exist. It matches
	// ErrNotFound.
	ErrRoleNotFound error = &kindError{msg: "permission: role not found", kind: ErrNotFound}
	// ErrRoleExists reports an attempt to create a role whose name or ID is
	// taken. It matches ErrConflict.
	ErrRoleExists error = &kindError{msg: "permission: role already exists", kind: ErrConflict}
	// ErrPermissionNotFound reports that a permission is not registered. It
	// matches ErrNotFound.
	ErrPermissionNotFound error = &kindError{msg: "permission: permission not found", kind: ErrNotFound}
	// ErrForbidden is matched by every authorization denial returned by
	// Authorizer.Authorize. The concrete error is a *ForbiddenError.
	ErrForbidden = errors.New("permission: forbidden")
	// ErrNoPolicy is matched, in addition to ErrForbidden, when Authorize
	// denies because no policy is registered for the action and resource type.
	ErrNoPolicy = errors.New("permission: no policy registered")
)

Package-specific sentinels. Errors carrying details wrap one of these, so callers test them with errors.Is, and each also matches its family sentinel.

Functions

func Deny

func Deny(reason string) error

Deny returns an error that a policy function returns to deny access with a specific reason. Authorize turns it into a *ForbiddenError carrying that reason, the actor, the action and the resource type.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

type Document struct {
	OwnerID string
	Team    string
}

func main() {
	ctx := context.Background()
	authz, _ := permission.New(memstore.New(), permission.Options{})
	permission.MustRegisterPolicy(authz, "edit", func(ctx context.Context, actor permission.Subject, d *Document) (bool, error) {
		return false, permission.Deny("document is archived")
	})
	err := authz.Authorize(ctx, permission.Subject{Type: "user", ID: "alice"}, "edit", &Document{})
	fmt.Println(err)
}
Output:
permission: forbidden: document is archived

func Match

func Match(granted, p string) bool

Match reports whether a granted permission or pattern covers the concrete permission p, with the semantics described at ValidatePermission. It fails closed: when granted is not a valid permission or pattern, or p is not a valid concrete permission, Match returns false.

Example
package main

import (
	"fmt"

	"github.com/byteminus/go-permission"
)

func main() {
	fmt.Println(permission.Match("posts.*", "posts.comments.delete"))
	fmt.Println(permission.Match("posts.*", "posts"))
}
Output:
true
false

func MustRegisterPolicy

func MustRegisterPolicy[T any](a *Authorizer, action string, fn PolicyFunc[T])

MustRegisterPolicy is RegisterPolicy for program setup; it panics on a registration error, which is always a programming mistake.

func RegisterPolicy

func RegisterPolicy[T any](a *Authorizer, action string, fn PolicyFunc[T]) error

RegisterPolicy registers fn as the policy for action on resources of type T, such as "update" on *Post. Lookups use the exact dynamic type of the resource, so T must be a concrete type: a policy for *Post does not apply to Post. Registering the same action and type twice is an error. RegisterPolicy is safe to call concurrently with checks.

Example
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

type Document struct {
	OwnerID string
	Team    string
}

func main() {
	ctx := context.Background()
	authz, _ := permission.New(memstore.New(), permission.Options{})

	// Owners may edit their documents; others need the RBAC permission in
	// the document's team.
	permission.MustRegisterPolicy(authz, "edit", func(ctx context.Context, actor permission.Subject, d *Document) (bool, error) {
		if actor.Type == "user" && actor.ID == d.OwnerID {
			return true, nil
		}
		return authz.Can(ctx, actor, "documents.edit", d.Team)
	})

	doc := &Document{OwnerID: "alice", Team: "team-1"}
	alice := permission.Subject{Type: "user", ID: "alice"}
	bob := permission.Subject{Type: "user", ID: "bob"}

	fmt.Println(authz.Authorize(ctx, alice, "edit", doc))

	err := authz.Authorize(ctx, bob, "edit", doc)
	var denied *permission.ForbiddenError
	if errors.As(err, &denied) {
		fmt.Println(denied.Reason)
	}

	err = authz.Authorize(ctx, alice, "delete", doc)
	fmt.Println(errors.Is(err, permission.ErrForbidden), errors.Is(err, permission.ErrNoPolicy))
}
Output:
<nil>
policy denied action "edit" on *permission_test.Document
true true

func ScopeFrom

func ScopeFrom(ctx context.Context) (string, bool)

ScopeFrom returns the scope set by WithScope, and false when none was set. Its signature matches the tenant lookups of sibling packages, such as tenancy.TenantIDFrom, so either can serve as an httpperm.Options.ScopeFrom hook.

func ValidatePermission

func ValidatePermission(p string) error

ValidatePermission reports whether p is a valid permission or wildcard pattern. A permission is 1 to MaxPermissionLength bytes of dot-separated segments of lowercase ASCII letters, digits, '_' and '-', each starting with a letter or digit, such as "posts.publish".

Names are case-sensitive and lowercase only: "Posts.read" is rejected rather than treated as "posts.read", so two spellings can never name one permission. The last segment may be "*", which grants every permission below the prefix, matched by whole segments: "posts.*" grants "posts.publish" and "posts.comments.delete" but neither "posts" nor "postsecret.read". "*" alone grants every permission. A "*" anywhere else ("*.read", "posts.*.edit", "posts*") is rejected. The error matches ErrInvalidPermission and ErrInvalid.

func ValidateRole

func ValidateRole(name string) error

ValidateRole reports whether name is a valid role name: 1 to MaxNameLength bytes, starting with a lowercase letter or digit, followed by lowercase letters, digits, '_', '.', ':' or '-'. The error matches ErrInvalidRole and ErrInvalid.

func ValidateScope

func ValidateScope(scope string) error

ValidateScope reports whether scope is valid: up to MaxScopeLength bytes of UTF-8 without NUL. The empty scope is GlobalScope. Scopes are opaque and compared exactly, so "Team-1" and "team-1" are different scopes. The error matches ErrInvalidScope and ErrInvalid.

func ValidateSubject

func ValidateSubject(s Subject) error

ValidateSubject reports whether s is a storable subject: Type follows the role name rules, and ID is 1 to MaxSubjectIDLength bytes of UTF-8 without NUL. The zero Subject is not storable. The error names the failing field and matches ErrInvalidSubject and ErrInvalid.

func WithScope

func WithScope(ctx context.Context, scope string) context.Context

WithScope returns a context carrying the current scope, typically the tenant or team of the request. The httpperm middleware reads it by default and stores it for the handlers it lets through.

func WithSubject

func WithSubject(ctx context.Context, s Subject) context.Context

WithSubject returns a context carrying the acting subject. The httpperm middleware sets it for every request it lets through.

Types

type Authorizer

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

Authorizer answers permission, role and policy checks, and is the write path for roles and grants so that its cache stays consistent. It is safe for concurrent use.

func New

func New(store Store, opts Options) (*Authorizer, error)

New returns an Authorizer over store. It returns an error matching ErrInvalid when store is nil or opts is invalid.

func (*Authorizer) Allows

func (a *Authorizer) Allows(ctx context.Context, actor Subject, action string, resource any) bool

Allows is Authorize reduced to a bool for templates and conditionals. It fails closed: any error denies. Errors other than a plain policy denial, including a missing policy, reach Options.OnError through Authorize.

func (*Authorizer) AssignRole

func (a *Authorizer) AssignRole(ctx context.Context, subject Subject, role, scope string) error

AssignRole assigns role to subject in scope; GlobalScope makes it apply in every scope. Repeating an assignment is a no-op. It returns ErrRoleNotFound when the role is absent.

func (*Authorizer) Authorize

func (a *Authorizer) Authorize(ctx context.Context, actor Subject, action string, resource any) error

Authorize checks whether actor may perform action on resource and returns nil only when allowed; any non-nil error means the action is denied.

A denial is a *ForbiddenError matching ErrForbidden and carrying a reason. Resources without a registered policy are denied with an error that also matches ErrNoPolicy, even for super-admins, so missing registrations surface early. Super-admins (see Options) otherwise pass without consulting the policy, in the scope of the resource itself: the value of PermissionScope when the resource implements ScopedResource, and GlobalScope otherwise. The request's scope is deliberately not used, so a super-admin of one tenant cannot skip the policy of another tenant's resource; a scoped super-admin of the resource's own tenant still passes any policy that calls Can with that scope. Errors from the policy, the super-admin function or the store are returned wrapped and do not match ErrForbidden. Every error except a plain policy denial is passed to Options.OnError. The zero Subject may be passed as an anonymous actor; a half-empty Subject is rejected with ErrInvalidSubject.

func (*Authorizer) Can

func (a *Authorizer) Can(ctx context.Context, subject Subject, permission, scope string) (bool, error)

Can reports whether subject holds permission in scope, through a role or a direct grant made in GlobalScope or in exactly scope, or as a super-admin. The permission must be concrete (no wildcard). The anonymous zero Subject holds nothing.

Can fails closed: whenever it returns an error it also returns false, and the error is passed to Options.OnError. Errors match ErrInvalid for malformed input; store and super-admin errors are wrapped.

func (*Authorizer) CreateRole

func (a *Authorizer) CreateRole(ctx context.Context, name, description string) (Role, error)

CreateRole creates a role. It returns ErrRoleExists when the name is taken: a role's identity matters to everyone holding it, so silently reusing an existing role is not treated as success.

func (*Authorizer) DeletePermission

func (a *Authorizer) DeletePermission(ctx context.Context, permission string) error

DeletePermission retires a permission: it is removed from every role and subject and unregistered. It returns ErrPermissionNotFound when absent.

func (*Authorizer) DeleteRole

func (a *Authorizer) DeleteRole(ctx context.Context, name string) error

DeleteRole deletes a role, its permission grants and all its assignments. It returns ErrRoleNotFound when absent.

func (*Authorizer) Grant

func (a *Authorizer) Grant(ctx context.Context, subject Subject, permission, scope string) error

Grant gives subject a permission (or wildcard pattern) directly in scope. Repeating a grant is a no-op.

func (*Authorizer) GrantToRole

func (a *Authorizer) GrantToRole(ctx context.Context, role string, permissions ...string) error

GrantToRole grants permissions (or wildcard patterns) to a role, all or none. Granting a held permission is a no-op. It returns ErrRoleNotFound when the role is absent.

func (*Authorizer) HasAllRoles

func (a *Authorizer) HasAllRoles(ctx context.Context, subject Subject, roles []string, scope string) (bool, error)

HasAllRoles reports whether subject holds every one of roles in GlobalScope or in exactly scope. roles must not be empty. Like Can, it fails closed and reports errors to Options.OnError.

func (*Authorizer) HasAnyRole

func (a *Authorizer) HasAnyRole(ctx context.Context, subject Subject, roles []string, scope string) (bool, error)

HasAnyRole reports whether subject holds at least one of roles in GlobalScope or in exactly scope. roles must not be empty. Like Can, it fails closed and reports errors to Options.OnError.

func (*Authorizer) HasRole

func (a *Authorizer) HasRole(ctx context.Context, subject Subject, role, scope string) (bool, error)

HasRole reports whether subject is assigned role in GlobalScope or in exactly scope. The super-admin bypass does not apply: this answers a factual question. Like Can, it fails closed and reports errors to Options.OnError.

func (*Authorizer) Invalidate

func (a *Authorizer) Invalidate(inv Invalidation)

Invalidate drops cached effective sets, and makes any cache fill that started before the call discard its result, so a value loaded before a revocation is never cached after it. Wire it to a cross-instance notification source such as pgxstore.Listener; local writes made through this Authorizer invalidate on their own. It is safe to call concurrently with checks.

func (*Authorizer) ListPermissions

func (a *Authorizer) ListPermissions(ctx context.Context) ([]string, error)

ListPermissions returns every registered permission, ordered. A permission is registered the first time it is granted.

func (*Authorizer) ListRoles

func (a *Authorizer) ListRoles(ctx context.Context) ([]Role, error)

ListRoles returns all roles ordered by name.

func (*Authorizer) PermissionsFor

func (a *Authorizer) PermissionsFor(ctx context.Context, subject Subject, scope string) ([]string, error)

PermissionsFor returns the effective permission set of subject in scope: every permission or wildcard pattern granted through its roles or directly, in GlobalScope or in exactly scope, ordered and without duplicates. The super-admin bypass is not reflected here. It returns nil and reports the error to Options.OnError when the lookup fails.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

func main() {
	ctx := context.Background()
	authz, _ := permission.New(memstore.New(), permission.Options{})
	bot := permission.Subject{Type: "api_key", ID: "ci"}

	// Global grants apply in every scope; scoped grants only in theirs.
	_ = authz.Grant(ctx, bot, "builds.trigger", permission.GlobalScope)
	_ = authz.Grant(ctx, bot, "deploys.*", "staging")

	staging, _ := authz.PermissionsFor(ctx, bot, "staging")
	production, _ := authz.PermissionsFor(ctx, bot, "production")
	fmt.Println(staging)
	fmt.Println(production)
}
Output:
[builds.trigger deploys.*]
[builds.trigger]

func (*Authorizer) Revoke

func (a *Authorizer) Revoke(ctx context.Context, subject Subject, permission, scope string) error

Revoke removes a direct grant made in exactly scope. Removing an absent grant is a no-op. Permissions held through roles are unaffected.

func (*Authorizer) RevokeFromRole

func (a *Authorizer) RevokeFromRole(ctx context.Context, role string, permissions ...string) error

RevokeFromRole revokes permissions from a role, all or none. Revoking a permission the role does not hold is a no-op. It returns ErrRoleNotFound when the role is absent.

func (*Authorizer) Role

func (a *Authorizer) Role(ctx context.Context, name string) (Role, error)

Role returns the named role, or ErrRoleNotFound.

func (*Authorizer) RolePermissions

func (a *Authorizer) RolePermissions(ctx context.Context, role string) ([]string, error)

RolePermissions returns the permissions granted to a role, ordered. It returns ErrRoleNotFound when the role is absent.

func (*Authorizer) RolesFor

func (a *Authorizer) RolesFor(ctx context.Context, subject Subject, scope string) ([]string, error)

RolesFor returns the roles subject holds in scope, including GlobalScope assignments, ordered by name. It returns nil and reports the error to Options.OnError when the lookup fails; the anonymous subject holds an empty list.

func (*Authorizer) SubjectsWithRole

func (a *Authorizer) SubjectsWithRole(ctx context.Context, role, scope string, opts SubjectsOptions) (SubjectPage, error)

SubjectsWithRole returns a page of the subjects assigned role in exactly scope, so listing a team's admins does not include global admins. Pagination is keyset-based on (Type, ID), so pages stay consistent while assignments are added or removed. It returns ErrRoleNotFound when the role is absent and an error matching ErrInvalid for malformed input.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

func main() {
	ctx := context.Background()
	authz, _ := permission.New(memstore.New(), permission.Options{})
	_, _ = authz.CreateRole(ctx, "admin", "")
	for _, id := range []string{"carol", "alice", "bob"} {
		_ = authz.AssignRole(ctx, permission.Subject{Type: "user", ID: id}, "admin", "team-1")
	}

	// Walk every page; Next is zero after the last one.
	opts := permission.SubjectsOptions{Limit: 2}
	for {
		page, err := authz.SubjectsWithRole(ctx, "admin", "team-1", opts)
		if err != nil {
			panic(err)
		}
		fmt.Println(page.Subjects)
		if page.Next.IsZero() {
			break
		}
		opts.After = page.Next
	}
}
Output:
[user:alice user:bob]
[user:carol]

func (*Authorizer) UnassignRole

func (a *Authorizer) UnassignRole(ctx context.Context, subject Subject, role, scope string) error

UnassignRole removes the assignment of role to subject in exactly scope. Removing an absent assignment is a no-op. It returns ErrRoleNotFound when the role is absent, which catches misspelled role names.

type CacheBypass

type CacheBypass interface {
	BypassCache(ctx context.Context) bool
}

CacheBypass is implemented by stores that can tell when a read must not be cached. The PostgreSQL store reports true while the caller carries its own connection or transaction, because such reads can see uncommitted writes that a rollback would undo. The Authorizer calls it on every cacheable read; it must be cheap and safe for concurrent use.

type ForbiddenError

type ForbiddenError struct {
	// Actor is the subject that was denied.
	Actor Subject
	// Action is the policy action that was checked.
	Action string
	// Resource is the Go type of the resource, such as "*app.Post".
	Resource string
	// Reason is a human-readable explanation suitable for logs. It is not
	// meant to be shown to end users verbatim.
	Reason string
	// Err is an optional cause, such as ErrNoPolicy.
	Err error
}

ForbiddenError describes why an authorization was denied. It matches ErrForbidden with errors.Is, and also matches Err when Err is set.

func (*ForbiddenError) Error

func (e *ForbiddenError) Error() string

Error returns the denial with its reason.

func (*ForbiddenError) Is

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

Is reports whether target is ErrForbidden.

func (*ForbiddenError) Unwrap

func (e *ForbiddenError) Unwrap() error

Unwrap returns the optional cause.

type Grants

type Grants struct {
	// Roles are the role names held, ordered and without duplicates.
	Roles []string
	// Permissions are the permissions and wildcard patterns held, ordered
	// and without duplicates.
	Permissions []string
}

Grants is the effective set of a subject in a scope.

type Invalidation

type Invalidation struct {
	// All invalidates every cached entry. Role-level writes affect every
	// holder of the role and use it.
	All bool
	// Subject invalidates every scope cached for one subject when All is
	// false. A zero Subject with All false invalidates nothing.
	Subject Subject
}

Invalidation identifies cached effective sets that are out of date. It is what cross-instance notifications carry; pass received values to Authorizer.Invalidate.

type Options

type Options struct {
	// CacheTTL bounds how long an effective set is reused. Zero means
	// DefaultCacheTTL; a negative value disables caching. Local writes
	// invalidate immediately; the TTL bounds staleness from writes made by
	// other processes when no cross-instance listener is running.
	CacheTTL time.Duration
	// CacheMaxEntries caps the number of cached (subject, scope) pairs; a
	// full cache evicts an expired entry or the one closest to expiry. Zero
	// means DefaultCacheMaxEntries; negative is invalid.
	CacheMaxEntries int
	// SuperAdminRole, when set, names a role whose holders pass every Can
	// check in the scopes where they hold it: an assignment in GlobalScope
	// makes a super-admin everywhere, one in "team-1" only for checks made
	// in "team-1". Authorize applies it in the resource's scope (see
	// ScopedResource), never in the request's.
	SuperAdminRole string
	// SuperAdmin, when set, is consulted before any other check; returning
	// true grants access. It may be combined with SuperAdminRole.
	SuperAdmin func(ctx context.Context, subject Subject, scope string) (bool, error)
	// Now is the clock for cache expiry and role creation times. Nil means
	// time.Now.
	Now func() time.Time
	// OnError receives every error that ends a check (Can, HasRole,
	// HasAnyRole, HasAllRoles, RolesFor, PermissionsFor, Authorize and
	// Allows) with a denial, including invalid input, store and super-admin
	// failures and missing policies, so none is lost. A plain denial is not
	// an error and is not reported. Each error is reported once. Nil
	// discards them.
	OnError func(ctx context.Context, err error)
}

Options configures an Authorizer. The zero value is valid: caching with the defaults, no super-admin, the system clock.

type PolicyFunc

type PolicyFunc[T any] func(ctx context.Context, actor Subject, resource T) (bool, error)

PolicyFunc decides whether actor may perform an action on resource. It returns true to allow and false to deny; returning the error from Deny denies with a specific reason, and any other error denies and is reported as a failure. A policy may call the Authorizer's RBAC checks; the scope of the resource is usually a field of the resource itself.

type Role

type Role struct {
	ID          uuid.UUID
	Name        string
	Description string
	// CreatedAt is in UTC with microsecond precision.
	CreatedAt time.Time
}

Role groups permissions under a name.

func (Role) Validate

func (r Role) Validate() error

Validate reports whether r can be stored: a non-nil ID, a valid name, a description within MaxDescriptionLength and a non-zero creation time.

type ScopedResource added in v0.2.0

type ScopedResource interface {
	PermissionScope() string
}

ScopedResource is implemented by resources that belong to a scope, such as a tenant's post. Authorize uses PermissionScope for the super-admin bypass, so a super-admin role held in that scope passes without consulting the policy. PermissionScope must return a valid scope (see ValidateScope); an invalid one makes Authorize fail with ErrInvalidScope.

Example
package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-permission"
	"github.com/byteminus/go-permission/memstore"
)

type tenantKey struct{}

// Invoice belongs to a tenant and says so, so the super-admin bypass of
// Authorize applies in that tenant only.
type Invoice struct{ Tenant string }

func (i *Invoice) PermissionScope() string { return i.Tenant }

func main() {
	ctx := context.Background()
	authz, _ := permission.New(memstore.New(), permission.Options{SuperAdminRole: "owner"})
	_, _ = authz.CreateRole(ctx, "owner", "")
	alice := permission.Subject{Type: "user", ID: "alice"}
	_ = authz.AssignRole(ctx, alice, "owner", "acme")
	permission.MustRegisterPolicy(authz, "void", func(ctx context.Context, actor permission.Subject, i *Invoice) (bool, error) {
		return false, nil
	})

	// The request's tenant does not matter; the invoice's does.
	reqCtx := context.WithValue(ctx, tenantKey{}, "acme")
	fmt.Println(authz.Allows(reqCtx, alice, "void", &Invoice{Tenant: "acme"}))
	fmt.Println(authz.Allows(reqCtx, alice, "void", &Invoice{Tenant: "globex"}))
}
Output:
true
false

type Store

type Store interface {
	// CreateRole stores a new role. It returns ErrRoleExists when the name
	// or the ID is taken.
	CreateRole(ctx context.Context, role Role) error
	// DeleteRole removes a role together with its permission grants and
	// every assignment of it. It returns ErrRoleNotFound when absent. An
	// assignment that races with the deletion either completes first and
	// is removed with the role, or fails with ErrRoleNotFound; it never
	// survives the role.
	DeleteRole(ctx context.Context, name string) error
	// Role returns the named role, or ErrRoleNotFound.
	Role(ctx context.Context, name string) (Role, error)
	// ListRoles returns all roles ordered by name (byte order).
	ListRoles(ctx context.Context) ([]Role, error)

	// GrantToRole atomically grants permissions to a role, registering any
	// permission not seen before. Granting a held permission is a no-op.
	// It returns ErrRoleNotFound when the role is absent.
	GrantToRole(ctx context.Context, role string, permissions []string) error
	// RevokeFromRole atomically revokes permissions from a role. Revoking a
	// permission the role does not hold is a no-op. It returns
	// ErrRoleNotFound when the role is absent.
	RevokeFromRole(ctx context.Context, role string, permissions []string) error
	// RolePermissions returns the permissions granted to a role, ordered
	// (byte order). It returns ErrRoleNotFound when the role is absent.
	RolePermissions(ctx context.Context, role string) ([]string, error)

	// AssignRole assigns a role to a subject in a scope. Assigning an
	// existing assignment is a no-op. It returns ErrRoleNotFound when the
	// role is absent.
	AssignRole(ctx context.Context, subject Subject, role, scope string) error
	// UnassignRole removes an assignment. Removing an absent assignment is a
	// no-op. It returns ErrRoleNotFound when the role is absent.
	UnassignRole(ctx context.Context, subject Subject, role, scope string) error
	// SubjectsWithRole returns up to q.Limit subjects assigned q.Role in
	// exactly q.Scope and ordered after q.After, by Type then ID (byte
	// order). It returns ErrRoleNotFound when the role is absent and
	// ErrInvalid when q fails SubjectQuery.Validate.
	SubjectsWithRole(ctx context.Context, q SubjectQuery) ([]Subject, error)

	// Grant gives a subject a permission directly in a scope, registering
	// the permission if needed. Granting a held permission is a no-op.
	Grant(ctx context.Context, subject Subject, permission, scope string) error
	// Revoke removes a direct grant. Removing an absent grant is a no-op.
	Revoke(ctx context.Context, subject Subject, permission, scope string) error

	// ListPermissions returns every registered permission, ordered (byte
	// order). A permission is registered the first time it is granted.
	ListPermissions(ctx context.Context) ([]string, error)
	// DeletePermission unregisters a permission and removes it from every
	// role and subject. It returns ErrPermissionNotFound when absent.
	DeletePermission(ctx context.Context, permission string) error

	// Effective returns what a subject holds in scope: the roles assigned
	// in GlobalScope or in exactly scope, and the permissions granted by
	// those roles or directly, in GlobalScope or in exactly scope. Nothing
	// held in any other scope may appear. Both lists are ordered (byte
	// order) and free of duplicates, and are read from one consistent
	// snapshot.
	Effective(ctx context.Context, subject Subject, scope string) (Grants, error)
}

Store persists roles, role permissions, role assignments and direct permission grants.

Store is implemented by storage packages (memstore, pgxstore, or your own) and called by an Authorizer. Applications do not call a Store directly: writes go through an Authorizer, which keeps its cache consistent. The storetest package verifies an implementation against every guarantee listed here.

Implementations validate their inputs with the Validate functions of this package and return its sentinel errors, wrapped with details, so every store behaves the same. Each method is atomic on its own. Lists are never nil. Implementations must be safe for concurrent use.

type Subject

type Subject struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

Subject is anything that can hold roles and permissions: a user, an API key, a service account. Type namespaces the ID, so "user"/"42" and "api_key"/"42" are different subjects. Both fields are compared exactly (case-sensitive, byte for byte).

The zero Subject stands for an anonymous actor: it holds nothing and cannot be granted anything. A Subject with only one field set is invalid everywhere and is rejected with ErrInvalidSubject.

func SubjectFrom

func SubjectFrom(ctx context.Context) (Subject, bool)

SubjectFrom returns the subject set by WithSubject, and false when none was set.

func (Subject) IsZero

func (s Subject) IsZero() bool

IsZero reports whether s is the anonymous subject: both fields empty.

func (Subject) String

func (s Subject) String() string

String returns "type:id", for logs and display only. It is not unique, as Type may contain ':', so never use it as a map, cache or database key; the struct itself is comparable and serves as a key.

type SubjectPage added in v0.2.0

type SubjectPage struct {
	// Subjects is never nil.
	Subjects []Subject
	// Next is the After value for the following page, or the zero Subject
	// when this is the last page.
	Next Subject
}

SubjectPage is one page of subjects ordered by Type, then ID (byte order).

type SubjectQuery added in v0.2.0

type SubjectQuery struct {
	// Role is the role name.
	Role string
	// Scope is matched exactly: GlobalScope lists global assignments only.
	Scope string
	// After is exclusive: only subjects ordered after it, by Type then ID
	// in byte order, are returned. The zero Subject starts at the
	// beginning. Because (Type, ID) is unique per role and scope, pages
	// neither skip nor repeat subjects while others are being assigned.
	After Subject
	// Limit is the maximum number of subjects to return. It must be
	// positive.
	Limit int
}

SubjectQuery selects one page of the subjects assigned a role in a scope. Stores receive it from Authorizer.SubjectsWithRole.

func (SubjectQuery) Validate added in v0.2.0

func (q SubjectQuery) Validate() error

Validate reports whether q is well formed: a valid role and scope, an After that is zero or a valid Subject, and a positive Limit. The error matches ErrInvalid.

type SubjectsOptions added in v0.2.0

type SubjectsOptions struct {
	// After is the Next value of the previous page; the zero Subject starts
	// at the beginning.
	After Subject
	// Limit is the page size: zero means DefaultPageSize, and values above
	// MaxPageSize are capped. Negative values are invalid.
	Limit int
}

SubjectsOptions selects a page of Authorizer.SubjectsWithRole.

Directories

Path Synopsis
Package httpperm provides net/http middleware that requires a permission or a role before calling the next handler.
Package httpperm provides net/http middleware that requires a permission or a role before calling the next handler.
Package memstore is an in-memory permission.Store for tests, examples and single-process tools.
Package memstore is an in-memory permission.Store for tests, examples and single-process tools.
Package pgxstore is a PostgreSQL permission.Store built on pgx v5, with LISTEN/NOTIFY-based cache invalidation across application instances.
Package pgxstore is a PostgreSQL permission.Store built on pgx v5, with LISTEN/NOTIFY-based cache invalidation across application instances.
Package storetest is a conformance suite for permission.Store implementations.
Package storetest is a conformance suite for permission.Store implementations.

Jump to

Keyboard shortcuts

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