AshGrant.IndeterminateMatch (AshGrant v0.20.0)

Copy Markdown View Source

Signals authorization answers that could not actually be determined (issue #126).

A type wildcard ("@read", or the deprecated "read*") matches on the Ash action type, so it can only be evaluated when an action_type is supplied. Every AshGrant.Evaluator entry point defaults action_type to nil, and with nil a type wildcard silently fails to match. The result is a two-layer asymmetry — same actor, same grant, opposite answers:

actor = %{permissions: ["document:*:@read:always"]}

# Takes the resource module, so it resolves the action type:
AshGrant.Introspect.can?(Document, :read, actor)
# => {:allow, ...}

# Takes a resource string, so it cannot — and answers anyway:
AshGrant.Evaluator.has_access?(actor.permissions, "document", "read")
# => false

That false is not "does not match", it is "could not tell" wearing "does not match" as a disguise — indistinguishable from a legitimate deny, which is what makes it expensive to debug. In practice it forces defensive read* + literal read grant pairs, and dropping the literal silently kills the feature it gated.

This module detects the case and signals it without changing the outcome:

Configure globally (the affected entry points take a resource string, so there is no resource module to read a DSL option from):

config :ash_grant, indeterminate_type_wildcard: :strict

Default is :warn.

How it decides — recompute and compare

Detection is sound: it never flags a call whose answer the skipped wildcards could not have changed, because false positives would make :strict unusable. Rather than reason about each function's return shape, guard/6 recomputes the same result twice:

  • once as the caller did — every type wildcard treated as non-matching;
  • once with those wildcards forced to match.

If the two results are equal, the wildcards were irrelevant (the answer is the same whichever way they would have resolved) and nothing is signalled — this correctly stays silent for a defensive wildcard-plus-literal pair, a scope-less wildcard that contributes nothing, or a query already settled by a concrete deny. If they differ, the true answer depends on a type the call did not supply, and it is reported.

Forcing all wildcards at once can, in rare mixed allow/deny cases, mask a difference (a false negative); it never invents one (no false positive).

Only RBAC grants (instance_id == "*") are forced. A type wildcard on an instance permission is dead outright — AshGrant.Permission.diagnostics/1 reports that — so it is treated as non-matching in both passes, never as indeterminate.

Fixing a reported call

Supply the action type, or use an API that resolves it for you:

# Resolves the type from the resource module:
AshGrant.Introspect.can?(MyApp.Document, :read, actor)

# Or pass it explicitly:
AshGrant.Evaluator.has_access?(perms, "document", "list_published", :read)

Summary

Types

Recomputes an entry point's result using the given per-permission match predicate.

Functions

Runs compute with match_fn, returning its result and signalling if a forced recompute would differ.

Returns the configured mode (default :warn).

Types

compute()

@type compute() :: ((AshGrant.Permission.t() -> boolean()) -> term())

Recomputes an entry point's result using the given per-permission match predicate.

The predicate replaces every Permission.matches?/4 (or matches_action?/3) call in the function body, so guard/6 can run the body a second time with wildcards forced.

mode()

@type mode() :: :off | :warn | :strict

Functions

guard(compute, match_fn, permissions, resource, action, action_type)

@spec guard(
  compute(),
  (AshGrant.Permission.t() -> boolean()),
  [AshGrant.Permission.t()],
  String.t(),
  String.t(),
  atom() | nil
) :: term()

Runs compute with match_fn, returning its result and signalling if a forced recompute would differ.

Never alters the outcome — the normal result is always what comes back. Raises IndeterminateMatchError only in :strict mode, and only when forcing the skipped type wildcards to match would change the result.

mode()

@spec mode() :: mode()

Returns the configured mode (default :warn).