permissions

package
v0.0.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 9, 2026 License: MIT Imports: 10 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func (p *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)

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]) 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 OpRead permission (resource-level) and, if the checker is a Scoper, injects the actor's owner-scope filters — so relation aggregates only fold related rows of owners the actor may see — then delegates to the inner repo. An inner repo without relation-aggregation support yields r3.ErrRelationAggregateNotSupported.

func (*CRUD[T, ID]) Count

func (p *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)

Count checks OpRead permission (resource-level), optionally injects scope filters if the checker implements Scoper, then delegates to inner.Count.

func (*CRUD[T, ID]) Create

func (p *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)

Create checks OpCreate permission, then delegates to inner.Create.

func (*CRUD[T, ID]) Delete

func (p *CRUD[T, ID]) Delete(ctx context.Context, id ID) error

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

func (p *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)

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

func (p *CRUD[T, ID]) Inner() r3.CRUD[T, ID]

Inner returns the underlying CRUD repository (unwrapped). Useful when you need to bypass permission checking for a specific operation.

func (*CRUD[T, ID]) List

func (p *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)

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

func (p *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)

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]) 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 the OpUpdate check, and — security-critical — when the checker is a Scoper the scope filters are AND-ed into the caller's filters so a scoped actor can only mutate rows inside its scope. Top-level filters combine with AND, so the effective selection is (caller filters AND scope filters). Returns r3.ErrBulkPatchNotSupported when the inner repo has no bulk-patch capability.

func (*CRUD[T, ID]) Rewrap

func (p *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]

Rewrap rebuilds this decorator around a different inner CRUD (used to re-apply the permission layer on top of a transaction-bound CRUD).

func (*CRUD[T, ID]) Unwrap

func (p *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]

Unwrap returns the wrapped CRUD so capability detection and transaction propagation can walk the decorator chain.

func (*CRUD[T, ID]) Update

func (p *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)

Update fetches the existing entity (if IDFunc is set) for entity-aware checks, then checks OpUpdate permission, then delegates to inner.Update.

func (*CRUD[T, ID]) Upsert added in v0.0.4

func (p *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (T, error)

Upsert gates an insert-or-update. Because it can do either, the actor must pass BOTH the OpCreate and the OpUpdate check (the stricter of the two wins). When the checker is also a Scoper, the entity being written must fall within the actor's scope, so a scoped actor cannot upsert onto a row outside its scope. Returns r3.ErrUpsertNotSupported when the inner repo has no upsert capability.

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 Operation

type Operation string

Operation represents a CRUD operation type.

const (
	OpCreate Operation = "create"
	OpRead   Operation = "read"   // Get and List
	OpUpdate Operation = "update" // Update and Patch
	OpDelete Operation = "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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL