Home Posts Tags Post Search Tag Search

Post 78

Ash Framework 07 - Authorization: What Can You Do?

Published on: 2025-10-09 Tags: elixir, Side Project, LiveView, Ecto, Authorization, Html/CSS, Phoenix, Ash
Chapter 6: Authorization: What Can You Do? (123)
    We have the app in a good place so that we can do everything we want but the permissions aren't all set up and the API doesn't have any Authorization. We want to also make act different when you are logged in and when you are not logged in. 
    
    Introducing Policies
        This is where we set the policies. Ash will check internally for all the policies before any action takes place if one fails then it will not happen.

        We write them once and then they will be done everywhere. At it's core we need to understand who is running the actions so we need to define the actors well. The policy is made up of 2 things:
            One or more policy conditions, helps to determine whether or not the policy applies to a situation.
            A set of policy checks, that will pass or fail. There is a check condition and then an action if it passes. 

        Lets say we were building a blog site a check might look like. 
            defmodule Blog.Post do
                use Ash.Resource, authorizers: [Ash.Policy.Authorizer]
                    policies do
                        policy action(:publish) do
                            forbid_if expr(published == true)
                            authorize_if actor_attribute_equals(:role, :admin)
                        end
                    end
                end
            # This will check during an publish action and not do it is already published or if they are not an admin.

        Decisions, Decisions
            For a policy we need to think of it as a cond statement. The check will be evaluated in order and if one succeeds then it will run, order matters here. If nothing makes a decision then it will fail and nothing will happen. 
        
    Authorizing API Access for Authentication
        Let's start to use this in our app. There are some basic setting that are set already head to lib/tunez/accounts/user.ex
            policies do
                bypass AshAuthentication.Checks.AshAuthenticationInteraction do
                    authorize_if(always())
                end

                policy always() do
                    forbid_if(always())
                end

        Looking at this we see that bypass that we will get into later but the AshAuthentication.Checks.AshAuthenticationInteraction is any action from the liveview. The second policy always applies and will fail if... always. This is any other action that is not through liveview again going back to the order of operations. We need to update that so that we can use the API

        Writing Our First User Policy
            Let's first look at what happens without the authorization to get a better look at what is happening under the hood.
                iex -S mix
                iex(1)> Tunez.Accounts.User
                Tunez.Accounts.User
                |> Ash.Changeset.for_create(:register_with_password, %{email: "Test@test.com", password: "password", password_confirmation: "password"})
                #Ash.Changeset<
                domain: Tunez.Accounts,
                ...>
                iex(3)> |> Ash.create()
                {:error,
                %Ash.Error.Forbidden{
                bread_crumbs: ["Error returned from: Tunez.Accounts.User.register_with_password"], 
                changeset: "#Changeset<>", 
                errors: [
                    %Ash.Error.Forbidden.Policy{...}
                ]}}

            Let's set some changes to make it so we have some functionality without liveview. Same file but now we change this.
                policies do
                    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
                        authorize_if(always())
                    end

                    policy action([:register_with_password, :sign_in_with_password]) do
                        authorize_if(always())
                    end
                end
                # You will not be able to do those action without it failing. Check for yourself. Just make sure to recompile.

        Authentication via JSON
            Just a quick bit of information I was able to download and install the Postman app [https://www.postman.com/downloads/] in order to send and receive data through the api. I used a general body to send the info but I needed a proper header which was just what it will send and accept ad a return.
                Content-Type: application/vnd.api+json
                Accept: application/vnd.api+json

            We tested out a token for sign-in last time and for registration it should be very similar, we need to also be sure the send the token to the user so they can store it and use it later. This is handled for us by AshAuthenticationPhoenix, we can then attach the metadata to the API response, head to lib/tunez/accounts.ex and add these lines.
                json_api do
                    routes do
                        post :register_with_password do
                            route("/register")

                            metadata(fn _subject, user, _request ->
                            %{token: user.__metadata__.token}
                            end)
                        end
                    end
                end

            We can then do the same for sign_in_with_password
                post :sign_in_with_password do
                    route("/sign_in")
                    metadata(fn _subject, user, _request -> %{token: user.__metadata__.token} end)
                end

        Authenticating via GraphQL
            This should be easier now that we have added in the policies for the access points. Start off the a linux command line, this will configure the GraphQL schema, domain module, and resource.
                mix ash.extend Tunez.Accounts.User graphql

            AshGraphql is strict in regards to which type of actions can do which. Mutations and queries are the 2 forms that we will work with Reads will be queries and updates, creates, etc will be mutations. Lets create the mutation for register_with_password, lib/tunez/accounts.ex
                graphql do
                    mutations do
                     create(Tunez.Accounts.User, :register_user, :register_with_password)
                    end
                end

            Now just do similar for the sign_in_with_password but use the queries using the get macro
                queries do
                    get(Tunez.Accounts.User, :sign_in_user, :sign_in_with_password)
                end

            We will get an error as they way that GraphQL works is that it has more fields for the return data and as such it doesn't know what to do with the token we can now add in a line to describe what to do with the Token
                get(Tunez.Accounts.User, :sign_in_user, :sign_in_with_password) do
                    type_name :user_with_token
                end

    Assigning Roles to Users
        For this app we don't need super robust roles just we need roles that will take care of the following:
            Basic no modification
            Editors create/update a limited set
            Admins any action across the app

        This role will be stored in the Tunez.Accounts.User and will be named role. So lets head to lib/tunez/accounts/user.ex
            attributes do
                # ...
                
                attribute :role, :atom do
                    allow_nil? false
                    default :user
                    constraints [one_of: [:user, :editor, :admin]]
                end
            end

        But there is a better way and we can just make a role.ex module and then reference that in the user.ex module.
            lib/tunez/account/role.ex
            defmodule Tunez.Accounts.Role do
                use Ash.Type.Enum, values: [:admin, :editor, :user]
            end
        
            lib/tunez/account/user.ex
                attribute :role, Tunez.Accounts.Role do
                    allow_nil? false
                    default :user
                end

        Now we just need to codegen and then migrate
            mix ash.codegen add_role_to_user
            mix ash.migrate

        Okay so now that we have the new migration and db we need a way to set and update the role for a user as everyone will be a user to start and no one will ever be an admin or editor. We can simply set a new action update :set_role that will accept role. lib/tunez/accounts/user.ex
            actions do
                defaults [:read]

                update :set_role do
                    accept [:role]
                end

                # ...
            end

        Now that we have that set we need to update the domain to be able to use the new action for the resource. lib/tunez/accounts.ex
            resources do
                # ...
                resource(Tunez.Accounts.User) do
                    define :set_user_role, action: :set_role, args: [:role]
                    define :get_user_by_email, action: :get_by_email, args: [:email]
                end
            end

        As we should only be running these from the console we don't need the authorize? false, once this is set and you have an account make it an admin like so
            iex -S mix
            iex(12)> user = Tunez.Accounts.get_user_by_email!(<email>, authorize?: false)
            %Tunez.Accounts.User{
            id: "9cb1d8b7-9231-4d16-930e-8e87a6db97ce",
            email: #Ash.CiString<<email>>,
            confirmed_at: ~U[2025-10-07 22:06:04.300165Z],
            role: :user,
            __meta__: #Ecto.Schema.Metadata<:loaded, "users">
            }
            iex(13)> Tunez.Accounts.set_user_role(user, :admin, authorize?: false)
            {:ok,
            %Tunez.Accounts.User{
            id: "9cb1d8b7-9231-4d16-930e-8e87a6db97ce",
            email: #Ash.CiString<<email>>,
            confirmed_at: ~U[2025-10-07 22:06:04.300165Z],
            role: :admin,
            __meta__: #Ecto.Schema.Metadata<:loaded, "users">
            }}
            
    Writing Policies for Artists
        Now we need to be sure that we are able to use these roles in an Ash Web app, there is 2 things that need to be done:
            Creating policies for our Resource
            Updating the interface to specify the actor, as well as hiding some UI for the right actors.

        Creating Our First Artist Policy
            Okay so let's head to lib/tunez/music/artist.ex and set up the authorizer for the module. 
                defmodule Tunez.Music.Artist do
                    use Ash.Resource,
                        otp_app: :tunez,
                        domain: Tunez.Music,
                        data_layer: AshPostgres.DataLayer,
                        extensions: [AshGraphql.Resource, AshJsonApi.Resource],
                        authorizers: [Ash.Policy.Authorizer]

            "Testing a create Action"
                We will get an error because we have nothing for that action (policy) and if there is no policy it will fail
                    iex -S mix
                    iex(15)> Tunez.Music.create_artist(%{name: "New Artist"})
                    {:error,
                    %Ash.Error.Forbidden{...}}

                We need to head to the artist resource and add in the policy lib/tunez/music/artist.ex, this is again a policy that will always work but we will change that later.
                    policies do
                        policy action(:create) do
                            authorize_if always()
                        end
                    end

                Lets add in the policy to make it only work for an admin.
                    policies do
                        policy action(:create) do
                         authorize_if actor_attribute_equals(:role, :admin)
                        end
                    end

                Now we can check to see how the iex  session works.
                    iex(2)> Tunez.Music.create_artist(%{name: "New Artist"})
                    {:error, %Ash.Error.Forbidden{...}}
                    iex(3)> Tunez.Music.create_artist(%{name: "New Artist"}, actor: nil)
                    {:error, %Ash.Error.Forbidden{...}}
                    iex(4)> editor = %Tunez.Accounts.User{role: :editor}
                    #Tunez.Accounts.User<role: :editor, ...>
                    iex(5)> Tunez.Music.create_artist(%{name: "New Artist"}, actor: editor)
                    {:error, %Ash.Error.Forbidden{...}}
                    iex(6)> admin = %Tunez.Accounts.User{role: :admin}
                    #Tunez.Accounts.User<role: :admin, ...>
                    iex(7)> Tunez.Music.create_artist(%{name: "New Artist"}, actor: admin)
                    {:ok, #Tunez.Music.Artist<...>}
            
        Filling Out Update and Destroy Policies
            Now let's figure out who can update and destroy anything. lib/tunez/music/artist.ex
                policies
                    policy action(:update) do
                        authorize_if actor_attribute_equals(:role, :admin)
                        authorize_if actor_attribute_equals(:role, :editor)
                    end
                        policy action(:destroy) do
                        authorize_if actor_attribute_equals(:role, :admin)
                    end
                
            "Cutting Out Repetitiveness with Bypasses"
                There is a way to avoid having to make a policy for every admin as we can set a bypass to make all actions work for that role. 
                    bypass actor_attribute_equals(:role, :admin) do
                        authorize_if always()
                    end
                
                With this set we can get rid of any polices that calls admin, and remember that if you don't have a policy for a role it will not work!!! Here are 2 things to keep in mind:
                    Keep all bypass policies together at the start of the policies block, and don’t intermingle them with standard policies.
                    • Write naive tests for your policies that test as many combinations of permissions as possible to verify that the behavior is what you expect. 
            
            "Debugging when Policies Fail"
                There is yet and other more robust debug that you can set to true in order to see more information about any failures. In config/deb.exs there is this line.
                    config :ash, policies: [show_policy_breakdowns?: true]

                Trying to do the same thing as create without the right permissions will result in an error
                    iex(1)> editor = %Tunez.Accounts.User{role: :editor}
                    #Tunez.Accounts.User<...>
                    iex(2)> Tunez.Music.create_artist!(%{name: "Oh no!"}, actor: editor)
                    ** (Ash.Error.Forbidden)
                    Bread Crumbs:
                    > Error returned from: Tunez.Music.Artist.create
                    Forbidden Error
                    * forbidden:
                    Tunez.Music.Artist.create
                    Policy Breakdown
                    user: %{id: nil}
                    Policy | [M]:
                    condition: action == :create
                    authorize if: actor.role == :admin | ✘ | [M]
                    SAT Solver statement:
                    "action == :create" and
                    (("action == :create" and "actor.role == :admin")
                    or not "action == :create")
                    Removing Forbidden Actions from the UI

        Filtering Results in read Action Policies
            Reads are a bit different as they are about filtering and giver entries that can satisfy a yes/no about a column. We won't modify too much here but the issues is that we might want to make it so an admin will see all records and a a normal user might not be able to see those. Either way we need to have a blanket policy for reads as again not policy no go. lib/tunez/music/artist.ex
                policies do
                    # ...
                    policy action_type(:read) do
                        authorize_if(always())
                    end
                end

    Removing Forbidden Actions from the UI
        Right now it will show all buttons no matter what the user role is there is a few things that we need to do in order to make sure that we only show the right information for each user:
            Update the actions to pass the current user
            Update forms to ensure that we only let the actor see the forms if they can submit them
            Update our templates to show show buttons if they have the right perms.

        Identifying the Actor When Calling Actions
            This could be the hardest part of the app in a larger scale version in our case the only action that are called directly are read and destroy. Tunez.Music.search_artists/2 in Tunez.Artists.IndexLive. We don't need to pass the actor as for NOW we allow all roles to search but that might change in the future. So let's get the information. lib/tunez_web/live/artists/index_live.ex
                 def handle_params(params, _url, socket) do
                    sort_by = Map.get(params, "sort_by") |> validate_sort_by()
                    query_text = Map.get(params, "q", "")
                    page_params = AshPhoenix.LiveView.page_from_params(params, 12)

                    page =
                        Tunez.Music.search_artists!(query_text,
                            query: [sort_input: sort_by],
                            page: page_params,
                            actor: socket.assigns.current_user
                        )
                
            Tunez.Music.get_artist_by_id/2 in Tunez.Artist.ShowLive, same as above let's add in the actor so we can use it later. lib/tunez_web/live/artists/show_live.ex
                artist =
                    Tunez.Music.get_artist_by_id!(artist_id,
                        load: [:albums],
                        actor: socket.assigns.current_user
                    )

            Tunez.Music.get_artist_by_id/2, in Tunez.Artists.FormLive. Same as before! lib/tunez_web/live/artists/form_live.ex
                artist =
                    Tunez.Music.get_artist_by_id!(artist_id,
                        load: [:albums],
                        actor: socket.assigns.current_user
                    )

            Tunez.Music.destroy_artist/2, in Tunez.Artists.ShowLive. We need to pass the actor in here to make it work, as only specific types of users can delete artists. lib/tunez_web/live/artists/show_live.ex
                def handle_event("destroy-artist", _params, socket) do
                    case Tunez.Music.destroy_artist(
                        socket.assigns.artist,
                        actor: socket.assigns.current_user
                    ) do

            Tunez.Music.destroy_album/2, in Tunez.Artists.ShowLive. We haven’t added policies for albums yet, but it doesn’t hurt to start updating our templates to support them.
                def handle_event("destroy-album", %{"id" => album_id}, socket) do
                    case Tunez.Music.destroy_album(
                        album_id,
                        actor: socket.assigns.current_user
                    ) do

            Tunez.Music.get_album_by_id/2, in Tunez.Albums.FormLive. Same as before. lib/tunez_web/live/albums/form_live.ex
                def mount(%{"id" => album_id}, _session, socket) do
                    album = Tunez.Music.get_album_by_id!(album_id,
                        load: [:artist],
                        actor: socket.assigns.current_user
                    )

            Tunez.Music.get_artist_by_id/2, in Tunez.Albums.FormLive. Same as before! Same file.
                def mount(%{"artist_id" => artist_id}, _session, socket) do
                artist = Tunez.Music.get_artist_by_id!(artist_id,
                    actor: socket.assigns.current_user
                )

        Updating Form to identify Actor
            This is where we utilize more Ash in order to only display the right things if we have the right roles. There is the AshPhoenix.Form.ensure_can_submit!(). lib/tunez_web/live/artists/form_live.ex
                def mount(%{"id" => artist_id}, _session, socket) do
                    # ...

                    form =
                    Tunez.Music.form_to_update_artist(
                        artist,
                        actor: socket.assigns.current_user
                    )
                    |> AshPhoenix.Form.ensure_can_submit!()

                def mount(_params, _session, socket) do
                    form = Tunez.Music.form_to_create_artist(
                        actor: socket.assigns.current_user
                    )
                    |> AshPhoenix.Form.ensure_can_submit!()

                Now we can make the same changes to the album as well. lib/tunez_web/live/albums/form_live.ex
                    def mount(%{"id" => album_id}, _session, socket) do
                        # ...
                        form =
                        Tunez.Music.form_to_update_album(
                            album,
                            actor: socket.assigns.current_user
                        )
                        |> AshPhoenix.Form.ensure_can_submit!()

                    def mount(%{"artist_id" => artist_id}, _session, socket) do
                        # ...
                        form = Tunez.Music.form_to_create_album(
                            artist_id,
                            actor: socket.assigns.current_user
                        )
                        |> AshPhoenix.Form.ensure_can_submit!()

        Blocking Pages from Unauthorized Access
            When we installed AshAuthenticationPhoenix there was some built-in features that we can use to be sure that we have a logged in user, during the on mount function. The live_user_optional function head will will not care if there is a user or not, live_no_user will redirect away if there is a user logged in, lastly live_user_required will require a user logged in. This below is a general syntax to use it.
                defmodule Tunez.Accounts.ForAuthenticatedUsersOnly do
                    use TunezWeb, :live_view
                        # or :live_user_optional, or :live_no_user
                        on_mount {TunezWeb.LiveUserAuth, :live_user_required}
                        # ...

            Let's use it in our app. lib/tunez_web/live_user_auth.ex this is where we can set some functionality to what happens on mount, lib/tunez_web/live_user_auth.ex
                def on_mount([role_required: role_required], _, _, socket) do
                    current_user = socket.assigns[:current_user]

                    if current_user && current_user.role == role_required do
                    {:cont, socket}
                    else
                    socket =
                        socket
                        |> Phoenix.LiveView.put_flash(:error, "Unauthorized!")
                        |> Phoenix.LiveView.redirect(to: ~p"/")

                    {:halt, socket}
                    end
                end

            This will allow us to leverage on on_mount for a given page. Like so...
                defmodule Tunez.Accounts.ForAdminsOnly do
                    use TunezWeb, :live_view
                    on_mount {TunezWeb.LiveUserAuth, role_required: :admin}
                    # ...

            Hiding Calls to Action That the Actor Can't Perform
                There are many different buttons all throughout the app that a user can and can't use depending on the role the user has. There must be a way to check to see if a user can do an action, that is where Ash.can? comes in.

                "Ash.can?"
                    Ash.can?16 is a pretty low-level function. It takes a tuple representing the action to call and an actor, runs the authorization checks for the action, and returns a boolean representing whether or not the action is authorized:
                        iex(1)> Ash.can?({Tunez.Music.Artist, :create}, nil)
                        false
                        iex(2)> Ash.can?({Tunez.Music.Artist, :create}, %{role: :admin})
                        true
                        iex(3) artist = Tunez.Music.get_artist_by_id!(«uuid»)
                        #Tunez.Music.Artist<id: «uuid», ...>
                        iex(4)> Ash.can?({artist, :update}, %{role: :user})
                        false
                        iex(5)> Ash.can?({artist, :update}, %{role: :editor})
                        true

                can_*? Code Interface Functions
                    We call these can_*? functions because the names are dynamically generated based on the name of the code interface. For our Tunez.Music domain, for example, iex shows a whole set of functions with the can_ prefix:
                        iex(1)> Tunez.Music.can_
                        can_create_album/1 can_create_album/2 can_create_album/3
                        can_create_album?/1 can_create_album?/2 can_create_album?/3
                        can_create_artist/1 can_create_artist/2 can_create_artist/3
                        ...

                    Now that we have these tools we can add checks for all the buttons on the app. There will be a few that need to be done so let's get started.

                    lib/tunez_web/live/artists/index_live.ex
                        <:action :if={Tunez.Music.can_create_artist?(@current_user)}>
                            <.button_link navigate={~p"/artists/new"} kind="primary">
                                New Artist
                            </.button_link>
                        </:action>

                    lib/tunez_web/live/artists/show_live.ex
                        <:action :if={Tunez.Music.can_destroy_artist?(@current_user, @artist)}>
                            <.button_link
                                kind="error"
                                inverse
                                data-confirm={"Are you sure you want to delete #{@artist.name}?"}
                                phx-click="destroy-artist"
                            >
                                Delete Artist
                            </.button_link>
                        </:action>
                        <:action :if={Tunez.Music.can_update_artist?(@current_user, @artist)}>
                            <.button_link navigate={~p"/artists/#{@artist.id}/edit"} kind="primary" inverse>
                                Edit Artist
                            </.button_link>
                        </:action>

                    lib/tunez_web/live/artists/show_live.ex
                        # This is in render 
                        <.button_link navigate={~p"/artists/#{@artist.id}/albums/new"} kind="primary"
                            :if={Tunez.Music.can_create_album?(@current_user, @artist)}>
                            New Album
                        </.button_link>

                        # This is in the album details section.
                        <:action :if={Tunez.Music.can_destroy_album?(@current_user, @album)}>
                            <.button_link
                            size="sm"
                            inverse
                            kind="error"
                            data-confirm={"Are you sure you want to delete #{@album.name}?"}
                            phx-click="destroy-album"
                            phx-value-id={@album.id}
                            >
                            Delete
                            </.button_link>
                        </:action>
                        <:action :if={Tunez.Music.can_update_album?(@current_user, @album)}>
                            <.button_link size="sm" kind="primary" inverse navigate={~p"/albums/#{@album.id}/edit"}>
                            Edit
                            </.button_link>
                        </:action>

                        # This is in render
                        <ul class="mt-10 space-y-6 md:space-y-10">
                            <li :for={album <- @artist.albums}>
                                <.album_details album={album} current_user={@current_user} />
                            </li>
                        </ul>

    Writing Policies for Albums
        Now we just want to be able to change some policies for the artists is as such:
            Everyone can read all artist data
            Editors can update (but not delete) artists.
            Admins can perform all actions

        Now we want to deal with albums we need to worry about editors being able to create albums and then edit/delete only those that they created.

        Recording Who Created and Last Modified a Resource
            We now need to create a relationship for the User in the album.ex so that we can keep track of who created and who last updated a record. lib/tunez/music/album.ex then do the same thing to Tunez.Music.Artists
                relationships do
                    # ...
                    belongs_to :created_by, Tunez.Accounts.User
                    belongs_to :updated_by, Tunez.Accounts.User
                end

            As always after a change to the DB we need to codegen and then migrate
                mix ash.codegen add_user_links_to_artists_and_albums
                mix ash.migrate

            Okay we have our new relationships now we can set some changes so that you can only do some actions if you are the created or and have the right perms. lib/tunez/music/album.ex
                defmodule Tunez.Music.Album
                    # ...
                    
                    changes do
                        change relate_actor(:created_by, allow_nil?: true), on: [:create]
                        change relate_actor(:updated_by, allow_nil?: true)
                    end
                end
                # Quick note about allow nil here there will be times like scripts that we want to have the created by etc not set as they will not have an owner.

            Lastly we need to unlock the actor in order to created the album etc as right not we don't have access to the actor. We need to head to lib/tunez/accounts/user.ex. This will allow a user to read their own record. 
                policies do
                    # ...
                    
                    policy action(:read) do
                        authorize_if expr(id == ^actor(:id))
                    end
                end

            Here is some testing results
                iex(1)> user = Tunez.Accounts.get_user_by_email!(«email», authorize?: false)
                #Tunez.Accounts.User<id: «uuid», email: «email», role: :admin, ...>
                iex(2)> Tunez.Music.create_artist(%{name: "Who Made Me?"}, actor: user)
                {:ok,
                #Tunez.Music.Artist<
                name: "Who Made Me?",
                updated_by_id: «uuid»,
                created_by_id: «uuid»,
                updated_by: #Tunez.Accounts.User<id: «uuid», ...>,
                created_by: #Tunez.Accounts.User<id: «uuid», ...>,
                ...
                >}

        Filling Out Policies
            Now that we have all that done we need to make the policies for the albums. lib/tunez/music/album.ex
                defmodule Tunez.Music.Album do
                    use Ash.Resource,
                        otp_app: :tunez,
                        domain: Tunez.Music,
                        data_layer: AshPostgres.DataLayer,
                        extensions: [AshGraphql.Resource, AshJsonApi.Resource],
                        authorizers: [Ash.Policy.Authorizer]

            Now we can set the polices
                policies do
                    bypass actor_attribute_equals(:role, :admin) do
                        authorize_if(always())
                    end

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

                    policy action(:create) do
                        authorize_if(actor_attribute_equals(:role, :editor))
                    end

                    policy action(:update) do
                        authorize_if(relates_to_actor_via(:created_by))
                    end

                    policy action_type([:update, :destroy]) do
                        authorize_if(expr(^actor(:role) == :editor and created_by_id == ^actor(:id)))
                    end
                end