permissions

package
v0.0.10 Latest Latest
Warning

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

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

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

Constants

This section is empty.

Variables

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

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

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

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

Count checks resource-level OpRead, injects the Scoper's filters if any, 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, checks OpDelete, 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 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

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

Inner returns the underlying CRUD, unwrapped - bypasses permission checking.

func (*CRUD[T, ID]) List

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

List checks resource-level OpRead, injects the Scoper's filters if any, 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, 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

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

Rewrap rebuilds this decorator around inner, re-applying 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 decorator-chain walks (capability detection, transaction propagation) can reach the backend.

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, checks OpUpdate, 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: 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.

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 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.

Jump to

Keyboard shortcuts

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