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")
# => falseThat 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:
:off— do nothing:warn— emit aLogger.warning(deduplicated per process):strict— raiseAshGrant.IndeterminateMatch.IndeterminateMatchError
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: :strictDefault 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
@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.
@type mode() :: :off | :warn | :strict
Functions
@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.
@spec mode() :: mode()
Returns the configured mode (default :warn).