Documentation
¶
Overview ¶
Package permissions gates every CRUD operation through a caller-supplied authorization policy. The decorator wraps any r3.CRUD[T, ID], transparently satisfies it, and drops in anywhere a CRUD is expected.
It is stateless: it stores no roles or permissions. A single Checker receives the actor (read from context via r3.GetActor), operation, and entity context and returns allow or deny - bring your own authorization logic. Composable helpers cover the common shapes: AllowAll, DenyAll, ReadOnly, ByActorType, Compose, OperationCheckers.
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 ¶
With WithIDFunc, the decorator fetches the existing entity before Update, Patch, and Delete, 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 its filters into List/Count/Aggregate for DB-level row filtering (and enforces them on Get):
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]) AggregateThroughRelation(ctx context.Context, relation string, 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]) PatchWhere(ctx context.Context, filters r3.Filters, entity T, fields r3.Fields) (int64, 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)
- func (p *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (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 every denial matches via errors.Is.
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 carries structured details about a denied operation and matches 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 is the canonical constructor for a denial by actor on op, with an optional reason. Set RecordType/RecordID afterward 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 carries the context for a permission decision. Some fields are unset per operation: Entity is nil for List and pre-insert Create; EntityID is nil for List and Create.
type CRUD ¶
type CRUD[T any, ID comparable] struct { // contains filtered or unexported fields }
CRUD gates every operation through the Checker before delegating to the inner repo. It is stateless - it stores no roles or permissions, only asks "is this allowed?". See the package doc for usage.
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 inner so every operation is gated by checker.
func (*CRUD[T, ID]) Aggregate ¶ added in v0.0.3
Aggregate checks resource-level OpRead and injects the Scoper's filters if any - so grouped results cover only rows the actor may see - then delegates to the inner repo. Yields r3.ErrAggregateNotSupported when it has no aggregation.
func (*CRUD[T, ID]) AggregateThroughRelation ¶ added in v0.0.5
func (p *CRUD[T, ID]) AggregateThroughRelation( ctx context.Context, relation string, qarg ...r3.Query, ) ([]r3.AggregateRow, error)
AggregateThroughRelation checks resource-level OpRead and, if the checker is a Scoper, injects the owner-scope filters - so relation aggregates fold only related rows of owners the actor may see - then delegates to the inner repo. Yields r3.ErrRelationAggregateNotSupported when it has no relation aggregation.
func (*CRUD[T, ID]) Count ¶
Count checks resource-level OpRead, injects the Scoper's filters if any, then delegates to inner.Count.
func (*CRUD[T, ID]) Delete ¶
Delete fetches the existing entity (if IDFunc is set) for entity-aware checks, checks OpDelete, then delegates to inner.Delete.
func (*CRUD[T, ID]) Get ¶
Get fetches first (the entity is needed for the row-level check), then checks OpRead - a denied entity is never leaked.
If the checker is a Scoper, the fetched entity is verified against the scope filters, consistent with List/Count. An out-of-scope entity is reported as r3.ErrNotFound (invisible), exactly as List would omit it. Scope filters are evaluated in memory; an unevaluable filter fails closed (out of scope).
func (*CRUD[T, ID]) Inner ¶
Inner returns the underlying CRUD, unwrapped - bypasses permission checking.
func (*CRUD[T, ID]) List ¶
List checks resource-level OpRead, injects the Scoper's filters if any, then delegates to inner.List.
func (*CRUD[T, ID]) Patch ¶
Patch fetches the existing entity (if IDFunc is set) for entity-aware checks, checks OpUpdate, then delegates to inner.Patch.
func (*CRUD[T, ID]) PatchWhere ¶ added in v0.0.4
func (p *CRUD[T, ID]) PatchWhere( ctx context.Context, filters r3.Filters, entity T, fields r3.Fields, ) (int64, error)
PatchWhere gates a bulk conditional update. The actor must pass OpUpdate, and - security-critical - when the checker is a Scoper the scope filters are AND-ed into the caller's filters, so the effective selection is (caller AND scope) and a scoped actor can only mutate rows inside its scope. Returns r3.ErrBulkPatchNotSupported when the inner repo has no bulk-patch capability.
func (*CRUD[T, ID]) Rewrap ¶
Rewrap rebuilds this decorator around inner, re-applying the permission layer on top of a transaction-bound CRUD.
func (*CRUD[T, ID]) Unwrap ¶
Unwrap returns the wrapped CRUD so decorator-chain walks (capability detection, transaction propagation) can reach the backend.
func (*CRUD[T, ID]) Update ¶
Update fetches the existing entity (if IDFunc is set) for entity-aware checks, checks OpUpdate, then delegates to inner.Update.
func (*CRUD[T, ID]) Upsert ¶ added in v0.0.4
Upsert gates an insert-or-update: since it may do either, the actor must pass BOTH the OpCreate and OpUpdate check. When the checker is a Scoper, the written entity must fall within scope, so a scoped actor cannot upsert onto a row outside it. Returns r3.ErrUpsertNotSupported when the inner repo can't upsert.
type Checker ¶
type Checker[T any, ID comparable] interface { Check(ctx context.Context, req AccessRequest[T, ID]) error }
Checker makes the authorization decision for a CRUD operation: return nil to allow, or an error (typically *AccessDeniedError) to deny. A non-nil error stops the operation before it reaches the inner CRUD. Implement it however you like - hardcoded rules, RBAC, ABAC, an external policy engine.
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, letting the decorator 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 primary-key extractor, enabling entity-aware (row-level) checks for Update, Patch, and Delete.
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, when set, makes the decorator fetch the existing entity before // Update, Patch, and Delete for entity-aware (row-level) checks. Without it, // mutations get only resource-level checks. IDFunc IDFunc[T, ID] // RecordType names the entity type in error messages. If empty, derived from // T (e.g. Order -> "orders", CampaignAdset -> "campaign_adsets"). RecordType string }
Options configures the permissions decorator.
type Scoper ¶
type Scoper[T any, ID comparable] interface { Scope(ctx context.Context, actor r3.Actor) (r3.Filters, error) }
Scoper is an optional interface a Checker may also implement to narrow reads by actor. When present, the decorator merges Scope's filters into List/Count/ Aggregate for row-level access control at the DB (e.g. "WHERE owner_id = ?") instead of post-filtering in memory; a Checker without it gets no 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. Without it, a relationship-scoped Get fails closed (r3.ErrNotFound) rather than risk leaking an out-of-scope row.