How to define AshAuthentication policy through a many_to_many relationship?

I’m just getting my head around basic AshAuthorization and still struggle with some of the relationship DSL… and I’ve no idea how to tackle this one.

I have two resources, Teams and Users (the later being defined by AshAuthentication). They are related via a many_to_many named :team_members:

# Team
  policies do
    bypass actor_attribute_equals(:role, :admin) do
      authorize_if always()
    end

    policy action_type(:update) do
      authorize_if actor_attribute_equals(:role, :team_lead) && expr(exists(team_memberships, user_id == ^actor(:id)))
    end
  end
  
  relationships do
    has_many :team_memberships, WasteWalk.Teams.Members
    has_many :sprints, WasteWalk.Sprints.Sprint

    many_to_many :team_members, WasteWalk.Accounts.User do
      join_relationship :team_memberships
      source_attribute_on_join_resource :team_id
      destination_attribute_on_join_resource :user_id
    end
  end

As you can see, to update a team, you need to be a team lead for that team.

Users are just users, as defined by Ash. I’ve added a many_to_many relationship to the User resource:

# User
  relationships do
    has_many :team_memberships, WasteWalk.Teams.Members

    many_to_many :teams, WasteWalk.Teams.Team do
      join_relationship :team_memberships
      source_attribute_on_join_resource :user_id
      destination_attribute_on_join_resource :team_id
    end
  end

Keeping in mind that only someone with a :team_leads role can actually modify their own Team, I want to extend that to team membership. In other words, only a :team_lead can modify (create, destroy) the members of a team.

As it is now (see below) a team lead could modify another team’s membership. (The code API doesn’t really support it, but there’s nothing keeping someone from updating the Members resource directly).

So I’m struggling with the DSL to prevent team leads from modifying relationships unless they are a :team_lead (role) for the specific :team_id they are updating. I’ve no idea how to do that with the policy DSL… (see comment, below – vague idea, but lacking practical knowledge).

# Members
  policies do
    bypass actor_attribute_equals(:role, :admin) do
      authorize_if always()
    end

    policy action_type(:create) do
      authorize_if actor_attribute_equals(:role, :admin)

      # TODO restrict to only :team_lead's own teams: 
      # e.g. kind of like... && expr(exists(team_memberships, user_id == ^actor(:id)))
      authorize_if actor_attribute_equals(:role, :team_lead) # &&...??
    end

    policy action_type(:read) do
      authorize_if always()
    end
  end

  actions do
    defaults [:read]

    create :create do
      primary? true
      accept [:user_id, :team_id]
    end
  end

  relationships do
    belongs_to :user, WasteWalk.Accounts.User, primary_key?: true, allow_nil?: false
    belongs_to :team, WasteWalk.Teams.Team, primary_key?: true, allow_nil?: false

    actions do
      defaults [:read, :destroy, update: :*]
    end
  end

Just one note on terminology: policies and AshAuthentication are unrelated to each other. Authentication is “who is the user”, and Authorization is “what can the user do”. Authorization is built in to Ash core, but Authentication is done with something external to core (most often AshAuthentication).

One thing to note is to make sure that you’ve read through the policies guide. It can be hard to wrap your head around, but once it clicks you can do a lot of really cool things with them. And most people say they had to read it two or three times :sweat_smile:

Based on what you’re saying, I actually think there are two problems here.

Data model

If your data model is such that an actor has a role, i.e :team_lead as an attribute, and then relates to multiple teams, how could you have an actor that is a lead of one team but a member of another? Typically the role would exist in the team memberships. Then you could do something like this:

authorize_if exists(team_memberships, user_id == ^actor(:id) and role == :team_lead)

Policy flow

policy checks operate like a “workflow” where you step through each step in order. There are more options than just authorize_if. So if you are keeping your existing data model, then you are likely looking for something like this:

  policy action_type(:create) do
    authorize_if actor_attribute_equals(:role, :admin)

    forbid_if expr(not exists(team_memberships, user_id == ^actor(:id)))
    authorize_if actor_attribute_equals(:role, :team_lead)
  end

