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
}
Advertisement ¶
Beyond enforcing, the package can advertise verdicts: Allow, AllowedOps, and AllowResource ask the same Checker/Scoper the decorator asks, without performing the operation. Use them to publish per-row capabilities (a DTO "can" block a frontend renders as flags) instead of re-implementing the policy client-side:
ops := permissions.AllowedOps[Post, int64](ctx, policy, post,
permissions.OpUpdate, permissions.OpDelete)
The helpers work on a bare Checker (no decorator needed - e.g. on rows from an unwrapped repo); the decorator's Allow/AllowedOps methods additionally populate AccessRequest.EntityID via WithIDFunc. See Allow for the exact faithfulness contract.
Index ¶
- Variables
- func Allow[T any, ID comparable](ctx context.Context, checker Checker[T, ID], op Operation, entity T) bool
- func AllowResource[T any, ID comparable](ctx context.Context, checker Checker[T, ID], op Operation) bool
- 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]) Allow(ctx context.Context, op Operation, entity T) bool
- func (p *CRUD[T, ID]) AllowedOps(ctx context.Context, entity T, ops ...Operation) []Operation
- 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 ¶
func Allow ¶ added in v0.1.2
func Allow[T any, ID comparable](ctx context.Context, checker Checker[T, ID], op Operation, entity T) bool
Allow reports whether op would be permitted for the context actor on the given (already-loaded) entity, WITHOUT performing it. It asks the same Checker the decorator asks, building the same AccessRequest the decorator's corresponding method builds - so an advertised verdict matches enforcement.
It is a projection primitive: use it to publish per-row capabilities (e.g. a DTO "can" block a frontend reads to show/hide controls). The backend still enforces on every real operation; this only advertises the verdict. The verdict is computed against the row the caller holds - if the row has changed in storage since it was fetched, advertisement and enforcement can differ at action time (inherent to any capability projection).
Scope: for OpRead, Allow additionally consults the Scoper (if the checker is one), matching the decorator's Get - an out-of-scope row is not readable. Plain column scope filters are matched in memory; a relation ("has") scope filter cannot be, so it fails closed (not readable), exactly like the decorator's Get without WithIDFunc. Advertising is deliberately pure (no I/O): a caller that needs relation scope in the read verdict should scope the List that produced the rows, making read-advertise trivially true. For write ops only Check runs - the plain Update/Patch/Delete paths never consult the read Scoper. (Upsert/PatchWhere do, but they are compositions: probe them as OpCreate+OpUpdate and OpUpdate respectively.)
AccessRequest.EntityID is always nil here: a bare Checker has no ID extractor. The decorator's Delete (always) and Update/Patch (with WithIDFunc) set EntityID during enforcement, so a Checker that keys decisions on req.EntityID must be probed via the decorator's CRUD.Allow, which populates it from IDFunc. Checkers deciding from req.Entity fields (the common ownership/tenant shape) are advertised faithfully by this helper.
Row-level write advertisement presumes the decorator is configured with WithIDFunc (the row-level setup): only then does enforcement fetch the existing row and hand it to the Checker as req.Entity. Without IDFunc the decorator checks writes at resource level (Entity nil), so an entity-reading Checker sees different requests from enforcement than from Allow.
For row-less questions ("may this actor create/list at all?") use AllowResource - probing Allow with a zero-value entity would let an entity-reading policy judge garbage field values.
func AllowResource ¶ added in v0.1.2
func AllowResource[T any, ID comparable](ctx context.Context, checker Checker[T, ID], op Operation) bool
AllowResource reports whether the context actor passes the resource-level check for op: Entity and EntityID both nil, the exact AccessRequest the decorator's List/Count issue for OpRead. Use it for row-less questions (nav gating, "show the New button?"). Note it advertises only the resource-level verdict - a Scoper may still narrow which rows the actor actually sees, and row-level Check rules do not apply.
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]) Allow ¶ added in v0.1.2
Allow reports whether op would be permitted on entity, using this decorator's checker - see the package-level Allow for semantics. Unlike the bare helper, it uses IDFunc (when set) to populate AccessRequest.EntityID for non-create ops, matching the request shape the decorator's own methods build, so Checkers that read req.EntityID are advertised faithfully too.
func (*CRUD[T, ID]) AllowedOps ¶ added in v0.1.2
AllowedOps returns the subset of ops permitted on entity, preserving the given order - see the package-level AllowedOps. With no ops given, it probes the full CRUD set.
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 Operation ¶
type Operation string
Operation represents a CRUD operation type.
func AllowedOps ¶ added in v0.1.2
func AllowedOps[T any, ID comparable]( ctx context.Context, checker Checker[T, ID], entity T, ops ...Operation, ) []Operation
AllowedOps returns the subset of ops permitted for the context actor on entity, preserving the given order. Convenience over Allow for building a capability set to serialise (a DTO "can" block). With no ops given, it probes the full CRUD set (OpCreate, OpRead, OpUpdate, OpDelete); note OpCreate on an already-loaded row means "would creating an identical entity be allowed".
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", OrderItem -> "order_items"). 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.