permissions

package
v0.1.5 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 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
}

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

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

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

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]) Allow added in v0.1.2

func (p *CRUD[T, ID]) Allow(ctx context.Context, op Operation, entity T) bool

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

func (p *CRUD[T, ID]) AllowedOps(ctx context.Context, entity T, ops ...Operation) []Operation

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

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"
)

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.

Jump to

Keyboard shortcuts

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