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 ¶
- Constants
- Variables
- func Deny(reason string) error
- func Match(granted, p string) bool
- func MustRegisterPolicy[T any](a *Authorizer, action string, fn PolicyFunc[T])
- func RegisterPolicy[T any](a *Authorizer, action string, fn PolicyFunc[T]) error
- func ScopeFrom(ctx context.Context) (string, bool)
- func ValidatePermission(p string) error
- func ValidateRole(name string) error
- func ValidateScope(scope string) error
- func ValidateSubject(s Subject) error
- func WithScope(ctx context.Context, scope string) context.Context
- func WithSubject(ctx context.Context, s Subject) context.Context
- type Authorizer
- func (a *Authorizer) Allows(ctx context.Context, actor Subject, action string, resource any) bool
- func (a *Authorizer) AssignRole(ctx context.Context, subject Subject, role, scope string) error
- func (a *Authorizer) Authorize(ctx context.Context, actor Subject, action string, resource any) error
- func (a *Authorizer) Can(ctx context.Context, subject Subject, permission, scope string) (bool, error)
- func (a *Authorizer) CreateRole(ctx context.Context, name, description string) (Role, error)
- func (a *Authorizer) DeletePermission(ctx context.Context, permission string) error
- func (a *Authorizer) DeleteRole(ctx context.Context, name string) error
- func (a *Authorizer) Grant(ctx context.Context, subject Subject, permission, scope string) error
- func (a *Authorizer) GrantToRole(ctx context.Context, role string, permissions ...string) error
- func (a *Authorizer) HasAllRoles(ctx context.Context, subject Subject, roles []string, scope string) (bool, error)
- func (a *Authorizer) HasAnyRole(ctx context.Context, subject Subject, roles []string, scope string) (bool, error)
- func (a *Authorizer) HasRole(ctx context.Context, subject Subject, role, scope string) (bool, error)
- func (a *Authorizer) Invalidate(inv Invalidation)
- func (a *Authorizer) ListPermissions(ctx context.Context) ([]string, error)
- func (a *Authorizer) ListRoles(ctx context.Context) ([]Role, error)
- func (a *Authorizer) PermissionsFor(ctx context.Context, subject Subject, scope string) ([]string, error)
- func (a *Authorizer) Revoke(ctx context.Context, subject Subject, permission, scope string) error
- func (a *Authorizer) RevokeFromRole(ctx context.Context, role string, permissions ...string) error
- func (a *Authorizer) Role(ctx context.Context, name string) (Role, error)
- func (a *Authorizer) RolePermissions(ctx context.Context, role string) ([]string, error)
- func (a *Authorizer) RolesFor(ctx context.Context, subject Subject, scope string) ([]string, error)
- func (a *Authorizer) SubjectsWithRole(ctx context.Context, role, scope string, opts SubjectsOptions) (SubjectPage, error)
- func (a *Authorizer) UnassignRole(ctx context.Context, subject Subject, role, scope string) error
- type CacheBypass
- type ForbiddenError
- type Grants
- type Invalidation
- type Options
- type PolicyFunc
- type Role
- type ScopedResource
- type Store
- type Subject
- type SubjectPage
- type SubjectQuery
- type SubjectsOptions
Examples ¶
Constants ¶
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.
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.
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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
Grant gives subject a permission (or wildcard pattern) directly in scope. Repeating a grant is a no-op.
func (*Authorizer) GrantToRole ¶
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 ¶
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 ¶
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) RolePermissions ¶
RolePermissions returns the permissions granted to a role, ordered. It returns ErrRoleNotFound when the role is absent.
func (*Authorizer) RolesFor ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
SubjectFrom returns the subject set by WithSubject, and false when none was set.
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.
Source Files
¶
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. |