Skip to content

feat: Relationship Through#2567

Open
ken-kost wants to merge 26 commits intoash-project:mainfrom
ken-kost:feat/relationship-through-2
Open

feat: Relationship Through#2567
ken-kost wants to merge 26 commits intoash-project:mainfrom
ken-kost:feat/relationship-through-2

Conversation

@ken-kost
Copy link
Contributor

@ken-kost ken-kost commented Feb 17, 2026

Contributor checklist

#72
#1780

Related to: ash-project/ash_postgres#686 & ash-project/ash_sql#212

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

@ken-kost
Copy link
Contributor Author

@zachdaniel Reponed.

I force pushed something to main and it looks like it closed all PRs and broke the history somehow

Interesting. & scary - force pushing to main 😆

@zachdaniel
Copy link
Contributor

Yeah it's a long story 😢 I literally force pushed an entire different project to ash main. Was ridiculous (and it wasn't claude, it was just me 🤦)

@ken-kost ken-kost marked this pull request as ready for review March 2, 2026 12:06
@ken-kost
Copy link
Contributor Author

ken-kost commented Mar 2, 2026

Okay, I added some tests all over the place. On the last one I found missing tenant propagation. ash_postgres still has my initial suit of tests. Is this enough? 🤔

@zachdaniel
Copy link
Contributor

Will reevaluate in a few days! Thanks for your hard work! 🙇

@zachdaniel
Copy link
Contributor

I think the only other missing piece here is a verifier that runs to confirm that all relationships along the path exist and match.

@ken-kost ken-kost requested a review from zachdaniel March 5, 2026 13:28
@zachdaniel
Copy link
Contributor

This PR doesn't appear to test authorization on intermediate hops for through
relationships. Looking at the lateral join path in relationships.ex, the new
is_list(Map.get(relationship, :through)) branch builds the path without
calling Ash.can on intermediate queries, unlike the many_to_many branch which
explicitly does.

Here's a minimal test that demonstrates the issue:

  defmodule Ash.Test.Actions.ThroughAuthorizationTest do
    use ExUnit.Case, async: true

    alias Ash.Test.Actions.ThroughAuthorizationTest, as: ThisTest

    defmodule Domain do
      use Ash.Domain
      resources do
        allow_unregistered? true
      end
    end

    defmodule Post do
      use Ash.Resource, domain: ThisTest.Domain, data_layer: Ash.DataLayer.Ets
      ets do private?(true) end
      actions do
        default_accept :*
        defaults [:read, :destroy, create: :*, update: :*]
      end
      attributes do
        uuid_primary_key :id
        attribute :title, :string, public?: true
      end
      relationships do
        has_many :post_links, ThisTest.PostLink, public?: true,
  destination_attribute: :source_id
        has_many :linked_posts, __MODULE__, through: [:post_links, :destination]
      end
    end

    defmodule PostLink do
      use Ash.Resource,
        domain: ThisTest.Domain,
        data_layer: Ash.DataLayer.Ets,
        authorizers: [Ash.Policy.Authorizer]
      ets do private? true end
      actions do
        default_accept :*
        defaults [:read, :destroy, create: :*, update: :*]
      end
      policies do
        policy action_type(:create) do
          authorize_if always()
        end
        policy action_type(:read) do
          authorize_if expr(visible == true)
        end
      end
      attributes do
        uuid_primary_key :id
        attribute :source_id, :uuid, public?: true
        attribute :visible, :boolean, public?: true, default: true
      end
      relationships do
        belongs_to :destination, ThisTest.Post, public?: true
      end
    end

    test "loading a through relationship respects policies on the intermediate
  resource" do
      source = Post |> Ash.Changeset.for_create(:create, %{title: "source"}) |>
  Ash.create!()
      visible_dest = Post |> Ash.Changeset.for_create(:create, %{title:
  "visible"}) |> Ash.create!()
      hidden_dest = Post |> Ash.Changeset.for_create(:create, %{title:
  "hidden"}) |> Ash.create!()

      PostLink
      |> Ash.Changeset.for_create(:create, %{source_id: source.id,
  destination_id: visible_dest.id, visible: true})
      |> Ash.create!()

      PostLink
      |> Ash.Changeset.for_create(:create, %{source_id: source.id,
  destination_id: hidden_dest.id, visible: false})
      |> Ash.create!()

      # Without auth — both links traversable
      assert length(Ash.load!(source, :linked_posts, authorize?:
  false).linked_posts) == 2

      # With auth — PostLink policy should filter out the hidden link
      result = Ash.load!(source, :linked_posts, authorize?: true, actor: %{})
      assert length(result.linked_posts) == 1
      assert hd(result.linked_posts).title == "visible"
    end
  end

This fails — result.linked_posts returns 2 records instead of 1, confirming
that the intermediate PostLink read policy (visible == true) is not being
enforced during through relationship loading.

@zachdaniel
Copy link
Contributor

found an issue, had claude put the above together

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.

2 participants