feat: declarative grants DSL with compile-time verification#3
Merged
C-Sinclair merged 5 commits intomainfrom Apr 21, 2026
Merged
feat: declarative grants DSL with compile-time verification#3C-Sinclair merged 5 commits intomainfrom
C-Sinclair merged 5 commits intomainfrom
Conversation
Adds a `grants` block to the `ash_grant` DSL so permissions can be declared as data rather than returned from an imperative resolver function. Each grant pairs an actor predicate with a set of named, structured permissions and optional compliance-purpose metadata. - `grant :name, predicate do ... end` — predicate is a 1-arity function over the actor; matching grants contribute their permissions to the synthesized resolver output - `permission :name, :action, :scope` — positional mirrors the string form; `on:`, `instance:`, `purpose(s):`, `deny:`, `description:` as keyword options - Extension-level `purposes [...]` declares the compliance vocabulary; unknown purpose atoms fail compilation - `NormalizeGrants` transformer injects the enclosing resource as the default `on:` and rejects declaring both `grants` and an explicit `resolver` - `ValidateGrantReferences` verifier checks that each permission's `on:` is an Ash.Resource, that `action:` exists on it (or is `:*`), and that `scope:` is defined in its `ash_grant` block - `SynthesizeGrantsResolver` wires `AshGrant.GrantsResolver` as the resource's resolver when grants are declared — no other runtime machinery changes; Check/FilterCheck/Explainer pick it up unchanged - `Info.grants/1`, `permissions/1`, `permissions_for_purpose/2`, `effective_purposes/2`, `declared_purposes/1` for compliance introspection
…on tests
Following review feedback:
- Grant predicates are now `Ash.Expr` expressions evaluated via
`Ash.Expr.eval/2` with `Ash.Expr.fill_template/2` for `^actor(:key)`
substitution. The function form was inconsistent with how scopes and
other AshGrant expressions already work.
grant :admin, expr(^actor(:role) == :admin) do
permission :manage_all, :*, :always
end
- Removed `purpose` / `purposes` entirely from the DSL, schema, Info,
NormalizeGrants, and tests. Compliance-purpose metadata needs deeper
design before shipping.
- Added end-to-end integration test (`grants_integration_test.exs`) that
exercises the full pipeline against AshPostgres and Ash.Policy.Authorizer:
read filtering via FilterCheck, create/update enforcement via Check,
:own scope narrowing, compound predicates, and actor/context edge cases.
- Added `AshGrant.Test.GrantsPost` test resource driven purely by the
synthesized resolver, backed by a real `grants_posts` table.
- DSL test suite rewritten with `expr()` predicates; added compound and
missing-field coverage.
Addressing review items: #1 (Important): GrantsResolver predicate eval no longer rescues silently. Any error is now logged at :warning with the resource and grant name; the grant is still treated as non-matching (fail-closed preserved). #6 (Important): The @grants section DSL example had stale function-form predicates. Updated to use `expr(^actor(:role) == :admin)`, matching what the feature actually ships. #7 (Important): Documented the known deny+sibling-allow limitation on `AshGrant.Dsl.Permission.@moduledoc`. The resolver emits the `!` prefix correctly and Evaluator honors deny-wins for broad allow + narrow deny, but narrow allow (:own) + narrow deny (:published) on the same action does not always surface in Check. Recommended workaround inline. jhlee111#8 (Minor): Pulled through caller-provided `context.context` into `Ash.Expr.fill_template/2` so grant predicates can reference `^context(:key)` the same way scopes can. jhlee111#10 (Minor): Tightened the `instance:` schema type from `:any` to `{:or, [{:in, [:*]}, :atom, :string]}`. Random structs and integers now fail at compile time instead of producing malformed permission strings at runtime. Added a DSL test covering context threading.
Resolves conflicts from two changes landed on main: - #2 removed scope inheritance (no impact here — grants don't use scope inheritance; tests still combine conditions inline with `and`). - #4 broke the compile-time cycle between resource and domain by removing the MergeDomainConfig transformer and the ValidateResolverPresent transformer, moving resolver-merge logic into AshGrant.Info.resolver/1 at runtime and turning ValidateResolverPresent into a verifier. Adjustments to this branch: - Dropped lib/ash_grant/transformers/validate_resolver_present.ex — the file was deleted on main (replaced by a verifier). My earlier explicit before?/after? guards in that file were no longer needed. - Reintegrated NormalizeGrants and SynthesizeGrantsResolver into the new transformer list (main-first ordering, no references to the now-gone MergeDomainConfig). - Added ValidateGrantReferences to the verifier list alongside the new ValidateResolverPresent and ValidateScopes verifiers. - Cleaned before?/after? in NormalizeGrants and SynthesizeGrantsResolver to reference only transformers that still exist. Full suite still green (1089 tests, 1 pre-existing unrelated failure). Credo clean on all new files.
2 tasks
C-Sinclair
added a commit
that referenced
this pull request
Apr 21, 2026
PR #3 merged the declarative grants DSL but left the README showing the old imperative-resolver pattern. This commit: - Adds the grants DSL to the feature list at the top - Replaces the Quick Start code sample to use a grants block (RBAC for admin/editor/viewer) instead of a case-statement resolver - Documents the function-form resolver as the mutually-exclusive escape hatch for runtime instance-specific permissions No code changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
grantsblock to theash_grantDSL so permissions can be declared as data rather than returned from an imperative resolver function. Each grant pairs an actor predicate (anAsh.Expr) with a set of named, structured permissions.Before
After
What's new
grant :name, expr(...) do ... end— predicate is anAsh.Exprover the actor, evaluated at runtime viaAsh.Expr.eval/2withAsh.Expr.fill_template/2for^actor(:key)substitution. Same expression machinery scopes already use — expressions all the way down.permission :name, :action, :scope— positional mirrors the permission string;on:,instance:,deny:,description:as keyword options.NormalizeGrantstransformer — injects the enclosing resource as the defaulton:, rejects declaring bothgrantsand an explicitresolver.ValidateGrantReferencesverifier — compile-time checks that each permission'son:is anAsh.Resource, thataction:exists on it (or is:*), and thatscope:is defined in itsash_grantblock.SynthesizeGrantsResolver+AshGrant.GrantsResolver— when grants are declared, wiresGrantsResolveras the resource's resolver.Check,FilterCheck, andExplainerpick it up unchanged.Info.grants/1,Info.get_grant/2,Info.permissions/1.Why
^actor(:key)in a grant predicate works the same way it does in a scope, with the same evaluation machinery.Scope of this PR
Resource-level grants only. Explicitly deferred:
purpose/purposescompliance metadata — removed from this PR; needs deeper design before shipping.grantsblock and dedicateduse AshGrant.Grantsmodule form — sensible follow-ups.!prefix correctly, but there appears to be a Check evaluation subtlety when an allow-grant and deny-grant match the same action for the same actor. A standalone deny grant works; a deny grant alongside a sibling allow grant needs a separate look. Deny-wins semantics for declared grants will be its own follow-up PR.expr()predicates inside conditional grant nesting — no intentional constraints, but worth a broader coverage pass with complex actors.Grants and an explicit
resolverare mutually exclusive — for runtime instance-specific grants (DB-backed per-row shares) keep using theresolverfunction escape hatch.Test plan
DSL tests (
test/ash_grant/grants_dsl_test.exs, 19 tests)::oninjection,:instanceoverride, deny flag[][]grants+ explicitresolverwith aSpark.Error.DslErrorpurpose:keyword with a schema errorIntegration tests (
test/ash_grant/grants_integration_test.exs, 17 tests — AshPostgres + Ash.Policy.Authorizer)::always, viewer only sees:published, stranger denied, nil actor denied:ownnarrows), admin can update anything:own)GrantsResolver; emits expected permission strings per roleFull suite: 1091 tests, 1 pre-existing failure unrelated to this PR (
default_field_policies_test.exs).Credo:
mix credo --strictclean on all new files.