Skip to content

feat: declarative grants DSL with compile-time verification#3

Merged
C-Sinclair merged 5 commits intomainfrom
claude/amazing-fermi-6accdd
Apr 21, 2026
Merged

feat: declarative grants DSL with compile-time verification#3
C-Sinclair merged 5 commits intomainfrom
claude/amazing-fermi-6accdd

Conversation

@C-Sinclair
Copy link
Copy Markdown

@C-Sinclair C-Sinclair commented Apr 21, 2026

Summary

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 (an Ash.Expr) with a set of named, structured permissions.

Before

resolver fn actor, _ ->
  case actor do
    %{role: :admin}  -> [\"post:*:*:always\"]
    %{role: :editor} -> [\"post:*:read:always\", \"post:*:update:own\"]
    %{role: :viewer} -> [\"post:*:read:published\"]
    _ -> []
  end
end

After

ash_grant do
  scope :always, true
  scope :own, expr(author_id == ^actor(:id))
  scope :published, expr(status == :published)

  grants do
    grant :admin, expr(^actor(:role) == :admin) do
      permission :manage_all, :*, :always
    end

    grant :editor, expr(^actor(:role) == :editor) do
      permission :read_all,   :read,   :always
      permission :update_own, :update, :own
    end

    grant :viewer, expr(^actor(:role) == :viewer) do
      permission :read_published, :read, :published
    end

    # Compound predicates — ABAC falls out for free
    grant :paid_editor, expr(^actor(:role) == :editor and ^actor(:plan) == :pro) do
      permission :destroy_own, :destroy, :own
    end
  end
end

What's new

  • grant :name, expr(...) do ... end — predicate is an Ash.Expr over the actor, evaluated at runtime via Ash.Expr.eval/2 with Ash.Expr.fill_template/2 for ^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.
  • NormalizeGrants transformer — injects the enclosing resource as the default on:, rejects declaring both grants and an explicit resolver.
  • ValidateGrantReferences verifier — compile-time 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 + AshGrant.GrantsResolver — when grants are declared, wires GrantsResolver as the resource's resolver. Check, FilterCheck, and Explainer pick it up unchanged.
  • IntrospectionInfo.grants/1, Info.get_grant/2, Info.permissions/1.

Why

  • Inspectable, diffable security config — grants become data a reviewer can scan top-to-bottom; the resolver becomes generated.
  • Compile-time safety — renaming an action or deleting a scope breaks compilation, not runtime.
  • Expressions all the way down^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 / purposes compliance metadata — removed from this PR; needs deeper design before shipping.
  • Domain-level grants block and dedicated use AshGrant.Grants module form — sensible follow-ups.
  • Declarative deny rules combined with allow rules for the same action — the resolver emits the ! 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 resolver are mutually exclusive — for runtime instance-specific grants (DB-backed per-row shares) keep using the resolver function escape hatch.

Test plan

DSL tests (test/ash_grant/grants_dsl_test.exs, 19 tests):

  • Grant + permission parsing, :on injection, :instance override, deny flag
  • Synthesized resolver emits correct permission strings for admin / editor / viewer / nil / unknown / specific-instance / compound-predicate actors
  • Actor missing predicate fields yields []
  • Missing resource in context yields []
  • Rejects grants + explicit resolver with a Spark.Error.DslError
  • Rejects removed purpose: keyword with a schema error

Integration tests (test/ash_grant/grants_integration_test.exs, 17 tests — AshPostgres + Ash.Policy.Authorizer):

  • Reads: admin sees all, editor sees all via :always, viewer only sees :published, stranger denied, nil actor denied
  • Creates: admin ✓, editor ✓, viewer ✗, stranger ✗
  • Updates: editor can update own, editor cannot update other's (scope :own narrows), admin can update anything
  • Compound predicate: free editor ✗ destroy, pro editor ✓ destroy own draft, pro editor ✗ destroy other's (scope :own)
  • Resolver identity: resource wired to GrantsResolver; emits expected permission strings per role

Full suite: 1091 tests, 1 pre-existing failure unrelated to this PR (default_field_policies_test.exs).

Credo: mix credo --strict clean on all new files.

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.
@C-Sinclair C-Sinclair merged commit 83c7c63 into main Apr 21, 2026
1 check passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant