AshIntrospection.Rpc.LoadRestrictions (AshIntrospection v0.4.0)

Copy Markdown View Source

Shapes the loadable surface of an RPC action: which relationships, calculations and aggregates a client may ask for.

This is not authorization

Load restrictions are an API surface tool, not a security boundary. The reason to reach for them is cost: keeping an expensive aggregate or a deep relationship off an endpoint that has no need for it, so a client cannot turn a list request into a report. They say nothing about who may see a value.

Authorization is Ash's job and Ash still does it. Policies, field policies and tenancy apply to every load that gets through, exactly as they would if no restriction were declared. A field that must be hidden from an actor is hidden by a policy; adding it to denied_loads instead leaves it readable through every other action, and through any caller that is not this pipeline. Upstream ash_typescript states the same in 24266dc.

Where restrictions come from

Upstream reads them from Ash.Info.Manifest, which this library has not adopted (issue #23). Here they arrive on the config map that already threads through field selection, under the :load_restrictions key:

FieldSelector.process(resource, action, fields, %{
  load_restrictions: {:deny, [comments: [:score]]}
})

Omitting the key means :none — every load is permitted, which is what every existing caller gets.

What a restriction says

A restriction is {:allow, spec} or {:deny, spec}, where spec is a keyword-style nesting of internal (snake_case) field names:

SpecMeaning
{:deny, [:comments]}comments and everything under it is refused
{:deny, [comments: [:score]]}only comments.score is refused; comments itself is fine
{:allow, [:comments]}only comments may be loaded, and nothing deeper
{:allow, [comments: [:score]]}comments may be loaded as the step to comments.score

The two directions are deliberately not mirror images. denied_loads inherits downwards — denying a parent denies its children — because a deny list is a statement about a subtree. allowed_loads does not: allowing comments does not allow comments.score, because an allow list that inherited downwards would open a subtree its author never enumerated. Naming a nested path implicitly allows the parents needed to reach it, and only those.

Attributes are never checked. They are selected, not loaded, so they never reach the load statement and no restriction can name one.

How enforcement works

check!/2 is called by AshIntrospection.Rpc.FieldProcessing.FieldSelector at every point where it appends to the Ash load statement — six of them. A load therefore cannot reach the load statement without passing the check, so there is no second traversal that could disagree with field selection about what is being loaded. Nested paths are checked at every level as selection descends, not re-derived from the finished load statement afterwards.

Summary

Types

A load path as a list of internal field names, e.g. [:comments, :score].

t()

Normalized restrictions: the output of normalize/1 and the input to check!/2.

Functions

Checks one load path against normalized restrictions.

Expands a restriction spec into a flat list of paths.

Types

path()

@type path() :: [atom()]

A load path as a list of internal field names, e.g. [:comments, :score].

t()

@type t() :: :none | {:allow, [path()]} | {:deny, [path()]}

Normalized restrictions: the output of normalize/1 and the input to check!/2.

Functions

check!(path, arg2)

@spec check!(path(), t()) :: :ok

Checks one load path against normalized restrictions.

Returns :ok, or throws {:load_not_allowed, [path_string]} / {:load_denied, [path_string]} — the tuples AshIntrospection.Rpc.ErrorBuilder turns into a client error. It throws rather than returning an error tuple because its callers sit inside the recursive descent of field selection, which already reports every other refusal by throwing to process/4.

The path string in the thrown tuple is the dotted internal path, e.g. "comments.score", so the client is told which field it may not ask for.

normalize(arg1)

@spec normalize(term()) :: t()

Expands a restriction spec into a flat list of paths.

Accepts {:allow, spec}, {:deny, spec} and anything else, which becomes :none. It is idempotent: passing its own output back returns that output, so a caller that pre-normalizes is not punished for it.

Returns t/0.

iex> AshIntrospection.Rpc.LoadRestrictions.normalize({:deny, [comments: [:score]]})
{:deny, [[:comments, :score]]}