Good points of clarification – thanks for that. I think that was a typo, I should have typed AshAuthorization (not AshAuthentication). (I’ll update that for posterity).

I found the policies guide lacked some specifics, but the corresponding chapter in your book was helpful in clearing up some of the confusion. I do wish there was a more in-depth exploration of how to manipulate many-to-many relationships.

If your data model is such that an actor has a role, i.e :team_lead as an attribute, and then relates to multiple teams, how could you have an actor that is a lead of one team but a member of another? Typically the role would exist in the team memberships. Then you could do something like this:

Agreed, but in this business case it’s not a concern. I actually had a :role on the Members resource, but we removed it as unnecessary given the simple business case.

policy checks operate like a “workflow” where you step through each step in order. There are more options than just authorize_if. So if you are keeping your existing data model, then you are likely looking for something like this:

  policy action_type(:create) do
    authorize_if actor_attribute_equals(:role, :admin)

    forbid_if expr(not exists(team_memberships, user_id == ^actor(:id)))
    authorize_if actor_attribute_equals(:role, :team_lead)
  end

That is actually what I’m doing in the latest Team and it’s perfect for the Team API. But in this case I’m concerned about direct manipulation of the Member (the relationship)?

But it seems that I should prevent directly updating the relationship, e.g.,

  WasteWalk.Teams.Members |>
  Ash.Changeset.for_create(:create, %{user_id: u, team_id: t}) |>
  Ash.update!(actor: not_a_team_lead)

The only user that should be allowed to do that is a team lead. So, I’m trying to prevent updates on the relationship itself (here, Member). Keep in mind that’s the joining resource, not the Team itself. The join resource only has these relationships available:

# Member
  relationships do
    belongs_to :user, WasteWalk.Accounts.User, primary_key?: true, allow_nil?: false
    belongs_to :team, WasteWalk.Teams.Team, primary_key?: true, allow_nil?: false

    actions do
      defaults [:read, :destroy, update: :*]
    end

Is there a way the DSL does something like this, or is this totally custom? (From a DSL perspective, something kind of like this psuedocode):

# Member (the join between Team and User)
  policy action_type(:create) do
    authorize_if expr(
      exists(:team_id == ^team_id && :user_id == ^actor(:id))
    ) && actor_attribute_equals(:role, :team_lead)
  end

Otherwise, Members (the joining resource) is somewhat loosely secured. (Maybe it’s unnecessary? It’s not exposed over an API… I could be taking this too far, but it seems like the business rule should be validated on the joining resource).

I’d love to see more complex examples added to the docs / your book… most of the examples are a good start, but they seem to come up short of some real-world concerns, e.g., visibility / security scenarios. For example, being able to follow an artist is nifty, but in a business setting it important to model all the constraints… like, authorize a trade on certain accounts, viewing balances on others, no visibility if it’s a non-client account, plus security to make sure nobody could manipulate the system…

Yes, you can write:

  policy action_type(:create) do
    authorize_if expr(
      exists(:team_id == ^team_id && :user_id == ^actor(:id))
      and ^actor(:role) == :team_lead
    )
  end

But, if you actually step through the logic, the above code’s behavior is identical

to this ones:

  policy action_type(:create) do
    authorize_if actor_attribute_equals(:role, :admin)

    forbid_if expr(not exists(team_memberships, user_id == ^actor(:id)))
    authorize_if actor_attribute_equals(:role, :team_lead)
  end

Now, with all of that said, looking at your code with fresh eyes, we have a problem :cry:

You can’t use filter expressions like this on create actions. You’ll get an error if you tried. You’ll need to write a custom check.

An example can be seen here: Ash.Policy.SimpleCheck — ash v3.5.34

Custom checks may also be a nice tool if the policies flow is causing confusion, because they are “plain old Elixir” :smiley:

Thank you @zachdaniel appreciate you taking the time to understand the question and confirm. I had suspected this would need to be custom… but wasn’t sure, as I really don’t know the policy DSL very well yet.