Documentation
¶
Overview ¶
Package wardn is simple, opinionated authorization for Go.
Products define their own permissions, roles and scope hierarchy in a single policy file; wardn stores role assignments, evaluates access on every request, protects HTTP routes, and supports admin-created custom roles with built-in protection against privilege escalation.
wardn follows the compatibility promise in docs/STABILITY.md: nothing in v1 breaks code, data or decisions that rely on v1.0.0. See docs/DESIGN.md for the design and docs/THREAT_MODEL.md for its security model.
Example ¶
package main
import (
"context"
"fmt"
"github.com/MogRex-Limited/wardn"
)
const examplePolicy = `
scopes:
organisation: {}
branch: { parent: organisation }
permissions:
customer:view: View customers
loan:create: Create loan applications
loan:approve: Approve loans
roles:
org_admin:
assignable_at: [organisation]
allow: ["*"]
teller:
assignable_at: [branch]
allow: [customer:view, loan:create]
`
func main() {
policy, err := wardn.ParsePolicy([]byte(examplePolicy), "wardn.yaml")
if err != nil {
panic(err)
}
w := wardn.MustNew(wardn.Config{Policy: policy}) // production: Config{File: "wardn.yaml", Store: ...}
ctx := context.Background()
org := wardn.NewScope("organisation", "acme")
branch := wardn.NewScope("branch", "lagos")
_ = w.Link(ctx, branch, org) // the Lagos branch belongs to Acme
_ = w.System().Assign(ctx, "ada", "org_admin", org)
_ = w.System().Assign(ctx, "bayo", "teller", branch)
fmt.Println(w.Can(ctx, "ada", "loan:approve", branch)) // inherited from the organisation
fmt.Println(w.Can(ctx, "bayo", "loan:create", branch)) // teller
fmt.Println(w.Can(ctx, "bayo", "loan:approve", branch)) // tellers can't approve
d, _ := w.Check(ctx, "bayo", "loan:approve", branch)
fmt.Println(d.Reason)
}
Output: true true false no role grants "loan:approve" at branch:lagos
Index ¶
- Constants
- Variables
- func DecodeMemberCursor(cursor string) (user, role string, err error)
- func EncodeMemberCursor(user, role string) string
- func HTTPStatus(err error) int
- func PathValue(name string) func(*http.Request) string
- func UserOf(ctx context.Context) (string, bool)
- func WithScope(ctx context.Context, s Scope) context.Context
- func WithUser(ctx context.Context, userID string) context.Context
- type AssignOption
- type Assignment
- type AssignmentStore
- type Cache
- type Clock
- type Config
- type CustomRole
- type Decision
- type DeleteOption
- type DeniedError
- type Event
- type EventType
- type MemoryCache
- func (c *MemoryCache) Counters(ctx context.Context, keys []string) ([]uint64, error)
- func (c *MemoryCache) Get(ctx context.Context, key string) ([]byte, bool, error)
- func (c *MemoryCache) Incr(ctx context.Context, keys []string) error
- func (c *MemoryCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
- type MemoryCacheOption
- type MemoryStore
- func (m *MemoryStore) Assign(ctx context.Context, a Assignment) error
- func (m *MemoryStore) Assignments(ctx context.Context, user string, scopes []Scope) ([]Assignment, error)
- func (m *MemoryStore) CreateRole(ctx context.Context, r CustomRole) error
- func (m *MemoryStore) DeleteRole(ctx context.Context, id string) error
- func (m *MemoryStore) ExpiredUnreported(ctx context.Context, t time.Time, limit int) ([]Assignment, error)
- func (m *MemoryStore) Link(ctx context.Context, child, parent Scope) error
- func (m *MemoryStore) MarkExpiryReported(ctx context.Context, as []Assignment) error
- func (m *MemoryStore) Members(ctx context.Context, scope Scope, cursor string, limit int) ([]Assignment, string, error)
- func (m *MemoryStore) Parent(ctx context.Context, child Scope) (Scope, bool, error)
- func (m *MemoryStore) Revoke(ctx context.Context, user, role string, scope Scope) error
- func (m *MemoryStore) RevokeExpired(ctx context.Context, a Assignment) error
- func (m *MemoryStore) RevokeRole(ctx context.Context, role string) error
- func (m *MemoryStore) RoleInUse(ctx context.Context, role string) (bool, error)
- func (m *MemoryStore) Roles(ctx context.Context, ids []string) ([]CustomRole, error)
- func (m *MemoryStore) RolesOwnedBy(ctx context.Context, owners []Scope) ([]CustomRole, error)
- func (m *MemoryStore) UpdateRole(ctx context.Context, r CustomRole, expectedVersion int) error
- type Migrator
- type Permission
- type Policy
- type PolicyError
- type PolicyIssue
- type Role
- type RoleInfo
- type RoleSpec
- type RoleStore
- type Scope
- type ScopeStore
- type ScopeType
- type Store
- type SweepOption
- type System
- func (s *System) Assign(ctx context.Context, user, role string, scope Scope, opts ...AssignOption) error
- func (s *System) CreateRole(ctx context.Context, owner Scope, spec RoleSpec) (CustomRole, error)
- func (s *System) DeleteRole(ctx context.Context, id string, opts ...DeleteOption) error
- func (s *System) Revoke(ctx context.Context, user, role string, scope Scope) error
- func (s *System) UpdateRole(ctx context.Context, id string, spec RoleSpec, version int) (CustomRole, error)
- type TxStore
- type UndefinedError
- type Warden
- func (w *Warden) Assign(ctx context.Context, user, role string, scope Scope, opts ...AssignOption) error
- func (w *Warden) AssignableRoles(ctx context.Context, scope Scope) ([]RoleInfo, error)
- func (w *Warden) Can(ctx context.Context, user, permission string, scope Scope) bool
- func (w *Warden) CanAll(ctx context.Context, user string, scope Scope, permissions ...string) bool
- func (w *Warden) CanAny(ctx context.Context, user string, scope Scope, permissions ...string) bool
- func (w *Warden) CanEach(ctx context.Context, user, permission string, scopes []Scope) []bool
- func (w *Warden) Catalog() []Permission
- func (w *Warden) Check(ctx context.Context, user, permission string, scope Scope) (Decision, error)
- func (w *Warden) CheckEach(ctx context.Context, user, permission string, scopes []Scope) ([]Decision, error)
- func (w *Warden) CreateRole(ctx context.Context, owner Scope, spec RoleSpec) (CustomRole, error)
- func (w *Warden) CustomRole(ctx context.Context, id string) (CustomRole, error)
- func (w *Warden) DeleteRole(ctx context.Context, id string, opts ...DeleteOption) error
- func (w *Warden) InvalidateCache(ctx context.Context, scopes ...Scope) error
- func (w *Warden) Link(ctx context.Context, child, parent Scope) error
- func (w *Warden) Members(ctx context.Context, scope Scope, cursor string, limit int) ([]Assignment, string, error)
- func (w *Warden) Move(ctx context.Context, child, newParent Scope) error
- func (w *Warden) Now() time.Time
- func (w *Warden) PermissionsOf(ctx context.Context, user string, scope Scope) ([]string, error)
- func (w *Warden) Require(permissions ...string) func(http.Handler) http.Handler
- func (w *Warden) RequireAny(permissions ...string) func(http.Handler) http.Handler
- func (w *Warden) Revoke(ctx context.Context, user, role string, scope Scope) error
- func (w *Warden) Roles(ctx context.Context, scope Scope) ([]RoleInfo, error)
- func (w *Warden) RolesOf(ctx context.Context, user string, scope Scope) ([]Assignment, error)
- func (w *Warden) RunExpiry(ctx context.Context, interval time.Duration, onError func(error), ...)
- func (w *Warden) ScopeFrom(scopeType string, id func(*http.Request) string) func(http.Handler) http.Handler
- func (w *Warden) SweepExpired(ctx context.Context, opts ...SweepOption) (int, error)
- func (w *Warden) System() *System
- func (w *Warden) SystemRoles() []string
- func (w *Warden) Transaction(ctx context.Context) (context.Context, func(context.Context) error)
- func (w *Warden) UpdateRole(ctx context.Context, id string, spec RoleSpec, version int) (CustomRole, error)
- func (w *Warden) WithMemo(ctx context.Context) context.Context
Examples ¶
Constants ¶
const ( // PermRolesManage allows creating, editing and deleting custom roles. PermRolesManage = "wardn:roles.manage" // PermRolesAssign allows assigning and revoking roles. PermRolesAssign = "wardn:roles.assign" )
Built-in permissions. Every policy's catalog includes them automatically; grant them to roles like any other permission.
const DefaultCacheTTL = 5 * time.Second
DefaultCacheTTL is how long cache entries live when Config.CacheTTL is 0.
const DefaultPolicyFile = "wardn.yaml"
DefaultPolicyFile is the policy file New reads when Config sets neither File nor Policy.
const MaxCheckEach = 1000
MaxCheckEach is the most scopes one CheckEach or CanEach call accepts. Page longer lists.
Variables ¶
var ( // ErrInvalidInput reports a malformed user ID, scope, role name, // permission or time window. ErrInvalidInput = errors.New("wardn: invalid input") // ErrNotAssignable reports an attempt to assign a role at a scope type // its assignable_at list does not include. ErrNotAssignable = errors.New("wardn: role is not assignable at this scope") // ErrInvalidLink reports a scope link that contradicts the policy's // scope hierarchy, or a stored link that does. ErrInvalidLink = errors.New("wardn: invalid scope link") // ErrEscalation reports an attempt to create, edit, assign or revoke a // role containing permissions the acting user does not hold (the // escalation guard, design §6.2). ErrEscalation = errors.New("wardn: you cannot grant permissions you do not hold") // ErrRoleInUse reports an attempt to delete a custom role that is still // assigned, without wardn.Cascade. ErrRoleInUse = errors.New("wardn: role is still assigned") // ErrAlreadyLinked reports a Link to a different parent than the scope // already has. It usually means two tenants reuse an ID. Use Move to // change a parent on purpose. It also matches ErrInvalidLink. ErrAlreadyLinked = errors.New("wardn: scope already has a different parent") // ErrLinksExternal reports a call to Link when scope parents are // resolved by Config.ParentOf instead of stored by wardn. ErrLinksExternal = errors.New("wardn: scope parents are resolved by Config.ParentOf; Link is disabled") )
Sentinel errors. Errors returned by wardn wrap these, so test for them with errors.Is.
var ( // ErrUnauthenticated reports a request with no authenticated user. ErrUnauthenticated = errors.New("wardn: no authenticated user") // ErrDenied reports a request whose user lacks the required permissions. // The error is a *DeniedError carrying the decisions. ErrDenied = errors.New("wardn: permission denied") )
HTTP errors passed to Config.OnError. Test for them with errors.Is.
var ( // ErrRoleNameTaken: a custom role with the same normalised name already // exists in the owner scope. ErrRoleNameTaken = errors.New("wardn: a role with this name already exists here") // ErrRoleNotFound: no custom role has the given ID. ErrRoleNotFound = errors.New("wardn: role not found") // ErrConflict: the role was changed by someone else since it was read. ErrConflict = errors.New("wardn: role was changed by someone else; reload and try again") )
Store errors. Stores return these (possibly wrapped) so wardn can react.
var ErrAuditRejected = errors.New("wardn: the audit hook rejected the change")
ErrAuditRejected wraps an error returned by Config.OnChange, which stops the change (fail closed).
var ErrCacheInvalidation = errors.New("wardn: change saved, but invalidating the cache failed")
ErrCacheInvalidation reports that a change was saved, but invalidating the cache afterwards failed: other instances may serve the old state for up to the cache TTL. Retrying the (idempotent) change is safe.
var ErrNoTransaction = errors.New("wardn: a change inside an application transaction needs a context from Warden.Transaction")
ErrNoTransaction reports a change made with a context that carries an application transaction, but did not come from Warden.Transaction. wardn refuses it, because it could not keep its cache in step with the commit.
var Global = Scope{}
Global is the implicit scope above every other scope. A role assigned at Global applies everywhere.
Functions ¶
func DecodeMemberCursor ¶
DecodeMemberCursor parses a cursor made by EncodeMemberCursor.
func EncodeMemberCursor ¶
EncodeMemberCursor builds a Members cursor from the last user and role of a page. Stores may use it, or any opaque encoding of their own.
func HTTPStatus ¶
HTTPStatus returns the HTTP status code for an error from wardn: 401 when no user is authenticated; 403 when denied or when the escalation guard refuses; 400 for invalid input; 404 for an unknown custom role (also what an actor without wardn:roles.manage sees for an existing one); 409 for an edit conflict, a taken name, a role still in use or a scope already linked elsewhere; and 500 for anything else, such as a store failure. Admin APIs can use it for management errors too.
func PathValue ¶
PathValue returns a function that reads a path parameter, for use with ScopeFrom. It works with net/http's ServeMux and with chi.
Types ¶
type AssignOption ¶
type AssignOption func(*assignWindow)
AssignOption limits an assignment in time: From or Until. Options can set only the time window, never the user, role or scope that the guard and the audit trail see.
type Assignment ¶
type Assignment struct {
User string `json:"user"`
Role string `json:"role"` // a system role name, or a custom role ID ("cr-...")
Scope Scope `json:"scope"`
// StartsAt and ExpiresAt bound when the assignment applies. The zero
// time means "no bound". The window is half-open: it applies from
// StartsAt (inclusive) until ExpiresAt (exclusive).
StartsAt time.Time `json:"starts_at,omitzero"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
}
Assignment records that a user holds a role in a scope, optionally only within a time window.
type AssignmentStore ¶
type AssignmentStore interface {
// Assign saves an assignment. There is at most one assignment per
// (user, role, scope): assigning again replaces the time window and
// clears any expiry report.
Assign(ctx context.Context, a Assignment) error
// Revoke deletes the assignment of role to user at scope, if any.
// Revoking an assignment that does not exist is not an error.
Revoke(ctx context.Context, user, role string, scope Scope) error
// Assignments returns the user's assignments at any of the given
// scopes, regardless of time window. It must never return assignments
// for other users or other scopes.
Assignments(ctx context.Context, user string, scopes []Scope) ([]Assignment, error)
// Members returns up to limit assignments at exactly scope, ordered by
// user then role, starting after cursor ("" for the first page). It
// returns the cursor for the next page, or "" after the last page.
// Cursors are opaque to callers.
Members(ctx context.Context, scope Scope, cursor string, limit int) ([]Assignment, string, error)
// RoleInUse reports whether any assignment refers to role.
RoleInUse(ctx context.Context, role string) (bool, error)
// RevokeRole deletes every assignment of role.
RevokeRole(ctx context.Context, role string) error
// ExpiredUnreported returns up to limit assignments whose ExpiresAt is
// at or before t and that have not been marked as reported.
ExpiredUnreported(ctx context.Context, t time.Time, limit int) ([]Assignment, error)
// MarkExpiryReported records that the given assignments' expiry was
// reported. An assignment whose time window has changed since it was
// read (it was assigned again) must not be marked.
MarkExpiryReported(ctx context.Context, as []Assignment) error
// RevokeExpired deletes the assignment only if its ExpiresAt still
// equals a.ExpiresAt. If it was assigned again with a new window, it
// stays. The check and the deletion must be atomic.
RevokeExpired(ctx context.Context, a Assignment) error
}
AssignmentStore persists role assignments.
type Cache ¶
type Cache interface {
// Get returns the value stored under key, and false if there is none
// or it has expired.
Get(ctx context.Context, key string) ([]byte, bool, error)
// Set stores value under key for ttl.
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
// Counters returns the current values of counters; a counter that has
// never been incremented is 0. Counters never expire.
Counters(ctx context.Context, keys []string) ([]uint64, error)
// Incr atomically increments each counter by one.
Incr(ctx context.Context, keys []string) error
}
Cache is an optional cache shared across requests, and across instances of the application when the implementation is shared (Redis, for example). It is off by default (design decision D1).
wardn caches what it reads from the store (assignments, scope parents, custom roles), never decisions: every check is still evaluated, against the clock, so time-limited access starts and ends on time however long an entry is cached. Entries are invalidated by version counters: every cache key includes the current counters of the tenant it belongs to, and every change increments them before and after writing, so stale entries become unreachable everywhere at once. The TTL is only a safety net, for changes made directly in the database, bypassing wardn.
Implementations must be safe for concurrent use and must keep Incr atomic. Keys are opaque, fixed-length hashes; wardn validates everything it reads back. A shared cache must be private to the application (threat model T24).
type Config ¶
type Config struct {
// File is the path of a YAML policy file. Default: "wardn.yaml".
File string
// Policy is a policy defined in Go, used instead of File.
Policy *Policy
// Store persists assignments and scope links. Default: a new
// MemoryStore, which is lost on exit. Use a database store in
// production.
Store Store
// ParentOf, if set, resolves a scope's parent from the application's own
// data instead of links stored with Link. It must return the true parent
// from authoritative data; returning ok=false means "no parent".
ParentOf func(ctx context.Context, child Scope) (parent Scope, ok bool, err error)
// Cache, if set, caches store reads across requests (see Cache). Off
// by default: without it every request reads the store, so a change is
// visible on the very next request. Use a shared cache when running
// more than one instance.
Cache Cache
// CacheSecret seals cache entries so they cannot be forged by whoever
// can write to a shared cache. Required with a shared Cache: at least 32
// random bytes from your secret store, the same on every instance.
// Optional with NewMemoryCache.
CacheSecret []byte
// CacheTTL is how long cache entries live (default 5s, at most 5m). It
// only matters for changes made behind wardn's back; wardn's own
// changes invalidate the cache immediately.
CacheTTL time.Duration
// ManualMigrations stops New from migrating the Store's database
// schema. Set it if your deploy pipeline runs migrations itself.
ManualMigrations bool
// Clock supplies the current time for time-limited assignments.
// Default: the system clock. Tests can inject a fake clock.
Clock Clock
// UserFrom, if set, returns the authenticated user's ID for a request.
// By default wardn's HTTP middleware reads it from the request context,
// where the application's authentication middleware put it with
// WithUser.
UserFrom func(r *http.Request) (userID string, ok bool)
// OnError, if set, writes the response when wardn's HTTP middleware
// rejects a request. Use HTTPStatus(err) for the status code. By default
// wardn writes a generic JSON error that does not reveal which
// permission was missing.
OnError func(w http.ResponseWriter, r *http.Request, err error)
// OnChange, if set, is called before every change to assignments,
// custom roles and scope links, with the acting user, for the audit
// trail. If it returns an error, the change is not made (fail closed).
// If the change then fails, it is called again with Event.Err set.
OnChange func(ctx context.Context, e Event) error
// OnDecision, if set, is called with every authorization decision and
// any error, for audit logging and debugging. It must be fast and must
// not panic.
OnDecision func(ctx context.Context, d Decision, err error)
}
Config configures a Warden.
type CustomRole ¶
type CustomRole struct {
ID string `json:"id"` // generated; starts with "cr-", so it never clashes with a system role name
Owner Scope `json:"owner"` // where the role was created
Name string `json:"name"` // display name, unique within the owner scope and its ancestors
NameKey string `json:"name_key"` // normalised name used for uniqueness; set by wardn
Description string `json:"description"` //
// Allow lists the permissions the role grants: concrete permissions or
// wildcards from the catalog.
Allow []string `json:"allow"`
// AssignableAt lists the scope types where the role may be assigned:
// the owner's type and types below it. Default: the owner's type.
AssignableAt []string `json:"assignable_at"`
Version int `json:"version"` // incremented on every update; used to detect conflicting edits
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
CustomRole is a role created at runtime, usually by an administrator. It belongs to an owner scope and applies only within that scope's subtree. Custom roles only grant; they cannot deny (design decision D2).
type Decision ¶
type Decision struct {
Allowed bool `json:"allowed"`
User string `json:"user"`
Permission string `json:"permission"`
Scope Scope `json:"scope"` // the scope that was checked
// Role is the role that decided the outcome: the role whose deny rule
// matched, or the role that granted the permission. Empty when no role
// matched or an error occurred.
Role string `json:"role,omitempty"`
// At is the scope where Role is assigned: Scope itself or an ancestor.
At Scope `json:"at,omitzero"`
// Rule is the allow or deny pattern that matched, e.g. "loan:*".
Rule string `json:"rule,omitempty"`
// Reason explains the decision in words, for logs and debugging.
Reason string `json:"reason"`
}
Decision is the outcome of an authorization check, with the reason.
type DeleteOption ¶
type DeleteOption func(*deleteOptions)
DeleteOption configures DeleteRole.
func Cascade ¶
func Cascade() DeleteOption
Cascade makes DeleteRole also revoke every assignment of the role. Without it, deleting a role that is still assigned fails with ErrRoleInUse.
type DeniedError ¶
type DeniedError struct {
Decisions []Decision
}
DeniedError is returned when a request is denied. It wraps ErrDenied and carries the decisions, so a custom OnError can log why.
func (*DeniedError) Error ¶
func (e *DeniedError) Error() string
func (*DeniedError) Unwrap ¶
func (e *DeniedError) Unwrap() error
Unwrap makes errors.Is(err, ErrDenied) true.
type Event ¶
type Event struct {
Type EventType `json:"type"`
// Actor is the acting user's ID. It is empty when System is true.
Actor string `json:"actor,omitempty"`
// System is true where no user is acting: changes made through
// Warden.System, Link and Move called with no signed-in user in ctx,
// and the expiry sweeper.
System bool `json:"system"`
At time.Time `json:"at,omitzero"`
User string `json:"user,omitempty"` // assignments: whose access changed
Role string `json:"role,omitempty"` // assignments and roles: system role name or custom role ID
Scope Scope `json:"scope,omitzero"` // assignments: where; roles: the owner; links and moves: the child
Parent Scope `json:"parent,omitzero"` // links and moves: the new parent
// PreviousParent is the parent before a move (EventMoved).
PreviousParent Scope `json:"previous_parent,omitzero"`
StartsAt time.Time `json:"starts_at,omitzero"` // assignments: the time window
ExpiresAt time.Time `json:"expires_at,omitzero"`
Before *CustomRole `json:"before,omitempty"` // role updates and deletions
After *CustomRole `json:"after,omitempty"` // role creations and updates
// Err is set when OnChange is called a second time for the same change
// because it failed after OnChange approved it. A failed change may
// have been partly applied (for example, a cascade deletion that deleted
// the role but not all its assignments); the store is the source of
// truth. OnChange's return value is ignored on that call.
Err error `json:"-"`
}
Event describes a change to who can do what, for the audit trail (Config.OnChange). Fields that do not apply to a type are zero.
Events marshal to JSON with stable snake_case field names, leaving out fields that do not apply; Err is written as its message ("error").
func (Event) MarshalJSON ¶
MarshalJSON writes the event with Err as its message, under "error".
func (*Event) UnmarshalJSON ¶
UnmarshalJSON reads an event written by MarshalJSON. A recorded error comes back as an error with the same message.
type EventType ¶
type EventType string
EventType names a kind of change.
const ( EventAssigned EventType = "assignment.created" EventRevoked EventType = "assignment.revoked" EventExpired EventType = "assignment.expired" EventRoleCreated EventType = "role.created" EventRoleUpdated EventType = "role.updated" EventRoleDeleted EventType = "role.deleted" EventLinked EventType = "scope.linked" EventMoved EventType = "scope.moved" )
Event types.
type MemoryCache ¶
type MemoryCache struct {
// contains filtered or unexported fields
}
MemoryCache is a Cache held in this process. It is correct only when a single instance of the application runs: other instances would not see its invalidations. Use a shared cache (wardn/cache/redis) when you run more than one.
func NewMemoryCache ¶
func NewMemoryCache(opts ...MemoryCacheOption) *MemoryCache
NewMemoryCache returns an empty MemoryCache.
type MemoryCacheOption ¶
type MemoryCacheOption func(*MemoryCache)
MemoryCacheOption configures a MemoryCache.
func WithCacheClock ¶
func WithCacheClock(now func() time.Time) MemoryCacheOption
WithCacheClock sets the clock used for entry expiry, for tests.
func WithMaxEntries ¶
func WithMaxEntries(n int) MemoryCacheOption
WithMaxEntries bounds the number of cached entries (default 100 000).
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is a Store that keeps everything in memory. Its contents are lost when the process exits, so it is meant for tests, examples and demos. Use a database-backed Store in production.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an empty MemoryStore.
func (*MemoryStore) Assign ¶
func (m *MemoryStore) Assign(ctx context.Context, a Assignment) error
Assign implements Store.
func (*MemoryStore) Assignments ¶
func (m *MemoryStore) Assignments(ctx context.Context, user string, scopes []Scope) ([]Assignment, error)
Assignments implements Store.
func (*MemoryStore) CreateRole ¶
func (m *MemoryStore) CreateRole(ctx context.Context, r CustomRole) error
CreateRole implements Store.
func (*MemoryStore) DeleteRole ¶
func (m *MemoryStore) DeleteRole(ctx context.Context, id string) error
DeleteRole implements Store.
func (*MemoryStore) ExpiredUnreported ¶
func (m *MemoryStore) ExpiredUnreported(ctx context.Context, t time.Time, limit int) ([]Assignment, error)
ExpiredUnreported implements Store.
func (*MemoryStore) Link ¶
func (m *MemoryStore) Link(ctx context.Context, child, parent Scope) error
Link implements Store.
func (*MemoryStore) MarkExpiryReported ¶
func (m *MemoryStore) MarkExpiryReported(ctx context.Context, as []Assignment) error
MarkExpiryReported implements Store.
func (*MemoryStore) Members ¶
func (m *MemoryStore) Members(ctx context.Context, scope Scope, cursor string, limit int) ([]Assignment, string, error)
Members implements Store.
func (*MemoryStore) RevokeExpired ¶
func (m *MemoryStore) RevokeExpired(ctx context.Context, a Assignment) error
RevokeExpired implements Store.
func (*MemoryStore) RevokeRole ¶
func (m *MemoryStore) RevokeRole(ctx context.Context, role string) error
RevokeRole implements Store.
func (*MemoryStore) Roles ¶
func (m *MemoryStore) Roles(ctx context.Context, ids []string) ([]CustomRole, error)
Roles implements Store.
func (*MemoryStore) RolesOwnedBy ¶
func (m *MemoryStore) RolesOwnedBy(ctx context.Context, owners []Scope) ([]CustomRole, error)
RolesOwnedBy implements Store.
func (*MemoryStore) UpdateRole ¶
func (m *MemoryStore) UpdateRole(ctx context.Context, r CustomRole, expectedVersion int) error
UpdateRole implements Store.
type Migrator ¶
Migrator is implemented by stores that manage their own database schema. New calls Migrate at startup unless Config.ManualMigrations is set.
type Permission ¶
Permission is an entry in the permission catalog.
type Policy ¶
type Policy struct {
// Scopes declares the scope types and how they nest. Leave it empty for
// a product without tenancy, which uses only the Global scope.
Scopes map[string]ScopeType
// Permissions is the catalog: every permission the code checks, mapped
// to a human-readable description. The built-in wardn:... permissions
// are added automatically and must not be declared.
Permissions map[string]string
// Roles are the system roles, keyed by name.
Roles map[string]Role
// contains filtered or unexported fields
}
Policy is a product's authorization model: its scope types, the catalog of permissions its code checks, and its system roles. It is usually loaded from a YAML file with LoadPolicyFile, but can be built in Go.
func LoadPolicyFile ¶
LoadPolicyFile reads and parses a YAML policy file. It checks the file's structure; New (or Policy.Validate) checks its meaning.
func ParsePolicy ¶
ParsePolicy parses a YAML policy. source names it in error messages, usually the file name.
type PolicyError ¶
type PolicyError struct {
Source string // the file name, or "policy" for a Go-defined policy
Issues []PolicyIssue // sorted by line, then path
}
PolicyError reports every problem found in a policy, so they can all be fixed at once.
func (*PolicyError) Error ¶
func (e *PolicyError) Error() string
type PolicyIssue ¶
type PolicyIssue struct {
Line int // line in the source file; 0 if unknown
Path string // location in the policy, e.g. "roles.teller.allow[1]"
Message string
}
PolicyIssue is one problem in a policy.
type Role ¶
type Role struct {
Description string
// Allow lists the permissions this role grants. Entries may be concrete
// permissions ("loan:create"), resource wildcards ("loan:*"), or "*".
Allow []string
// Deny lists permissions this role takes away. A deny from any role
// overrides an allow from every role.
Deny []string
// AssignableAt lists the scope types (or "global") at which this role
// may be assigned. Empty means anywhere.
AssignableAt []string
}
Role is a system role: a named set of permission patterns.
type RoleInfo ¶
type RoleInfo struct {
ID string `json:"id"` // system role name, or custom role ID
Name string `json:"name"` // display name (the system role name for system roles)
Description string `json:"description"`
Allow []string `json:"allow"`
Deny []string `json:"deny,omitempty"` // system roles only
AssignableAt []string `json:"assignable_at,omitempty"` // empty: anywhere
Custom bool `json:"custom"`
Owner Scope `json:"owner,omitzero"` // custom roles only
Version int `json:"version,omitzero"` // custom roles only
}
RoleInfo describes a role for admin screens.
type RoleSpec ¶
type RoleSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Allow []string `json:"allow"`
AssignableAt []string `json:"assignable_at,omitempty"` // default: the owner scope's type
// Deny is not supported for custom roles in this release: a RoleSpec
// with Deny entries is rejected (design decision D2).
Deny []string `json:"deny,omitempty"`
}
RoleSpec describes a custom role to create or the new content of one to update.
type RoleStore ¶
type RoleStore interface {
// CreateRole saves a new custom role. It returns ErrRoleNameTaken if a
// role with the same Owner and NameKey exists; the check and the write
// must be atomic.
CreateRole(ctx context.Context, r CustomRole) error
// UpdateRole replaces a custom role if its stored version equals
// expectedVersion, and returns ErrConflict otherwise, ErrRoleNotFound if
// it does not exist, or ErrRoleNameTaken. r.Version is the new version.
UpdateRole(ctx context.Context, r CustomRole, expectedVersion int) error
// DeleteRole deletes a custom role. Deleting a role that does not exist
// is not an error.
DeleteRole(ctx context.Context, id string) error
// Roles returns the custom roles with the given IDs, omitting IDs that
// do not exist.
Roles(ctx context.Context, ids []string) ([]CustomRole, error)
// RolesOwnedBy returns the custom roles owned by any of the scopes.
RolesOwnedBy(ctx context.Context, owners []Scope) ([]CustomRole, error)
}
RoleStore persists custom roles.
type Scope ¶
type Scope struct {
Type string `json:"type"` // a scope type declared in the policy, e.g. "branch"
ID string `json:"id"` // the application's identifier for this instance, e.g. "b7"
}
Scope is where a role applies: an instance of one of the scope types the policy declares, such as an organisation or a branch. The zero value is Global, the implicit scope above all others.
Scopes are compared exactly, field by field: "o1" and "O1" are different scopes. wardn never normalises IDs.
type ScopeStore ¶
type ScopeStore interface {
// Link records that parent is the parent of child, replacing any
// existing parent.
Link(ctx context.Context, child, parent Scope) error
// Parent returns child's parent, and false if none is recorded.
Parent(ctx context.Context, child Scope) (Scope, bool, error)
}
ScopeStore persists the scope hierarchy.
type ScopeType ¶
type ScopeType struct {
// Parent is the scope type this one sits inside, or "" for a top-level
// type. Roles assigned at a parent scope apply in its children.
Parent string
}
ScopeType declares one kind of scope.
type Store ¶
type Store interface {
AssignmentStore
RoleStore
ScopeStore
}
Store persists assignments, custom roles and scope links. wardn's core does all the authorization logic (inheritance, wildcards, deny-wins, time windows, the escalation guard), so a Store only saves and loads records.
Implementations must be safe for concurrent use, must honour context cancellation, and must compare IDs exactly, byte for byte (no case- or accent-insensitive matching). Every method must either succeed completely or return an error; wardn treats any error as "deny". Stores may round times to a coarser precision, but never in a direction that widens a time window. The storetest package checks all of this.
type SweepOption ¶
type SweepOption func(*sweepOptions)
SweepOption configures SweepExpired.
func RemoveExpired ¶
func RemoveExpired() SweepOption
RemoveExpired makes SweepExpired also delete the expired assignments it reports. Without it they stay (harmlessly, since they grant nothing).
type System ¶
type System struct {
// contains filtered or unexported fields
}
System performs management operations without the escalation guard, for code that has no human actor: seeding the first administrator, data migrations and background jobs. Calls to it should stand out in code review. Every change is still audited, with Event.System set.
func (*System) Assign ¶
func (s *System) Assign(ctx context.Context, user, role string, scope Scope, opts ...AssignOption) error
Assign gives user the role at scope without the escalation guard.
func (*System) CreateRole ¶
CreateRole creates a custom role without the escalation guard.
func (*System) DeleteRole ¶
DeleteRole deletes a custom role without the escalation guard.
func (*System) UpdateRole ¶
func (s *System) UpdateRole(ctx context.Context, id string, spec RoleSpec, version int) (CustomRole, error)
UpdateRole updates a custom role without the escalation guard.
type TxStore ¶
type TxStore interface {
Store
// InTx reports whether ctx carries an application transaction that
// this store will use instead of starting its own. A wrapper around a
// TxStore must implement it too, by calling the wrapped store.
InTx(ctx context.Context) bool
}
TxStore is a Store that can work inside the application's own database transaction, so a change to access commits or rolls back together with the application's other writes. The SQL store implements it (wardnsql.Store.WithTx).
type UndefinedError ¶
type UndefinedError struct {
Kind string // "permission", "role" or "scope type"
Name string // the undefined name
Suggestion string // the closest defined name, or ""
}
UndefinedError reports a reference to a permission, role or scope type that the policy does not define. It carries a suggestion when the name looks like a typo of one that is defined.
func (*UndefinedError) Error ¶
func (e *UndefinedError) Error() string
type Warden ¶
type Warden struct {
// contains filtered or unexported fields
}
Warden answers authorization questions for one policy. It is safe for concurrent use; create one at startup and share it.
func MustNew ¶
MustNew is like New but panics on error. It is meant for main(), where a misconfigured policy should stop the application.
func New ¶
New validates the policy and returns a Warden. Any problem in the policy is reported here, so a misconfigured application fails at startup rather than when a user is first denied.
func (*Warden) Assign ¶
func (w *Warden) Assign(ctx context.Context, user, role string, scope Scope, opts ...AssignOption) error
Assign gives user the role at scope, on behalf of the user in ctx. The actor needs wardn:roles.assign at scope and must hold every permission the role grants there (the escalation guard). role is a system role name or a custom role ID. Assigning again replaces the time window.
func (*Warden) AssignableRoles ¶
AssignableRoles returns the roles the user in ctx may assign at scope: those from Roles that pass the escalation guard. Use it to fill the role picker in an admin screen, so people only see roles they can grant.
func (*Warden) Can ¶
Can reports whether user holds permission at scope. It returns false on any error, including an undefined permission; use Check to see why.
func (*Warden) CanAll ¶
CanAll reports whether user holds every one of the permissions at scope. It returns false if the list is empty, if any permission is undefined, or on any error.
func (*Warden) CanAny ¶
CanAny reports whether user holds at least one of the permissions at scope. It returns false if any permission is undefined, or on any error.
func (*Warden) CanEach ¶
CanEach reports, for each scope, whether user holds permission there. The result has one entry per scope, in order. Use it to filter a list, such as the projects a user may see, without one round trip per item. Every entry is false on any error; use CheckEach to see why.
func (*Warden) Catalog ¶
func (w *Warden) Catalog() []Permission
Catalog returns every permission in the policy, including the built-in ones, sorted by name. Admin screens use it to let people pick permissions.
func (*Warden) Check ¶
Check decides whether user holds permission at scope and explains why. Errors (an undefined permission, an invalid scope, a store failure) are returned with a Decision that denies: wardn always fails closed.
Example (Typo) ¶
package main
import (
"context"
"fmt"
"github.com/MogRex-Limited/wardn"
)
const examplePolicy = `
scopes:
organisation: {}
branch: { parent: organisation }
permissions:
customer:view: View customers
loan:create: Create loan applications
loan:approve: Approve loans
roles:
org_admin:
assignable_at: [organisation]
allow: ["*"]
teller:
assignable_at: [branch]
allow: [customer:view, loan:create]
`
func main() {
policy, _ := wardn.ParsePolicy([]byte(examplePolicy), "wardn.yaml")
w := wardn.MustNew(wardn.Config{Policy: policy})
_, err := w.Check(context.Background(), "ada", "loan:aprove", wardn.Global)
fmt.Println(err)
}
Output: wardn: permission "loan:aprove" is not defined; did you mean "loan:approve"?
func (*Warden) CheckEach ¶
func (w *Warden) CheckEach(ctx context.Context, user, permission string, scopes []Scope) ([]Decision, error)
CheckEach decides, for each scope, whether user holds permission there, with the same rules and reasons as Check. It reads the user's assignments for all the scopes together, so it costs a few store reads rather than a few per scope.
The whole call fails, denying every scope, if the permission or user is invalid, any scope is invalid, there are more than MaxCheckEach scopes, or a read fails.
func (*Warden) CreateRole ¶
CreateRole creates a custom role owned by owner, on behalf of the user in ctx. The actor needs wardn:roles.manage at owner and must hold every permission in the role.
func (*Warden) CustomRole ¶
CustomRole returns a custom role by ID, for example to fill an edit form. Pass its Version to UpdateRole.
func (*Warden) DeleteRole ¶
DeleteRole deletes a custom role, on behalf of the user in ctx, under the same guard as UpdateRole.
func (*Warden) InvalidateCache ¶
InvalidateCache makes cached data for the tenants of the given scopes stale on every instance. wardn invalidates automatically after its own changes; call this after changing scope parents behind its back (when Config.ParentOf reads them from your data) or editing wardn's tables directly.
func (*Warden) Link ¶
Link records that parent is the parent of child, so roles assigned at parent (and its ancestors) apply in child. The parent's type must be the one the policy declares for child.
Link never changes an existing parent. Linking a scope to the parent it already has does nothing, so it is safe to call on every start. Linking it to a different parent fails with ErrAlreadyLinked (which also matches ErrInvalidLink): two tenants that reuse the same ID ("cardiology" in two hospitals) would otherwise move one tenant's scope under the other's administrators. Use Move to change a parent on purpose.
Link is plumbing for the application's backend, called when it creates an entity. Never expose it directly to end users: whoever controls links controls inheritance (threat model T11). Every link is audited.
func (*Warden) Members ¶
func (w *Warden) Members(ctx context.Context, scope Scope, cursor string, limit int) ([]Assignment, string, error)
Members returns one page of the assignments made at exactly scope (not inherited ones), ordered by user then role, for admin screens. Pass "" as cursor for the first page and the returned cursor for the next; it is "" after the last page. limit is capped at 500.
Members does not check who is asking: protect the route that exposes it with Require.
func (*Warden) Move ¶
Move changes child's parent, for example when a branch moves to another region. Roles inherited from the old parent stop applying, and those of the new parent start, on the next check. Every move is audited as EventMoved, with the old parent. Moving a scope that has no parent yet links it (EventLinked); moving it to the parent it has does nothing.
func (*Warden) Now ¶
Now returns the current time on the Warden's clock (Config.Clock). Use it when your code computes a time window, such as Until(w.Now().Add(72 * time.Hour)), so tests that move wardn's clock move yours too.
func (*Warden) PermissionsOf ¶
PermissionsOf returns every catalog permission the user holds at scope right now, sorted. Frontends use it to show or hide actions.
func (*Warden) Require ¶
Require returns middleware that lets a request through only if its user holds every one of the permissions at the request's scope. It works with net/http, chi, and any router that uses func(http.Handler) http.Handler.
The user comes from Config.UserFrom, or from the context (see WithUser). The scope comes from ScopeFrom middleware, or is Global if none is set.
Require panics if a permission is not in the policy, so a typo stops the application when routes are registered rather than denying users later.
func (*Warden) RequireAny ¶
RequireAny is like Require, but one of the permissions is enough.
func (*Warden) Revoke ¶
Revoke removes role from user at scope, on behalf of the user in ctx, under the same guard as Assign, so a lower administrator cannot strip a higher one (threat model T04).
func (*Warden) Roles ¶
Roles returns the roles that can be assigned at scope: system roles whose assignable_at allows it, and custom roles owned by scope or an ancestor that are assignable at its type. System roles come first, then custom roles, each sorted by name.
func (*Warden) RolesOf ¶
RolesOf returns the user's assignments that apply at scope right now, including those inherited from ancestor scopes and Global, nearest first.
func (*Warden) RunExpiry ¶
func (w *Warden) RunExpiry(ctx context.Context, interval time.Duration, onError func(error), opts ...SweepOption)
RunExpiry calls SweepExpired every interval until ctx is done, passing errors to onError (which may be nil). Run it in its own goroutine, on one instance or on all. Reporting is at least once: instances sweeping at the same time, or a sweep that fails part-way, can report an expiry twice, so an audit log should ignore a repeat of the same user, role, scope and expiry time.
func (*Warden) ScopeFrom ¶
func (w *Warden) ScopeFrom(scopeType string, id func(*http.Request) string) func(http.Handler) http.Handler
ScopeFrom returns middleware that sets the request's scope to the given scope type, with the ID taken from the request by id (for example PathValue("orgID")). Place it where the ID is available: with chi, inside r.Route or r.With, after routing.
A missing or invalid ID is answered with 400 Bad Request; the request never reaches Require. ScopeFrom panics if scopeType is not declared in the policy.
func (*Warden) SweepExpired ¶
SweepExpired reports every assignment that has expired since the last sweep through Config.OnChange (as EventExpired), for the audit trail, and returns how many it reported. Expired assignments stop working on their own (evaluation rule 7); the sweep only records that they did. If OnChange fails, the sweep stops and those assignments are reported again next time.
func (*Warden) System ¶
System returns the unguarded management handle. Requests made on behalf of a user must use the guarded methods on Warden instead.
func (*Warden) SystemRoles ¶
SystemRoles returns the names of the policy's system roles, sorted.
func (*Warden) Transaction ¶
Transaction prepares ctx for wardn changes made inside the application's own database transaction. The context passed to those changes must also carry the transaction for the store (for the SQL store, Store.WithTx), added before or after Transaction. Call the returned function once the transaction has ended, whether it committed or rolled back, never before the commit:
tx, err := db.BeginTx(ctx, nil)
...
defer tx.Rollback()
ctx, ended := w.Transaction(store.WithTx(ctx, tx))
if err := w.Assign(ctx, "bayo", "teller", branch); err != nil { ... }
// ... the application's own writes, using tx ...
err = tx.Commit()
if endErr := ended(ctx); endErr != nil { ... log it ... }
Inside the transaction, wardn reads through it, so it sees the transaction's own uncommitted writes, and it neither reads nor fills the cache or the request memo: uncommitted data must never reach other requests. The cache is invalidated before each change and again when ended is called, so no instance serves the old state once the transaction has committed. If ended fails, other instances may serve the old state for up to the cache TTL (ErrCacheInvalidation).
A change made with a transaction context that did not come from Transaction fails with ErrNoTransaction, so a forgotten call cannot silently leave the cache stale.
func (*Warden) UpdateRole ¶
func (w *Warden) UpdateRole(ctx context.Context, id string, spec RoleSpec, version int) (CustomRole, error)
UpdateRole replaces a custom role's name, description and permissions, on behalf of the user in ctx. version must be the version the caller read; if the role changed since, ErrConflict is returned. The actor needs wardn:roles.manage at the role's owner and must hold every permission in both the old and the new role.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cache
|
|
|
redis
module
|
|
|
Package cachetest is the conformance suite for wardn.Cache implementations:
|
Package cachetest is the conformance suite for wardn.Cache implementations: |
|
cmd
|
|
|
wardn
command
Command wardn checks policy files and generates Go code from them.
|
Command wardn checks policy files and generates Go code from them. |
|
store
|
|
|
sql
Package wardnsql is a wardn.Store for SQL databases: PostgreSQL, MySQL, MariaDB and SQLite.
|
Package wardnsql is a wardn.Store for SQL databases: PostgreSQL, MySQL, MariaDB and SQLite. |
|
mongo
module
|
|
|
Package storetest is the conformance suite for wardn.Store implementations, and their benchmark suite (Bench).
|
Package storetest is the conformance suite for wardn.Store implementations, and their benchmark suite (Bench). |
|
Package wardntest helps test code that uses wardn, without a database.
|
Package wardntest helps test code that uses wardn, without a database. |