AshArcadic.ManualRelationships.Traverse (AshArcadic v0.1.0)

Copy Markdown View Source

Bounded variable-length graph traversal as an Ash manual relationship.

has_many :descendants, MyApp.Node do
  manual {AshArcadic.ManualRelationships.Traverse,
          edge_label: :PARENT_OF, direction: :outgoing, min_depth: 1, max_depth: 3,
          scope_edges: true}
end

load/3 runs the Option-B two-phase read with per-hop authorization:

  • Phase 1 (reachability). One parameterized statement — UNWIND $ids AS sid MATCH p=<pattern> WHERE <src-pk-match> [AND <tenant scope>] RETURN <src-pk cols>, b.<dest-pk> AS d, [n IN nodes(p) | n.<dest-pk>] AS path — returns each matched path's destination PK plus the PKs of EVERY node on that path, not the vertices. For :attribute, the tenant scope binds the path and filters BOTH nodes and edges by the discriminator — ALL(x IN nodes(p) WHERE x.<attr> = $tenant) AND ALL(r IN relationships(p) WHERE r.<attr> = $tenant) — on by default; scope_edges: false opts out of the edge clause (probe E4).
  • Phase 2 (two authorized reads). Read A authorizes EVERY path node: a STANDARD Ash.read on the destination resource over the union of every path node's PK (destinations AND intermediates), carrying actor/authorize?/tenant/domain so row policy alone decides each node's visibility (Read A does NOT carry the caller's destination filter/sort — a filtered-out but authorized intermediate is still traversable). Each source's destinations that have a fully-authorized path then form Read B: a STANDARD authorized Ash.read narrowing context.query (the relationship/caller filter + sort + domain + tenant) to those surviving dest PKs, applying row policy, field policy (redaction), and the :attribute tenant filter / :context tenant DB. This module never decodes a vertex. NOTE: Ash rejects DYNAMIC limit/offset on manual relationships; per-source paging is therefore the STATIC per_source_limit/per_source_offset opts, applied as a post-authz, post-sort top-N slice in Phase 3 (regroup), never via Ash paging. The caller filter/sort apply to destinations by Read B.
  • Phase 3 (regroup). Assembles each source's records from Read B in READ order (so the caller sort survives), keeping those whose PK survived per-hop authorization — a destination reachable ONLY through a row-policy-denied intermediate is dropped, while a destination with any fully-authorized path survives; deduped per source, then sliced to the per-source per_source_offset..+per_source_limit top-N (post-authz, post-sort; nil limit = unbounded), cardinality-aware.

It returns a source-PK-keyed map of authorized destination records; authorization is enforced by row policy on EVERY node on the path (Read A), not only the returned destinations. This covers self-referential traversal (the shipped norm); a multi-resource path whose intermediates carry a different policy is a Slice-3 concern and fails closed here. A single-attribute destination primary key is required (composite → fail-closed value-free, mirroring the edge-write dest-PK rule). Values ride params; every interpolated identifier is AshArcadic.Identifier.validate!-checked. Tenancy is FAIL-CLOSED twice over: Phase 1 scopes the path (or targets the per-tenant :context database), and both Phase-2 reads re-apply the tenant filter / database.