Documentation
¶
Overview ¶
Package permissions provides a policy-based, entity-aware permission decorator for r3 CRUD repositories.
It works as a pre-call gating decorator around any r3.CRUD[T, ID] implementation, checking authorization before every Create, Get, List, Update, Patch, and Delete operation. The decorator is stateless -- it doesn't store roles or permissions. A single Checker interface receives the actor, operation, and entity context, returning allow or deny. Users bring their own authorization logic.
Key features:
- Policy-based: no built-in concept of roles or permission sets
- Entity-aware: the Checker receives the actual entity for row-level rules
- Scope injection: optional Scoper interface injects filters into List queries
- Composable helpers: AllowAll, DenyAll, ReadOnly, ByActorType, Compose, OperationCheckers
- r3.Actor integration: reads actor from context via r3.GetActor(ctx)
Basic Usage ¶
repo := permissions.WithPermissions[Post, int64](
innerRepo,
permissions.ByActorType[Post, int64](map[string]permissions.Checker[Post, int64]{
"admin": permissions.AllowAll[Post, int64](),
"user": permissions.ReadOnly[Post, int64](),
}),
)
Entity-Aware Checks ¶
When WithIDFunc is configured, the decorator fetches the existing entity before Update, Patch, and Delete operations, enabling row-level rules like "users can only edit their own posts":
repo := permissions.WithPermissions[Post, int64](
innerRepo, myChecker,
permissions.WithIDFunc[Post, int64](func(p Post) int64 { return p.ID }),
)
Scope Injection ¶
When a Checker also implements Scoper, the decorator injects filters into List queries for efficient DB-level row filtering:
type postPolicy struct{}
func (p postPolicy) Check(ctx context.Context, req permissions.AccessRequest[Post, int64]) error { ... }
func (p postPolicy) Scope(ctx context.Context, actor r3.Actor) (r3.Filters, error) {
if actor.Type == "admin" { return nil, nil }
return r3.Filters{r3.F(r3.NewFieldSpec("owner_id"), actor.ID)}, nil
}
Index ¶
- Variables
- type AccessDeniedError
- type AccessRequest
- type CRUD
- func (p *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)
- func (p *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)
- func (p *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)
- func (p *CRUD[T, ID]) Delete(ctx context.Context, id ID) error
- func (p *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)
- func (p *CRUD[T, ID]) Inner() r3.CRUD[T, ID]
- func (p *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)
- func (p *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)
- func (p *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]
- func (p *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]
- func (p *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)
- type Checker
- func AllowAll[T any, ID comparable]() Checker[T, ID]
- func ByActorType[T any, ID comparable](rules map[string]Checker[T, ID]) Checker[T, ID]
- func Compose[T any, ID comparable](checkers ...Checker[T, ID]) Checker[T, ID]
- func DenyAll[T any, ID comparable]() Checker[T, ID]
- func OperationCheckers[T any, ID comparable](perOp map[Operation]Checker[T, ID], fallback ...Checker[T, ID]) Checker[T, ID]
- func ReadOnly[T any, ID comparable]() Checker[T, ID]
- type CheckerFunc
- type IDFunc
- type Operation
- type Option
- type Options
- type Scoper
Constants ¶
This section is empty.
Variables ¶
var ErrAccessDenied = errors.New("r3/permissions: access denied")
ErrAccessDenied is the sentinel error for permission checks. Use errors.Is(err, permissions.ErrAccessDenied) to detect authorization failures.
Functions ¶
This section is empty.
Types ¶
type AccessDeniedError ¶
type AccessDeniedError struct {
Operation Operation
Actor r3.Actor
RecordType string
RecordID string // empty if not applicable
Reason string // optional human-readable reason
}
AccessDeniedError provides structured details about a denied operation. It satisfies errors.Is(err, ErrAccessDenied) via its Is method.
func NewAccessDeniedError ¶ added in v0.0.3
func NewAccessDeniedError(op Operation, actor r3.Actor, reason string) *AccessDeniedError
NewAccessDeniedError builds the structured denial for op by actor, with an optional human-readable reason — the canonical way to construct the error (mirroring validation's NewError/NewFieldError). Set RecordType/RecordID on the returned value when the denial concerns a specific record.
func (*AccessDeniedError) Error ¶
func (e *AccessDeniedError) Error() string
Error returns a human-readable description of the denied operation.
func (*AccessDeniedError) Is ¶
func (e *AccessDeniedError) Is(target error) bool
Is supports errors.Is(err, ErrAccessDenied).
type AccessRequest ¶
type AccessRequest[T any, ID comparable] struct { Operation Operation // Which CRUD operation Actor r3.Actor // Who is performing it (from context) Entity *T // The entity being operated on (nil for List, Create pre-check) EntityID *ID // The entity ID (for Get, Update, Patch, Delete) }
AccessRequest contains all context needed for a permission decision. Not all fields are populated for every operation:
- Entity is nil for List and pre-insert Create checks
- EntityID is nil for List and Create
type CRUD ¶
type CRUD[T any, ID comparable] struct { // contains filtered or unexported fields }
CRUD is a decorator that wraps any r3.CRUD[T, ID] and gates every operation through a user-defined Checker interface. The decorator is stateless -- it doesn't store roles or permissions. It asks the Checker "is this allowed?" before delegating to the inner CRUD.
It transparently satisfies the r3.CRUD[T, ID] interface, so it can be used as a drop-in replacement for any CRUD repository.
Usage:
repo := permissions.WithPermissions[Post, int64](
innerRepo,
myChecker,
permissions.WithIDFunc[Post, int64](func(p Post) int64 { return p.ID }),
)
func WithPermissions ¶
func WithPermissions[T any, ID comparable]( inner r3.CRUD[T, ID], checker Checker[T, ID], optFns ...Option[T, ID], ) *CRUD[T, ID]
WithPermissions wraps an existing r3.CRUD with permission checking. The checker parameter is the authorization policy that gates every operation.
Example:
repo := permissions.WithPermissions[Post, int64](
innerRepo,
permissions.AllowAll[Post, int64](),
)
func (*CRUD[T, ID]) Aggregate ¶ added in v0.0.3
Aggregate checks OpRead permission (resource-level), optionally injects scope filters if the checker implements Scoper — so grouped results only cover rows the actor is allowed to see — then delegates to the inner repo's Aggregate. An inner repo without aggregation support yields r3.ErrAggregateNotSupported.
func (*CRUD[T, ID]) Count ¶
Count checks OpRead permission (resource-level), optionally injects scope filters if the checker implements Scoper, then delegates to inner.Count.
func (*CRUD[T, ID]) Delete ¶
Delete fetches the existing entity (if IDFunc is set) for entity-aware checks, then checks OpDelete permission, then delegates to inner.Delete.
func (*CRUD[T, ID]) Get ¶
Get delegates to inner.Get first (need entity for row-level check), then checks OpRead permission. If denied, the entity is not leaked.
If the checker also implements Scoper, the fetched entity is verified against the scope filters — consistent with List/Count, which apply the same filters at the database level. An entity outside the actor's scope is reported as r3.ErrNotFound (invisible), exactly as List would omit it. The scope filters are evaluated in-memory against the fetched entity; an unevaluable filter fails closed (treated as out of scope).
func (*CRUD[T, ID]) Inner ¶
Inner returns the underlying CRUD repository (unwrapped). Useful when you need to bypass permission checking for a specific operation.
func (*CRUD[T, ID]) List ¶
List checks OpRead permission (resource-level), optionally injects scope filters if the checker implements Scoper, then delegates to inner.List.
func (*CRUD[T, ID]) Patch ¶
Patch fetches the existing entity (if IDFunc is set) for entity-aware checks, then checks OpUpdate permission, then delegates to inner.Patch.
func (*CRUD[T, ID]) Rewrap ¶
Rewrap rebuilds this decorator around a different inner CRUD (used to re-apply the permission layer on top of a transaction-bound CRUD).
type Checker ¶
type Checker[T any, ID comparable] interface { Check(ctx context.Context, req AccessRequest[T, ID]) error }
Checker makes authorization decisions for CRUD operations. Implementations can be as simple or complex as needed: hardcoded rules, RBAC, ABAC, or external policy engines.
Check returns nil to allow, or an error (typically *AccessDeniedError) to deny. Returning a non-nil error prevents the operation from reaching the inner CRUD.
func AllowAll ¶
func AllowAll[T any, ID comparable]() Checker[T, ID]
AllowAll returns a checker that allows everything.
func ByActorType ¶
func ByActorType[T any, ID comparable](rules map[string]Checker[T, ID]) Checker[T, ID]
ByActorType returns a checker that delegates to different checkers based on the actor's Type field. If no checker is registered for the actor's type, the operation is denied.
func Compose ¶
func Compose[T any, ID comparable](checkers ...Checker[T, ID]) Checker[T, ID]
Compose chains multiple checkers: all must allow for the operation to proceed. First denial wins. An empty list of checkers allows everything.
func DenyAll ¶
func DenyAll[T any, ID comparable]() Checker[T, ID]
DenyAll returns a checker that denies everything.
func OperationCheckers ¶
func OperationCheckers[T any, ID comparable]( perOp map[Operation]Checker[T, ID], fallback ...Checker[T, ID], ) Checker[T, ID]
OperationCheckers maps specific operations to specific checkers. Unmapped operations are denied by default unless a fallback checker is provided.
Example:
permissions.OperationCheckers[Post, int64](
map[permissions.Operation]permissions.Checker[Post, int64]{
permissions.OpRead: permissions.AllowAll[Post, int64](),
permissions.OpCreate: adminOnlyChecker,
},
permissions.DenyAll[Post, int64](), // fallback for unmapped ops
)
func ReadOnly ¶
func ReadOnly[T any, ID comparable]() Checker[T, ID]
ReadOnly returns a checker that allows only reads (Get, List) and denies all mutations (Create, Update, Patch, Delete).
type CheckerFunc ¶
type CheckerFunc[T any, ID comparable] func(ctx context.Context, req AccessRequest[T, ID]) error
CheckerFunc is an adapter to allow use of ordinary functions as Checkers.
func (CheckerFunc[T, ID]) Check ¶
func (f CheckerFunc[T, ID]) Check(ctx context.Context, req AccessRequest[T, ID]) error
Check calls the underlying function.
type IDFunc ¶
type IDFunc[T any, ID comparable] func(entity T) ID
IDFunc extracts the primary key from an entity. Used by the decorator to fetch existing entities for entity-aware checks (Update, Patch, Delete).
type Option ¶
type Option[T any, ID comparable] func(*Options[T, ID])
Option is a functional option for configuring the permissions CRUD decorator.
func WithIDFunc ¶
func WithIDFunc[T any, ID comparable](fn IDFunc[T, ID]) Option[T, ID]
WithIDFunc sets the function that extracts the primary key from an entity. This enables entity-aware (row-level) permission checks for Update, Patch, and Delete operations.
func WithRecordType ¶
func WithRecordType[T any, ID comparable](name string) Option[T, ID]
WithRecordType sets an explicit entity type name for error messages.
type Options ¶
type Options[T any, ID comparable] struct { // IDFunc extracts the primary key from an entity. // When set, the decorator fetches the existing entity before Update, Patch, // and Delete operations, enabling entity-aware (row-level) permission checks. // Without IDFunc, only resource-level checks are performed for mutations. IDFunc IDFunc[T, ID] // RecordType is the entity type name used in error messages. // If empty, it is derived automatically from the struct type T // (e.g. Order -> "orders", CampaignAdset -> "campaign_adsets"). RecordType string }
Options configures the behavior of the permissions decorator.
type Scoper ¶
type Scoper[T any, ID comparable] interface { Scope(ctx context.Context, actor r3.Actor) (r3.Filters, error) }
Scoper optionally narrows List queries based on the actor's permissions. When a Checker also implements Scoper, the decorator calls Scope() before List and merges the returned filters into the query.
This enables efficient row-level access control at the database level (e.g., "WHERE owner_id = $actorID") instead of post-filtering in memory.
Scoper is a separate optional interface, not part of Checker. This follows R3's pattern (like SoftDeleter being separate from CRUD). If a Checker doesn't implement Scoper, List just checks OpRead permission without filter injection.
Plain column scope filters are enforced on single-entity Get in memory. A relationship ("has") scope filter (r3.Has) can't be matched in memory, so the decorator verifies Get through a backend query — which requires WithIDFunc to be configured. Without it, a relationship-scoped Get fails closed (returns r3.ErrNotFound) rather than risk leaking an out-of-scope row.