Public API Contract

Copy Markdown View Source

This guide describes the stable API surface that external consumers can build on. It exists because AshGrant is increasingly consumed by independent packages — ash_grant_phoenix (admin dashboard / LiveView), ash_grant_ai (Ash AI tool surface), and custom IEx helpers — which all need to share one core without being surprised by internal refactors.

If a module, function, struct field, or behaviour callback is documented in this file, you may depend on it from outside the :ash_grant app without pinning exact patch versions. Anything not listed here is internal and may change at any time.

Stability tiers

TierMeaningBreaking change policy
StablePart of the public contractOnly in major version bumps, with CHANGELOG notice and ≥ 1 minor of deprecation when feasible
ProvisionalIn the public contract but recently addedMay tighten in a minor release if a real-world consumer finds a rough edge. Every entry below is marked when provisional
InternalEverything elseAny release

All identifier-based introspection added in v0.15 starts as Provisional.

What's public

AshGrant.Introspect — runtime introspection

The primary entry point for external tools. All functions take explicit resource modules or string keys — they never rely on global state beyond the standard Ash application config.

Resource / domain discovery:

  • list_domains/0[module()]
  • list_resources/1[module()] (opts: :domains)
  • find_resource_by_key/1{:ok, module()} | :error

Actor-oriented queries (already-loaded actor):

  • actor_permissions/3[permission_status()]
  • allowed_actions/3[atom()] or [map()] with detailed: true
  • can?/4{:allow, map()} | {:deny, map()}

  • permissions_for/3[String.t()]
  • available_permissions/1[available_permission()]
  • summarize_actor/2[resource_summary()] (Provisional)

Identifier-oriented queries (loads the actor via the resolver's optional load_actor/1 callback — see the behaviour section below):

  • explain_by_identifier/1 (Provisional)
  • can_by_identifier/3 (Provisional)
  • actor_permissions_by_id/2 (Provisional)

All identifier-based functions return a structured {:error, :unknown_resource | :actor_loader_not_implemented | :actor_not_found} tuple on failure — they never raise for these predictable conditions.

AshGrant.explain/4 — rich authorization explanation

Top-level entrypoint that returns an AshGrant.Explanation.t():

AshGrant.explain(MyApp.Post, :read, actor, %{})

The return value's field set is part of the contract (see below).

AshGrant.Explanation struct

Stable fields:

FieldTypeNotes
:resourcemodule()The Ash resource module
:actionatom()Action name
:actorterm()The actor passed in
:decision:allow | :denyThe final decision
:reasonatomLow-level reason (internal-shaped)
:reason_code:allow_matched | :deny_rule_matched | :no_matching_permission | nilStable branching code
:summaryString.t()Human/LLM-readable one-liner
:matching_permissions[map()]Permissions that contributed to the decision
:evaluated_permissions[map()]All evaluated permissions with per-permission reasons
:deny_rulemap() | nilThe deny rule that won, if any
:scope_filterAsh.Expr.t() | nilRaw scope filter expression
:scope_filter_stringString.t() | nilHuman/LLM-readable stringification of scope_filter

reason_code and summary and scope_filter_string are Provisional — added in v0.15. Other fields are Stable.

AshGrant.Explanation.to_string/1 is Stable — used for terminal output.

AshGrant.Permission struct

Stable fields: :deny, :resource, :instance_id, :action, :scope, :field_group, :description, :source, :metadata.

Stable functions:

  • parse!/1, parse/1
  • to_string/1
  • matches?/4
  • deny?/1
  • from_input/1

AshGrant.PermissionInput struct

Stable fields: :string, :description, :source, :metadata.

Stable functions: new/2, to_string/1.

This is the preferred shape for resolvers that want to attach human-readable metadata (description, source) to each permission string.

AshGrant.PermissionResolver behaviour

Required callback (Stable):

@callback resolve(actor(), context()) :: [permission()]

Optional callback (Provisional, added in v0.15):

@callback load_actor(id :: term()) :: {:ok, actor()} | :error

load_actor/1 powers the identifier-based introspection entry points. Implementing it opts a resolver module into CLI tools, admin dashboards, and LLM agents that only have an actor ID — not a hydrated struct.

Not implementing load_actor/1 is fine; identifier-based functions return {:error, :actor_loader_not_implemented} in that case. Existing resolvers (including anonymous-function resolvers) keep working unchanged.

AshGrant.Permissionable protocol

Stable. Lets custom structs flow through the resolver pipeline by providing a to_permission_input/1 conversion.

AshGrant.ExprStringify

Provisional. Added in v0.15.

Converts an Ash.Expr term into a human/LLM-readable string, humanizing internal reference tuples:

InternalStringified
{:_actor, :id}^actor(:id)
{:_context, :key}^context(:key)
:_tenant^tenant()

Used internally to populate Explanation.scope_filter_string. You can also call it directly when you have a standalone filter expression to render.

Contract: always returns a binary; never raises for arbitrary terms (falls back to inspect).

JSON encoding

Every struct listed below encodes cleanly via Jason.encode!/1 and the result never leaks module atoms or raw Ash.Expr AST — this is a hard contract, because ash_grant_ai returns these as LLM tool responses and ash_grant_phoenix renders them as API responses.

StructEncoding notes
AshGrant.PermissionDerived; all fields encoded as-is
AshGrant.PermissionInputDerived; all fields encoded as-is
AshGrant.ExplanationCustom impl: resource rendered via inspect, actor rendered via inspect, scope_filter omitted (use scope_filter_string instead), field group mask_with functions stripped

If you find a value that breaks round-tripping through JSON, treat it as a bug — open an issue.

Mix task: mix ash_grant.explain

Stable CLI wrapper around Introspect.explain_by_identifier/1:

mix ash_grant.explain --actor USER_ID --resource RESOURCE_KEY --action ACTION \
  [--format text|json] [--context '<json>']

Exit codes are part of the contract:

CodeMeaning
0Explanation produced (allow or deny both succeed)
1Lookup failure (unknown_resource, actor_not_found, actor_loader_not_implemented)
2Usage error (missing option, bad --context, unknown --format)

JSON output is the Jason.encode!/1 representation of the Explanation.t() — see the JSON encoding section.

What's not public

These modules exist in lib/ but are internal — do not call them from outside the :ash_grant app:

If you need something here to be public, open an issue describing the consumer and we'll promote it.

Versioning

AshGrant follows Semantic Versioning with respect to this contract only:

  • Patch (0.x.Y) — bug fixes, internal refactors, additions marked Provisional
  • Minor (0.X.y) — additions to the public contract
  • Major (X.y.z) — breaking changes to the public contract

Provisional entries may tighten or rename inside a minor release; each change will ship in CHANGELOG under a Breaking (Provisional) heading. Once an entry leaves Provisional it follows the full Stable policy.