All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.20.0] - 2026-08-02
Ports the compile-cycle fix from the team-alembic fork, authored by Barnabas Jovanovics and Conor Sinclair. The :always → :all scope renaming that fork also carries is not included — this project's :always convention and the @read type-wildcard spelling are unchanged.
Breaking Changes
A missing resolver no longer fails the build.
AshGrant.Transformers.ValidateResolverPresentandAshGrant.Transformers.ValidateScopesbecameAshGrant.Verifiers.ValidateResolverPresentandAshGrant.Verifiers.ValidateScopes. Verifiers run post-compile, which is what lets them reach into the domain without closing a compile cycle — but Spark surfaces verifier errors as compile warnings, not errors. So a resource with no resolver (on itself or its domain) now compiles — emitting aNo resolver configuredwarning — and instead raises the first time authorization runs against it.AshGrant.CheckraisesArgumentError, which Ash wraps, so what a caller rescues isAsh.Error.Unknowncarrying the messageno resolver configured for ….If you relied on the compile error as your gate, make the warning one: build with
--warnings-as-errors, or fail CI on the verifier's output. A misconfiguration that used to stop the build now reaches runtime.The namespace move is itself breaking: anything naming
AshGrant.Transformers.ValidateResolverPresentorAshGrant.Transformers.ValidateScopesdirectly must be updated.AshGrant.Transformers.MergeDomainConfigis removed. Domain-levelash_grantconfig is no longer folded into the resource's DSL state at compile time. It is resolved at runtime instead:AshGrant.Info.resolver/1falls back to the domain when the resource declares none, andAshGrant.Info.scopes/1merges resource and domain scopes with resource precedence.Configuration outcomes are unchanged. What changes is where the merged value lives: code that read domain-inherited config straight out of Spark DSL state —
Spark.Dsl.Extension.get_opt(resource, [:ash_grant], :resolver),get_entities(resource, [:ash_grant])— now sees only what the resource itself declares. Read throughAshGrant.Infoinstead.
Fixed
Compile deadlock when a domain combined
AshGrant.Domainwith acode_interfaceblock. A resource usingAshGrant, on a domain that both usedAshGrant.Domainand declaredcode_interface do define … end, deadlocked: the resource'sMergeDomainConfigtransformer forced the domain to compile so it could read the domain'sash_grantDSL, while the domain'scode_interfacetransformer was waiting on the resource.definecallsAsh.CodeInterface.require_action/2at compile time, so the wait is mutual and the compiler reportsdeadlocked waiting on module …for every resource in the cycle.The cycle did not need the resource carrying the
defineto be AshGrant-extended itself — a domain-leveldefineon any resource that compile-depends on an extended sibling was enough to close it, which is why the failure often surfaced far from the resource that was actually configured.Moving the merge to runtime and the validations to verifiers removes both compile-time edges.
test/ash_grant/code_interface_cycle_test.exspins the combination.
[0.19.0] - 2026-07-17
Added
Indeterminate type-wildcard evaluation is now signaled (#126). A type wildcard matches on the Ash action type, so an
Evaluatorentry point called without anaction_typecannot evaluate it. Previously it silently skipped the wildcard and fabricated an answer — afalsethat means "could not tell", indistinguishable from a real deny, so the same actor and grant got opposite answers from the two APIs (Introspect.can?(Document, :read, actor)→ allow;Evaluator.has_access?(perms, "document", "read")→false), which in practice forced defensive@read+ literalreadgrant pairs. In the deny direction the skip is fail-open: a skipped!…:@read:…deny leavestruestanding. The newAshGrant.IndeterminateMatchrecomputes the result with the skipped RBAC type wildcards forced to match and signals only if that changes the answer — sound by construction, so defensive wildcard+literal pairs, wildcards that contribute nothing, and queries already settled by a concrete deny stay silent. Severity is configurable, mirroringfield_group_permissions:config :ash_grant, indeterminate_type_wildcard: :warn # :off | :warn (default) | :strict:warnlogs,:strictraises,:offdisables — and authorization outcomes never change in any mode. All eight nil-defaulting entry points are guarded (has_access?,get_scope,get_all_scopes,get_write_scopes,get_field_group,get_all_field_groups,find_matching,field_group_grants). Framework-generated policies always pass the action type and are unaffected; the signal concerns directEvaluatorcalls — in application code, preferAshGrant.Introspect.can?/4, which takes the resource module and resolves the type itself.get_matching_instance_ids/4is deliberately unguarded; the pre-existing instance-path inconsistency that decision surfaced is tracked in #131.
Changed
- Type-wildcard documentation and examples now lead with the
@readspelling (AshGrant.Permission, theAshGrant.Evaluatormoduledoc,usage-rules.md,guides/permissions.md), withread*shown only as the deprecated equivalent.
[0.18.0] - 2026-07-17
Added
Explicit action-type wildcard:
@read(#126). Type wildcards can now be spelled@read,@create,@update,@destroy,@action— e.g."blog:*:@read:always". Semantics are identical to the existingread*form (match on the Ash action type, never on the action name), but the notation can no longer be misread as a prefix glob. Both spellings work;@cannot collide with an action name, since Elixir action atoms never start with it.AshGrant.Permission.diagnostics/1(#126) reports deprecated or dead grant syntax for a permission string or struct, returning%{code:, permission:, message:, suggestion:}maps. Three codes::deprecated_type_wildcard— theread*spelling; suggests the@readequivalent:dead_instance_type_wildcard— a type wildcard on an instance permission. Instance matching has no action type available, so the grant never matches anything ("blog:post_abc:read*:"is dead). Previously silent and undocumented.:unknown_action_type— a type wildcard naming something that is not an Ash action type.delete*is the common case: Ash calls that type:destroy, so"blog:*:delete*:always"silently never matches. Suggests@destroy.
Permission strings are runtime data that AshGrant cannot reach, so this is exposed as a function to run over your own store:
MyApp.Role |> MyApp.Repo.all() |> Enum.flat_map(& &1.permissions) |> Enum.flat_map(&AshGrant.Permission.diagnostics/1)Authorization behavior is unchanged — a flagged permission still evaluates exactly as it did before.
mix ash_grant.verifyreports permission syntax warnings (#126) by resolving each policy test actor's grants and runningdiagnostics/1over them. This only covers grants the declared test actors hold, so a clean run is not proof that your permission store is clean; YAML tests carry their permissions inline and are not scanned. Warnings never affect the exit code.
Deprecated
- The
read*spelling of type wildcards (#126), in favor of@read. Removal planned for v1.0.0.read*still works and its behavior is unchanged. Unlike the[:*]→:alland scopewrite:deprecations — which were DSL options the compiler could see — permission strings are runtime data from thePermissionResolver(your roles table, your seeds), so there is no compile-time warning and migrating means updating stored data rather than editing source. Runmix ash_grant.verify, orAshGrant.Permission.diagnostics/1over your own store, to find grants that need it.
Fixed
matches_action?/2's docstring documented the opposite of what the code does (#126). It claimed "prefix matching withprefix*" whilematches_action?/3, twenty lines below, correctly documented type matching — and the/2doctest already refuted its own prose, so the suite stayed green while the documentation misled. Corrected, along with:matches?/4(whose "will also match" implied name-prefix matching happened too),matches?/3(itsfalseis caused by the absentaction_type, not by a failed name comparison), the moduledoc format table, andusage-rules.md, whose examples used onlyread-prefixed action names and so taught the glob model by example.matches_instance?/3silently never matches type wildcards, previously undocumented (#126). Instance matching passes no action type, so a type wildcard on an instance permission is dead. Now documented, doctested, and reported bydiagnostics/1.Three places claimed type wildcards do not apply to generic actions (#126) —
guides/permissions.md,guides/checks-and-policies.md, andAshGrant.Check's@moduledoc. They do apply:"service:*:@action:always"grants every generic action on the resource, including ones added later, so the grant that permits:pingalso permits:process_refund. All three now document the real behavior and warn against relying on it; for generic actions, prefer exact action names. Whether to block this is tracked in #127.
[0.17.0] - 2026-07-06
Fixed
- Write actions resolved only ONE grant's scope — adding a narrow grant to a role holding a blanket grant revoked the action (#123).
AshGrant.CheckusedEvaluator.get_scope/4, which returns the scope of whichever matching allow permission appears first in the resolver's list, ignoring every other grant. An actor holding["schedule:*:cancel:online_content", "schedule:*:*:always"]was forbidden from canceling a regular class — the additive grant acted as a revocation — and the outcome depended on permission-list order (i.e. DB row order). Write and generic actions now OR-compose all matching grants' scopes via the newAshGrant.Evaluator.get_write_scopes/4and authorize when any scope passes: the union semanticsFilterCheck(read) andCanPerform(UI) have always applied, and the write-path analogue of #116's group-less collapse — adding an allow grant must never subtract access. Deny rules still win over the whole union, a scope-less grant (legacy 2-part format) still short-circuits to unrestricted, and each scope keeps its own evaluation strategy (in-memory or DB-query fallback). The same union now flows through the tooling:Introspect.can?/4allow-details carryscopes:(all matching grants' scopes) next to the backward-compatiblescope:(first match), andAshGrant.PolicyTestrecord assertions (assert_can/3/assert_cannot/3) evaluate the union, so policy tests agree with runtime.
Changed
write: falseis per-grant, not per-actor (consequence of #123). A scope withwrite: falseprevents grants carrying that scope from authorizing a write; the actor's other matching grants are still evaluated. Previously it could hard-block the whole action whenever it happened to be the first matching grant. To hard-block an action regardless of an actor's other grants, use a!deny permission.
[0.16.0] - 2026-06-24
Added
Per-record field visibility (#117 phase ②). Field-group access can now vary per record instead of per action. An actor whose row access is broader than their field-group access sees a field on some records and
%Ash.ForbiddenField{}on others, in the same result set — the pattern the action-wide model could not express:# public on every readable row, sensitive only on rows the actor owns ["employee:*:read:always:public", "employee:*:read:own:sensitive"] # sensitive only on a specific instance ["employee:*:read:always:public", "employee:emp_123:read::sensitive"]default_field_policies: truenow generates field policies backed by the newAshGrant.FieldFilterCheck(anAsh.Policy.FilterCheck) instead of the action-wideAshGrant.FieldCheck(aSimpleCheck). For each field group it builds a per-row predicate from the actor's reaching grants — the scope expression for RBAC grants,id in [...]for instance grants — OR-combined, with downward inheritance. A newAshGrant.Evaluator.field_group_grants/5enumerates those grants (including instance permissions, whichget_all_field_groups/4discards).Backward-compatible: a trivial-scope grant (
resource:*:read:always:group) resolves totrue→ visible on all rows, identical to the previous action-wide behavior; masking and the #116 group-less collapse are unchanged.
Fixed
- Ungrouped public fields were forbidden for every actor under
default_field_policies: true. A public attribute in no field group (e.g. a timestamp or foreign key) became%Ash.ForbiddenField{}for everyone, because the generated catch-allfield_policy :*was keyed literally in the rebuilt field-policy cache while Ash looks fields up concretely. The cache now expands:*into the concrete ungrouped fields, so they stay visible (matching the catch-all's documented intent).
[0.15.0] - 2026-06-24
Added
field_group_permissionsoption + validation for invalid field-group denies (#117). A permission that is a deny (!prefix) and carries afield_group(5th part) — e.g."!employee:*:read:always:sensitive"— is invalid: field-group access is positive-only, so column restrictions belong in the resource'sfield_groupdefinition (inherits/except/mask), not in a deny rule. Such a deny currently over-denies the entire action (fail-closed) rather than the named group. The newfield_group_permissionsoption on theash_grantblock controls how this is signaled, without changing the authorization outcome::off— silent (legacy behavior):warn— over-denial plus aLogger.warning(default):strict— raiseAshGrant.PermissionValidation.InvalidPermissionError
A global default may be set with
config :ash_grant, field_group_permissions: :strict. IntroducedAshGrant.PermissionValidationandAshGrant.Info.field_group_permissions/1;AshGrant.Evaluator.normalize_permissions/1is now public.
[0.14.2] - 2026-06-23
Fixed
- Group-less (4-part) read grant was silently narrowed by a field-group grant (#116).
AshGrant.Evaluator.get_all_field_groups/4dropped group-less allow grants viaEnum.reject(&is_nil/1), so a broad "all fields" grant (employee:*:read:always) combined with a narrow field-group grant (employee:*:read:always:sensitive) downgraded the actor to only the narrow group's fields — every other field became%Ash.ForbiddenField{}, and viaAshGrant.Preparations.ApplyMaskingfields that should have been raw started being masked. Adding an allow grant must never subtract access. The union now collapses to[](the "unrestricted" signal every consumer already understands) whenever any matching allow grant is group-less, so a broader group-less grant is never narrowed by a more specific field-group grant. A group-less grant scoped to a different action is correctly excluded and does not over-grant.
[0.14.1] - 2026-04-13
Two fixes that make resolve_argument (introduced in 0.14.0) actually
usable outside AshGrant.PolicyTest — both bugs rendered the DSL sugar
a silent no-op in production. Also bumps the hard Ash floor to 3.19 for
compatibility-CI parity.
Fixed
resolve_argumentwas a silent no-op for non-plain-map actors (#101). Theneeds_resolution?/3optimization read permissions straight offactor.permissionsand returned[]for any actor that was not a literal map with a:permissionskey. Real Ash resource structs carry no such field — permissions come from the configuredPermissionResolver— so the change never ran in production, the argument stayednil, and argument-based scopes always denied.AshGrant.Changes.ResolveArgumentnow routes through the resource's configured resolver (same source asAshGrant.Check/FilterCheck) and conservatively resolves the argument when the resolver is absent or raises, rather than skipping.resolve_argumentsilently failed on CREATE for attribute-multitenant targets (#99).AshGrant.Changes.ResolveArgumentdid not forward the changeset's tenant toAsh.get!/Ash.load!, so whenever any hop infrom_pathpointed to a resource withmultitenancy strategy: :attribute, the fetch raised, the rescue returnednil, and the argument-based scope evaluated tofalse— denying the action. The change now passestenant: changeset.tenantto both the create-pathsafe_get/3and the update/destroy-pathsafe_load/3.
Changed
- Ash floor bumped from
~> 3.7to~> 3.19(#98). Aligns the declared minimum with the version the compatibility CI matrix already exercises.
Documentation
- ExDoc now surfaces the
scope-naming-convention.mdandargument-based-scope.mdguides in its extras list (previously authored but unlinked inmix.exs). AshGrant.ArgumentAnalyzer@moduledocno longer references a removed helper onAshGrant.Check.
[0.14.0] - 2026-04-13
This release is centred on a new argument-based scope pattern for
multi-hop authorization, fixes a related security bypass in composite
scope routing, and begins deprecating the write: scope option in favour
of the new pattern.
Added
resolve_argumentDSL entity for argument-based scopes (#90). Declare a scope that compares an action argument (^arg(:name)) to actor attributes, and let the resource populate the argument from its own relationships:ash_grant do scope :at_own_unit, expr(^arg(:center_id) in ^actor(:own_org_unit_ids)) resolve_argument :center_id, from_path: [:order, :center_id] endThe transformer validates the path, detects dead declarations, and auto-injects an argument + a lazy change on every write action. The change only performs the DB load when an in-play permission uses a scope that actually references the argument — direct-attribute scopes pay zero cost. Multi-hop paths (e.g.,
[:order, :customer, :organization_id]) are supported. See the new Argument-Based Scope guide.^arg(:name)templates in scope expressions now resolve correctly at strict-check time (#88). PreviouslyAshGrant.Checknever forwardedchangeset.argumentstoAsh.Expr.fill_template, so any scope using^arg(...)crashed withBadMapErrorbefore this release.Explain surface for
resolve_argument(#93).AshGrant.Explanationgains a:resolve_argumentsfield populated byExplainer.explain/4, andExplanation.to_string/2renders a new "Argument Resolution" section showing each declared resolver, its path, and which scopes trigger it.PolicyTest support for action arguments (#93).
assert_can/assert_cannotaccept a keyword-list third argument —[record: ..., arguments: ...]— andYamlParserparses anarguments:field on each test.DslGeneratoremits the keyword-list form when converting YAML→DSL.
Fixed
- Composite scope on create security bypass (#87, closes #83).
should_use_db_query?/3now inspects the resolved filter (with inheritance applied) instead of the child scope's rawscope_def.filter. Composite scopes inheriting a relational parent (exists()or dot-path) no longer skip the DB query fallback on create actions. Before this fix,:at_own_unit_and_small(inheriting a relational:at_own_unitparent) could silently allow unauthorized creates when the parent'sexists()condition evaluated truthy against a virtual record. - Detector coverage for function-wrapped relational references and lists (#87).
contains_relationship_reference?/1now descends into%{__function__?: true, arguments: ...}structs, list RHS ofinoperators, and handlesnilexplicitly.
Deprecated
write:option onscope(#91). The option was introduced as an escape hatch for relational scopes that couldn't be evaluated in memory on writes. Withresolve_argumentnow providing a first-class way to express multi-hop authorization via in-memory-evaluable scopes,write:is redundant for the common case. Using it still works but emits a compile-time deprecation warning pointing at the replacement pattern. A regression test (#92) asserts the warning emits with the correct message.
Changed
- Scope naming: the unrestricted scope is renamed from
:allto:alwaysacross the codebase, guides, and test fixtures (#84)."all"is still accepted as a boolean-true scope for backward compatibility; new code should use:always. A Scope Naming Convention guide documents the "sentence test" that motivated the rename.
Documentation
- New Argument-Based Scope guide with full Refund → Order → center_id example, hand-rolled "under the hood" section, safety analysis, and gotchas (#89).
- Cross-links from
authorization-patterns.md,checks-and-policies.md,policy-testing.md,scope-naming-convention.md,debugging-and-introspection.md,scopes.md,getting-started.md(#94). usage-rules.md(the AI-agent-facing rulebook) rewritten to recommendresolve_argument, flagwrite:as deprecated, and document the new DSL entity (#95)./prand/releaseskill doc gates extended to checkusage-rules.mdandguides/*.md— structural fix so these files aren't silently missed in future PRs (#95).- New tip in
guides/scopes.mdpreferring direct FK column (is_nil(team_id)) over relationship traversal (is_nil(team.id)) when the check is really about the FK itself (#96).
Follow-up / related
- #86 (DB-query fallback limitations on function-wrapped relational refs during create) was closed as "not planned" — the argument-based pattern covers the motivating cases cleanly, and the fallback path is no longer the recommended route for authorization involving relationships.
[0.13.5] - 2026-04-08
Changed
- Remove noisy
:all/:alwaysscope warning: The compile-time warning fromValidateScopesthat fired for every resource without a universal scope has been removed. This was a best-practice hint rather than a real validation, and produced excessive noise in projects with many resources — especially with--warnings-as-errors. (#81)
[0.13.4] - 2026-04-06
Fixed
- Generic action authorization:
AshGrant.Checknow correctly extracts tenant fromaction_inputfor generic actions (type:action). Previously,get_tenant/1only handledqueryandchangeset, causing tenant-aware resolvers to return empty permissions for generic actions. The same fix is applied toFilterCheckandFieldCheck. (#76) default_policiesnow covers generic actions:AddDefaultPoliciestransformer generates a policy foraction_type(:action)usingAshGrant.Check. Previously, resources withdefault_policies trueand generic actions had no matching policy, resulting in forbidden. (#76)- Write scope evaluation: Use
fill_templateand passresource:option toAsh.Expr.evalfor correct template resolution and attribute hydration in write scope checks. (#75)
[0.13.2] - 2026-03-29
Changed
- Action type wildcard (
read*) no longer matches by string prefix. Previously,read*matched both actions starting with "read" (e.g.,read_all) and actions with:readaction type (e.g.,list). Nowread*matches only by action type — useread(exact) for the action named "read". This prevents false matches where an action namedread_somethingcould be a non-read type. (#72)
[0.13.1] - 2026-03-29
Added
- Authorization Patterns guide: New ExDoc guide covering RBAC, ABAC, ReBAC, and additional patterns (deny-wins, multi-tenancy, field-level access, domain inheritance, CanPerform). Includes practical scope DSL examples for each pattern and a comparison table. (#70)
- Usage rules: Document
instance_keyandscope_throughin policy test fixtures. (#69)
[0.13.0] - 2026-03-24
Added
instance_keyDSL option: Match instance permission IDs against a field other than:id. For example,instance_key :feed_idmakes"feed:feed_abc:read:"generateWHERE feed_id IN ('feed_abc')instead ofWHERE id IN ('feed_abc'). Works with FilterCheck, Check, and CanPerform. (#62)scope_throughentity: Propagate parent resource instance permissions to child resources viabelongs_torelationships. When a user has"feed:feed_abc:read:", addingscope_through :feedto the child resource grants access to all child records wherefeed_id == "feed_abc". Supports action filtering withactions:option. (#62)ValidateScopeThroughstransformer: Compile-time validation thatscope_throughreferences validbelongs_torelationships.
Changed
- Documentation refactored into ExDoc guides: README trimmed from 1,687 to 166 lines. Content split into 7 focused guides (Getting Started, Permissions, Scopes, Field-Level Permissions, Checks & Policies, Debugging & Introspection, Policy Testing) with ExDoc sidebar grouping under "Guides". (#65)
- Issue #65 documentation improvements: Action wildcard type clarification (
read*matches action type, not string prefix), instance permission boundary note, per-actiondefault_policiessubsection, RBAC + instance OR combination example, relational scopes tip in Getting Started.
[0.12.0] - 2026-03-16
Added
CanPerformcalculation for per-record UI visibility: NewAshGrant.Calculation.CanPerformmodule produces per-record boolean values (e.g.,:can_update?,:can_destroy?) that compile to SQL viaexpression/2— no N+1 queries. Supports RBAC scopes, instance permissions, deny-wins, and multi-scope OR combination. (#58)can_performDSL entity: Declare individual CanPerform calculations inline in theash_grantblock with optional custom naming (e.g.,can_perform :read, name: :visible?). The transformer auto-detects the resource module.can_perform_actionsbatch option: Generate multiple CanPerform calculations at once (e.g.,can_perform_actions [:update, :destroy]generates:can_update?and:can_destroy?).- Compile-time action name validation:
can_performandcan_perform_actionsnow raiseSpark.Error.DslErrorat compile time if a referenced action does not exist on the resource, preventing typos from silently producing always-false calculations. (#60) AshGrant.Info.can_perform_actions/1: Introspection helper to query configured batch actions.
[0.11.1] - 2026-03-15
Changed
- Documentation: Add domain-level DSL section to README with usage guidance, inheritance rules, and examples. Update installation version, feature list, and DSL Configuration table.
- Developer tooling: Add
/prand/releaseslash commands for streamlined PR and release workflows with built-in documentation gates. Update CLAUDE.md architecture section.
[0.11.0] - 2026-03-15
Added
- Domain-level DSL (
AshGrant.Domain): Define sharedresolverandscopeat the Ash Domain level. Resources using theAshGrantextension automatically inherit domain config, eliminating repeatedash_grant doblocks across resources. Resource-level settings take precedence (resolver override, same-name scope override). Cross-boundary scope inheritance is supported (resource scope can inherit from a domain-defined parent). (#54)
[0.10.3] - 2026-03-15
Fixed
default_field_policiesincludes PK/timestamps, fails on OTP 28: Whenfield_group :admin, :allis used withdefault_field_policies true, generated field policies now exclude primary keys and non-public attributes (e.g.created_at,updated_at). Previously these invalid fields causedSpark.Error.DslErroron OTP 28 due to transformer ordering differences. (#51)
Changed
- Resolve all credo issues: Fixed 82 credo warnings including
length/1comparisons,Enum.map_joinusage, negated conditions, nesting depth, and cyclomatic complexity.mix credonow reports zero issues.
0.10.2 - 2026-03-13
Fixed
- Overlapping field_policies with
:allfield_groups: When multiplefield_groupdefinitions use:all(or:all, except:), resolved fields overlapped across policies. Since Ash requires ALL matchingfield_policyentries to pass, fields in multiple groups were denied even when the actor had the correct permission.AddFieldPoliciesnow deduplicates fields across groups so each field appears in exactly one policy. (#48)
0.10.1 - 2026-03-12
Fixed
- Action prefix patterns now match by Ash action type:
read*matches any:read-type action (e.g.,list_published,by_slug,search), not just actions whose name starts with"read". Same forcreate*,update*,destroy*. (#46) - Explainer prefix matching:
Explainer.matches_action?/2only did exact match — now usesPermission.matches_action?/3with full prefix and action-type support.
0.10.0 - 2026-03-12
Changed (BREAKING)
field_groupDSL redesign (#40): Removed positional argument ambiguity betweeninheritsandfields- BREAKING:
inheritsis now keyword-only:field_group :name, [:parents], [:fields]no longer works. Usefield_group :name, [:fields], inherits: [:parents]instead. - BREAKING:
[:*]replaced by:all: Usefield_group :name, :allinstead offield_group :name, [:*](deprecated[:*]still works with a warning, will be removed in v1.0.0) - New
:allsyntax:field_group :admin, :all— all resource attributes - Blacklist mode:
field_group :public, :all, except: [:salary, :ssn] - Combined:
field_group :editor, :all, except: [:admin_notes], inherits: [:base] - Most common pattern unchanged:
field_group :public, [:name, :department]works as before
- BREAKING:
Migration Guide
# Before (v0.9.0)
field_group :public, [], [:*], except: [:salary, :ssn]
field_group :sensitive, [:public], [:phone, :address]
field_group :confidential, [:sensitive], [:salary, :email]
# After (v0.10.0)
field_group :public, :all, except: [:salary, :ssn]
field_group :sensitive, [:phone, :address], inherits: [:public]
field_group :confidential, [:salary, :email], inherits: [:sensitive]Deprecated
[:*]wildcard syntax: Use:allinstead.[:*]still works but emits a compile-time deprecation warning. Will be removed in v1.0.0.
0.9.0 - 2026-03-12
Added
field_groupexceptoption (blacklist mode): Use[:*]wildcard withexceptto exclude specific fields instead of listing all visible ones. Useful for resources with many attributes where only a few are sensitive. (#36)field_group :public, [], [:*], except: [:salary, :ssn]— all attributes except salary and ssn[:*]withoutexceptexpands to all resource attributes- Compile-time validations:
exceptrequires[:*], except fields must exist, masked fields cannot be inexcept - New transformer
AshGrant.Transformers.ResolveFieldGroupExceptresolves wildcards before downstream validation
0.8.1 - 2026-03-11
Fixed
- DB query fallback now handles
Ash.Query.Call(dot-path expressions): Expressions likeexpr(order.center_id in ^actor(:ids))are now correctly detected as relationship references and trigger the DB query fallback for write actions. Previously onlyexists()expressions were detected. (#33) - Pass
tenant:toAsh.exists?/2calls: Multitenanted resources no longer fail silently during DB query fallback write checks. (#33)
0.8.0 - 2026-03-11
Added
- DB query fallback for relational write scopes: Scopes using
exists()or dot-path references now work correctly for write actions (create, update, destroy) without requiring awrite:option. When a scope has relationship references and no explicitwrite:override,AshGrant.Checkautomatically queries the database using the read scope expression. (#28)- Update/destroy: Queries DB to check if the existing record matches the read scope
- Create: Splits the filter — direct-attribute conditions are evaluated in-memory, relationship conditions are verified via DB query on parent resources
write:option still works as an explicit override (backward compatible)- Resources without a data layer fall back to in-memory evaluation (existing behavior)
Removed
- Compile-time warning for relationship scopes without
write:: The warning is no longer needed since the DB query fallback handles these cases automatically. (#28)
0.7.0 - 2026-03-11
Added
- Dual read/write scope (
write:option): Scopes can now provide a separate expression for write actions via thewrite:option. This solves the authorization bypass whereexists()and dot-path scopes were silently replaced withtrueduring in-memory evaluation for write actions. (#26)write: expr(...)— direct-field expression for in-memory evaluationwrite: false— explicitly deny writes with this scopewrite: true— allow all writes with this scope (no filtering)- Falls back to
filterwhenwrite:is omitted (backward compatible) - Inheritance support: child scopes inherit parent's
write:expression;write: falsepropagates to children - New
AshGrant.Info.resolve_write_scope_filter/3function for write scope resolution
Changed
- Compile-time warning for relationship scopes: The warning for
exists()/dot-path scopes now fires regardless ofdefault_policiessetting and also detects dot-path references (not justexists()). The warning is suppressed when awrite:option is provided. (#26) AshGrant.Checkuses write scope resolution: Write actions now resolve scopes viaresolve_write_scope_filter/3instead ofresolve_scope_filter/3, using thewrite:expression when available. (#26)
0.6.1 - 2026-03-01
Fixed
- Bulk operations crash with
exists()scopes:Ash.bulk_create/4(and bulk_update/bulk_destroy) crashed withnil.persisted(:relationships_by_name)when the resource had anexists()scope expression. The fix replacesexists()nodes withtruebefore in-memory evaluation. Attribute-based conditions in the same scope are still enforced. (#23)
Added
- Compile-time warning for
exists()scopes: Resources withdefault_policiesincluding write actions now emit a warning when scopes containexists(), informing users that the relational condition is not enforced for writes - Documentation: Added "Relational Scopes" section to README and moduledocs explaining the
exists()limitation for write actions
0.6.0 - 2026-02-19
Added
Field-Level Permissions: Column-level read authorization via field groups
field_groupDSL entity with inheritance (DAG-based) and masking support- 5-part permission format:
resource:instance:action:scope:field_group(backward compatible with 4-part) AshGrant.FieldCheck- SimpleCheck for Ashfield_policiesintegrationAshGrant.field_check/1- Public API for use in manualfield_policies(Mode A)default_field_policies: true- Auto-generatefield_policiesfromfield_groupdefinitions (Mode B)- Field group inheritance: child groups include all parent fields
- Field masking with allow-wins semantics via
maskandmask_withoptions
New Modules:
AshGrant.Dsl.FieldGroup- Struct and DSL entity for field group definitionsAshGrant.FieldCheck- SimpleCheck for field-level authorizationAshGrant.Preparations.ApplyMasking- Runtime masking viaafter_actionhookAshGrant.Transformers.AddFieldPolicies- Auto-generatesfield_policiesfrom field groupsAshGrant.Transformers.AddMaskingPreparation- Auto-registers masking preparationAshGrant.Transformers.ValidateFieldGroups- Compile-time validation (duplicates, cycles, missing parents)
New Evaluator Functions:
get_field_group/3- Get first matching field group from permissionsget_all_field_groups/3- Get all matching field groups (union for field access)
New Info Functions:
field_groups/1- Get all field group definitions for a resourceget_field_group/2- Get a specific field group by nameresolve_field_group/2- Resolve a field group with inheritancedefault_field_policies/1- Get thedefault_field_policiessettingfetch_permissions/3- Shared permission resolution helper
Introspect Updates:
actor_permissions,available_permissions,can?, andallowed_actionsnow includefield_groups/field_groupin their responsesExplainer & PolicyExport: Field group information in
explain/4output, Markdown tables, and Mermaid diagrams
Changed
- Permission format: Extended from 4-part to optional 5-part with
field_group - Transformer ordering:
ValidateFieldGroupsnow explicitly runs beforeAddFieldPoliciesandAddMaskingPreparation
0.5.0 - 2026-01-21
Added
Policy Configuration Testing: DSL-based testing framework for verifying policy configurations without a database
AshGrant.PolicyTest- Main module withusemacro for defining policy testsassert_can/2,3andassert_cannot/2,3assertion macrosresource/1,actor/2,describe/2,test/2DSL macrosAshGrant.PolicyTest.Runner- Test execution with summary statisticsAshGrant.PolicyTest.Result- Result struct with timing information
YAML Policy Tests: Alternative format for non-Elixir developers
AshGrant.PolicyTest.YamlParser- Parse and run YAML test filesAshGrant.PolicyTest.YamlExporter- Export DSL tests to YAMLAshGrant.PolicyTest.DslGenerator- Generate DSL code from YAML
Policy Export: Export policy configurations to documentation formats
AshGrant.PolicyExport.Mermaid- Generate Mermaid flowchart diagramsAshGrant.PolicyExport.Markdown- Generate Markdown documentation
Mix Tasks: CLI tools for policy testing and export
mix ash_grant.verify- Run policy configuration testsmix ash_grant.export- Export policies to YAML/Mermaid/Markdownmix ash_grant.import- Convert YAML to Elixir DSL
Dependencies
- Added
yaml_elixir ~> 2.9as optional dependency for YAML support
0.4.1 - 2026-01-21
Added
- SAT Solver Optimization Callbacks: Implements
Ash.Policy.Checkoptional callbacks for smarter authorization decisionssimplify/2- Returns ref unchanged (permissions are runtime-resolved)implies?/3- Returnstruewhen check refs have identical module and optionsconflicts?/3- Returnsfalse(deny-wins is handled at evaluation time)- Enables the authorizer to reach decisions with fewer variables in conditions
- Suggested by Jonatan Männchen (Ash contributor)
Changed
Version management: Removed hardcoded version numbers from documentation
- README.md now references GitHub without specific tag (always uses latest)
@moduledocinstallation section now links to README- Single source of truth:
mix.exs @version
Deprecation timeline: Extended
owner_fielddeprecation from v0.3.0 to v1.0.0- Affects:
dsl.ex,info.ex,validate_scopes.ex,CHANGELOG.md
- Affects:
0.4.0 - 2026-01-05
Added
Permission Introspection Module: New
AshGrant.Introspectmodule for runtime permission queriesactor_permissions/3- Admin UI: Display all permissions with their status for an actoravailable_permissions/1- Permission management: List all possible permission combinationscan?/4- Debugging: Simple check returning:allowor:denywith detailsallowed_actions/3- API response: List allowed actions (with optional:detailedmode)permissions_for/3- Raw access to permission strings from resolver- All functions support
:contextoption for resolver context
Instance Permission Read Support: Instance permissions now work with read actions (
filter_check/1)AshGrant.Evaluator.get_matching_instance_ids/3extracts instance IDs from permissionsFilterCheckcombines RBAC scopes with instance ID filters using OR logic- Enables Google Docs-style sharing where specific resources are shared with specific users
- Example:
"doc:doc_abc123:read:"allows reading the specific document
0.3.1 - 2025-01-05
Added
Scope Descriptions: Optional
descriptionfield for scopes in the DSLscope :own, [], expr(author_id == ^actor(:id)), description: "Records owned by the current user"AshGrant.Info.scope_description/2to retrieve scope descriptions programmatically- Descriptions are displayed in
explain/4output for better debugging
Authorization Debugging with
explain/4: NewAshGrant.explain/4function for debugging authorization decisions- Returns
AshGrant.Explanationstruct with detailed decision info - Shows matching permissions with metadata (description, source)
- Shows all evaluated permissions with match/no-match reasons
- Includes scope information from both permissions and DSL definitions
AshGrant.Explanation.to_string/2for human-readable output with ANSI colors
- Returns
New Modules:
AshGrant.Explanation- Struct for authorization decision explanationsAshGrant.Explainer- Builds detailed authorization explanations
0.3.0 - 2025-01-04
Added
Permission Metadata:
AshGrant.PermissionInputstruct for permissions with metadatadescription- Human-readable description of the permissionsource- Where the permission came from (e.g., "role:admin")metadata- Additional arbitrary metadata as a map
Permissionable Protocol:
AshGrant.Permissionableprotocol for converting custom structs to permissions- Implement for your own structs to return them directly from resolvers
- Default implementations for
BitString,PermissionInput, andPermission
Instance Permissions with Scopes (ABAC): Instance permissions now support scope conditions
doc:doc_123:update:draft- Update only when document is in draft statusdoc:doc_123:read:business_hours- Access only during business hoursinvoice:inv_456:approve:small_amount- Approve only below threshold- Scopes are now treated as "authorization conditions" rather than just "record filters"
- Empty scopes (trailing colon) remain backward compatible ("no conditions")
New Evaluator Functions:
get_instance_scope/3- Get the scope from a matching instance permissionget_all_instance_scopes/3- Get all scopes from matching instance permissions
Context Injection for Testable Scopes: Scopes can now use
^context(:key)for injectable valuesscope :today_injectable, expr(fragment("DATE(inserted_at) = ?", ^context(:reference_date)))scope :threshold, expr(amount < ^context(:max_amount))- Enables deterministic testing of temporal and parameterized scopes
- Values are passed via
Ash.Query.set_context(%{reference_date: ~D[2025-01-15]})
Changed
- Documentation: Clarified that scope represents an "authorization condition" that can apply to both RBAC and instance permissions, enabling full ABAC (Attribute-Based Access Control)
0.2.2 - 2025-01-02
Fixed
- Documentation: Removed deprecated
owner_fieldfrom README examples - Documentation: Added note that instance permissions currently only work with write actions (
check/1)
Changed
- Tests: Enabled previously skipped "own" scope update tests that now pass
0.2.1 - 2025-01-01
Added
- Multi-tenancy Support: Full support for Ash's
^tenant()template in scope expressionsscope :same_tenant, expr(tenant_id == ^tenant())now works correctly- Tenant context is passed through to
Ash.Expr.eval/2 - Smart fallback evaluation when Ash.Expr.eval returns
:unknown
- TenantPost test resource: Demonstrates multi-tenancy scope patterns
Deprecated
owner_fieldDSL option: This option is deprecated and will be removed in v1.0.0. Use explicit scope expressions instead:
The fallback evaluation now extracts the field from the scope expression directly.# Instead of: owner_field :author_id # Use: scope :own, expr(author_id == ^actor(:id))
Improved
- Fallback expression evaluation: Smarter handling when
Ash.Expr.evalcan't evaluate- Analyzes filter to detect
^tenant()and^actor()references - Automatically extracts actor field from the filter expression (no
owner_fieldneeded) - Proper tenant isolation for write actions
- Analyzes filter to detect
0.2.0 - 2025-01-01
Added
- Default Policies: New
default_policiesDSL option to auto-generate standard policiesdefault_policies trueor:all- Generate both read and write policiesdefault_policies :read- Only generate filter_check policy for read actionsdefault_policies :write- Only generate check policy for write actions- Eliminates boilerplate policy declarations for common use cases
- Transformer:
AshGrant.Transformers.AddDefaultPoliciesgenerates policies at compile time - Info helper:
AshGrant.Info.default_policies/1to query the setting
Improved
- Expression evaluation: Now uses
Ash.Expr.eval/2for proper Ash expression handling- Full support for all Ash expression operators (not just
==andin) - Proper actor template resolution (
^actor(:id),^actor(:tenant_id), etc.) - Proper tenant template resolution (
^tenant()) - Handles nested actor paths automatically
- Full support for all Ash expression operators (not just
- Code quality: Removed ~60 lines of custom expression handling in favor of Ash built-ins
DSL Configuration (Updated)
ash_grant do
resolver MyApp.PermissionResolver # Required
default_policies true # NEW: auto-generate policies
resource_name "custom_name" # Optional
scope :all, true
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
end0.1.0 - 2025-01-01
Added
- Unified Permission Format: New 4-part permission syntax
resource:instance_id:action:scope- RBAC permissions:
blog:*:read:all(instance_id =*) - Instance permissions:
blog:post_abc123:read:(specific instance) - Backward compatible with legacy 2-part and 3-part formats
- RBAC permissions:
- Scope DSL: Define scopes inline within resources using the
scopeentityscope :all, truescope :own, expr(author_id == ^actor(:id))scope :published, expr(status == :published)- Scope inheritance with
scope :own_draft, [:own], expr(status == :draft)
- Deny-wins semantics: Deny rules always override allow rules
- Wildcard matching:
*for resources/actions,read*for action prefixes - Two check types:
AshGrant.filter_check/1for read actions (returns filter expression)AshGrant.check/1for write actions (returns true/false)
- Property-based testing: 34 property tests for edge case discovery
- Comprehensive test coverage: 211 total tests (19 doctests + 34 properties + 158 unit tests)
DSL Configuration
ash_grant do
resolver MyApp.PermissionResolver # Required
resource_name "custom_name" # Optional
# Inline scope definitions (new!)
scope :all, true
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
endBehaviours
AshGrant.PermissionResolver- Resolves permissions for actorsAshGrant.ScopeResolver- Legacy: translates scopes to Ash filters (deprecated in favor of scope DSL)
Modules
| Module | Description |
|---|---|
AshGrant | Main extension with check/1 and filter_check/1 |
AshGrant.Permission | Permission parsing and matching |
AshGrant.Evaluator | Deny-wins permission evaluation |
AshGrant.Info | DSL introspection helpers |
AshGrant.Check | SimpleCheck for write actions |
AshGrant.FilterCheck | FilterCheck for read actions |