A first look at Phoenix scopes

Phoenix 1.8 includes a new feature called scopes. They enforce a simple but extensible authorization pattern by default.

After running mix phx.gen.auth, basic authentication is generated for the app, much like in previous versions of Phoenix. But, in v1.8 an accounts/scope.ex file is also created, the app's config.exs is updated and all subsequent generators that create contexts will inject scopes into them to lock down authorization based upon the user in the current scope.

mix phx.gen.auth Accounts User users

Initial impressions auth changes

The default behavior for newly registered users is now to only request an email and to use "magic" email links for login. This has a couple of advantages—every user's email will be verified when they register, and some users prefer not to deal with passwords. Once users have signed up, they can add a password in their settings page and use it for logging in in the future. Many prefer this, myself included, now that password managers are so common, even at the OS level.

Scopes are opt-out

Scopes are opt-out! This means that after running the auth generator, the behavior of phx.gen.context, phx.gen.html, and phx.gen.live is changed. Let's look at a simple example:

mix phx.gen.live Curriculum Level levels name:string difficulty:integer

This generator still generates a schema, a migration, a context file, and liveviews as it would have previously. However, the output is different, due to scopes. Let's start with the schema:

defmodule App.Curriculum.Level do
  use Ecto.Schema
  import Ecto.Changeset

  schema "levels" do
    field :name, :string
    field :difficulty, :integer
    field :user_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(level, attrs, user_scope) do
    level
    |> cast(attrs, [:name, :difficulty])
    |> validate_required([:name, :difficulty])
    |> put_change(:user_id, user_scope.user.id)
  end
end

In addition to the name and difficulty we specified in the generator above, the schema now includes a user_id as well. The changeset function requires a user with an ID to be passed in in its user_scope parameter, as well. The migration also includes a user_id, too:

  def change do
    create table(:levels) do
      add :name, :string
      add :difficulty, :integer
      add :user_id, references(:users, type: :id, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end
  end

Changes in the context module

Scopes are now added as a parameter to every function in the newly-generated context module:

  def subscribe_levels(%Scope{} = scope) do
    key = scope.user.id

    Phoenix.PubSub.subscribe(ZhuyinKing.PubSub, "user:#{key}:levels")
  end

  defp broadcast(%Scope{} = scope, message) do
    key = scope.user.id

    Phoenix.PubSub.broadcast(ZhuyinKing.PubSub, "user:#{key}:levels", message)
  end

  def list_levels(%Scope{} = scope) do
    Repo.all(from level in Level, where: level.user_id == ^scope.user.id)
  end
  #...

This means that any calls to any of them that don't include a scope with a valid user will result in an error. Also, resources are restricted to the user who created them by default. I.e., a user can only list or get records they created.

The new Scope module

When you run mix phx.gen.auth, it creates a user_auth.ex module inside the accounts context it creates, as in previous versions. However, there's a new file, too: /accounts/scopes.ex. It's very short:

defmodule AppWeb.Accounts.Scope do
  alias AppWeb.Accounts.User

  defstruct user: nil

  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end

The functions in user_auth that previously would have put a current_user into the assigns of a conn or socket, now call Scope.for(user) and pass the result into the assigns under current_scope. The structure of a conn or socket will now be this:

%{assigns: %{current_scope: %{user: some_user}}}  # if a user is logged in
%{assigns: %{current_scope: nil}}                               # if no user is logged in

Final thoughts

Phoenix auth generators now include not only authentication but also a starting point for authorization. It's opt-out, which is a ideal in my opinion. Authorization is always highly dependent on the app one is working on, so it's good to have an escape hatch, but it's also good to put some basics in place by default. The more a framework can steer its users into a secure by default approach, where we remove restrictions as needed, as opposed to everything being open by default, with restrictions added where needed, the fewer security vulnerabilities we'll have!

In the next episode, I'll take a more hands-on look at opening up some of this generated code based on common real-world use cases.

Back to index

No Comments