Requirement: This guide expects that you have gone through the introductory guides and got a Phoenix application up and running.
A scope is a data structure used to keep information about the current request or session, such as the current user logged in, the organization/company it belongs to, permissions, and so on. Think about it as a container that holds information that is required in the huge majority of pages in your application. It can also hold important request metadata, such as IP addresses.
Scopes also play a very important role in security. OWASP (Open Worldwide Application Security Project) lists "Broken access control" as the biggest security risk in web applications. That's because most data in an application is not publicly available. Instead, it most often belongs to a user, a team, or an organization. Therefore, it is extremely important that, when you query the database, your queries, inserts, updates, and deletes are properly scoped to the current user/team/organization.
By using scopes, you have a single data structure that contains all relevant information, which is then passed around so all of your operations are properly scoped. By defining your own scopes, Phoenix generators such as mix phx.gen.html
, mix phx.gen.json
, and mix phx.gen.live
will automatically make sure all operations pertain to that scope, ensuring that all generated code is safe by default.
Scopes are also flexible: you can have more than one scope in your application and choose the relevant scope when invoking the relevant generator. When you run mix phx.gen.auth
, it will automatically generate a scope for you, but you may also add your own.
This guide will:
- Show how
mix phx.gen.auth
generates a scope for you - Discuss how generators, such as
mix phx.gen.context
, rely on scopes for security - How to define your own scope from scratch and all valid options
- Augment the built-in scope with additional scopes
When you invoke mix phx.gen.auth
, it will generate a default scope for you. This scope ties the generated resources to the currently authenticated user. Let's see it in action:
$ mix phx.gen.auth Accounts User users
The scope code is the same for the --live
and --no-live
variants of the generator.
Looking at the generated scope file lib/my_app/accounts/scope.ex
, we can see that it defines a struct with a single user
field, and a function for_user/1
that, if given a User
struct, returns a new %Scope{}
for that user.
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
defstruct user: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
The scope is automatically fetched by the fetch_current_scope_for_user
plug that is injected into the :browser
pipeline:
# route.ex
...
pipeline :browser do
...
plug :fetch_current_scope_for_user
end
# user_auth.ex
def fetch_current_scope_for_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_scope, Scope.for_user(user))
end
Similarly, for LiveViews, there is a pre-defined mount_current_scope
hook that ensures
the scope is available:
# user_auth.ex
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
user =
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
Scope.for_user(user)
end)
end
If a default scope is defined in your application's config, the generators will generate scoped resources by default. The generated LiveViews / Controllers will automatically pass the scope to the context functions. mix phx.gen.auth
automatically sets its scope as default, if there is not already a default scope defined:
# config/config.exs
config :my_app, :scopes,
user: [
default: true,
...
]
We will look at the individual options in the next section.
Now let's look at the code generated once a default scope is set:
$ mix phx.gen.live Blog Post posts title:string body:text
This creates a new Blog
context, with a Post
resource. To ensure the scope is available, for LiveViews the routes in your router.ex
must be added to a live_session
that includes at least the :mount_current_scope
hook. Most of the time, we want to also require authentication, in which case the required_authenticated_user
section is more appropriate:
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
+ live "/posts", PostLive.Index, :index
+ live "/posts/new", PostLive.Form, :new
+ live "/posts/:id", PostLive.Show, :show
+ live "/posts/:id/edit", PostLive.Form, :edit
end
post "/users/update-password", UserSessionController, :update_password
end
Now, let's look at the generated LiveView (lib/my_app_web/live/post_live/index.ex
):
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
...
@impl true
def mount(_params, _session, socket) do
Blog.subscribe_posts(socket.assigns.current_scope)
{:ok,
socket
|> assign(:page_title, "Listing Posts")
|> stream(:posts, Blog.list_posts(socket.assigns.current_scope))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(socket.assigns.current_scope, id)
{:ok, _} = Blog.delete_post(socket.assigns.current_scope, post)
{:noreply, stream_delete(socket, :posts, post)}
end
@impl true
def handle_info({type, %MyApp.Blog.Post{}}, socket)
when type in [:created, :updated, :deleted] do
{:noreply, stream(socket, :posts, Blog.list_posts(socket.assigns.current_scope), reset: true)}
end
end
Note that every function from the Blog
context that we call gets the current_scope
assign passed in as the first argument. The list_posts/1
function then uses that information to properly filter posts:
# lib/my_app/blog.ex
def list_posts(%Scope{} = scope) do
Repo.all(from post in Post, where: post.user_id == ^scope.user.id)
end
The LiveView even subscribes to scoped PubSub messages and automatically updates the rendered list whenever a new post is created or an existing post is updated or deleted, while ensuring that only messages for the current scope are processed.
The Phoenix generators use your application's config to discover the available scopes. A scope is defined by the following options:
config :my_app, :scopes,
user: [
default: true,
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: MyApp.AccountsFixtures,
test_login_helper: :register_and_log_in_user
]
In this example, the scope is called user
and it is the default
scope that is automatically used when running mix phx.gen.schema
, mix phx.gen.context
, mix phx.gen.live
, mix phx.gen.html
and mix phx.gen.json
. A scope needs a module that defines a struct, in this case MyApp.Accounts.Scope
. Those structs are used as first argument to the generated context functions, like list_posts/1
.
-
default
- a boolean that indicates if this scope is the default scope. There can only be one default scope defined. -
module
- the module that defines the struct for this scope. -
assign_key
- the key where the scope struct is assigned to the socket or conn. -
access_path
- a list of keys that define the path to the identifying field in the scope struct. The generators generate code likewhere: schema_key == ^scope.user.id
. -
route_prefix
- (optional) a path template string for how resources should be nested. For example,/orgs/:org
would generate routes like/orgs/:org/posts
. The parameter segment (:org
) will be replaced with the appropriate scope access value in templates and LiveViews. -
route_access_path
- (optional) list of keys that define the path to the field used in route generation (ifroute_prefix
is set). This is particularly useful for user-friendly URLs where you might want to use a slug instead of an ID. If not specified, it defaults toEnum.drop(scope.access_path, -1)
oraccess_path
if the former is empty. For example, if theaccess_path
is[:organization, :id]
, it defaults to[:organization]
, assuming that the value atscope.organization
implements thePhoenix.Param
protocol. -
schema_key
- the foreign key that ties the resource to the scope. New scoped schemas are created with a foreign key field namedschema_key
of typeschema_type
to theschema_table
table. -
schema_type
- the type of the foreign key field in the schema. Typically:id
or:binary_id
. -
schema_migration_type
(optional) - the type of the foreign key column in the database. Used for the generated migration. It defaults to the default migration foreign keytype. -
schema_table
- the name of the table where the foreign key points to. -
test_data_fixture
- a module that is automatically imported into the context test file. It must have aNAME_scope_fixture/0
function that returns a unique scope struct for context tests, in this caseuser_scope_fixture/0
. -
test_login_helper
- the name of a function that is registered assetup
callback in LiveView / Controller tests. The function is expected to be imported in the test file. Usually, this is ensured by putting it into theMyAppWeb.ConnCase
module.
While the mix phx.gen.auth
automatically generates a scope, scopes can also be defined manually. This can be useful, for example, to retrofit an existing application with scopes or to define scopes that are not tied to a user.
For this example, we will implement a custom scope that gives each session its own scope. While this might not be useful in most real-world applications as created resources would be inaccessible as soon as the session ends, it is a good example to understand how scopes work. See the following section for an example on how to augment an existing scope with organizations (teams, companies, or similar).
First, let's define our scope module lib/my_app/scope.ex
:
defmodule MyApp.Scope do
defstruct id: nil
def for_id(id) do
%MyApp.Scope{id: id}
end
end
Next, we define a plug in our router that assigns a scope to each request:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :assign_scope
end
+
+ defp assign_scope(conn, _opts) do
+ if id = get_session(conn, :scope_id) do
+ assign(conn, :current_scope, MyApp.Scope.for_id(id))
+ else
+ id = System.unique_integer()
+
+ conn
+ |> put_session(:scope_id, id)
+ |> assign(:current_scope, MyApp.Scope.for_id(id))
+ end
+ end
For tests, we'll also define a fixture module test/support/fixtures/scope_fixtures.ex
:
defmodule MyApp.ScopeFixtures do
alias MyApp.Scope
def session_scope_fixture(id \\ System.unique_integer()) do
%Scope{id: id}
end
end
And then add a setup
helper to our test/support/conn_case.ex
:
defmodule MyAppWeb.ConnCase do
...
def put_scope_in_session(%{conn: conn}) do
id = System.unique_integer()
scope = MyApp.ScopeFixtures.session_scope_fixture(id)
conn =
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:scope_id, id)
%{conn: conn, scope: scope}
end
end
Finally, we configure the scope in our application's config/config.exs
:
config :my_app, :scopes,
session: [
default: true,
module: MyApp.Scope,
assign_key: :current_scope,
access_path: [:id],
schema_key: :session_id,
schema_type: :id,
schema_migration_type: :bigint,
schema_table: nil,
test_data_fixture: MyApp.ScopeFixtures,
test_login_helper: :put_scope_in_session
]
Setting schema_table
to nil
means that the generated resources don't have a foreign key to the scope, but instead a normal bigint
column that directly stores the scope's id.
We can now generate a new resource, for example with phx.gen.html
:
$ mix phx.gen.html Blog Post posts title:string
When you now visit http://localhost:4000/posts, and create a new post, you will see that it is only visible to the current session. If you open a private browser window and visit the same URL, the previously created post is not visible. Similarly, if you create a new post in the private window, it is not visible in the other window. If you try to copy the URL of a post created in one session and access it in another, you will get an Ecto.NoResultsError
error, which is automatically converted to 404 when the debug_errors
setting is disabled.
Let's assume that you used mix phx.gen.auth
to generate a scope tied to users. But now you also create a new organization
entity, where users can be members of:
defmodule MyApp.Accounts.Organization do
use Ecto.Schema
import Ecto.Changeset
@derive {Phoenix.Param, key: :slug}
schema "organizations" do
field :name, :string
field :slug, :string
...
many_to_many :users, MyApp.Accounts.User, join_through: "organizations_users"
timestamps(type: :utc_datetime)
end
end
First, we'd adjust our scope struct to also include the organization:
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
alias MyApp.Accounts.Organization
- defstruct user: nil
+ defstruct user: nil, organization: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
+
+ def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
+ %{scope | organization: organization}
+ end
end
Let's also assume that the current organization is part of the URL path, like http://localhost:4000/organizations/foo/posts
. Then, we'd adjust our router to fetch the organization from the path and assign it to the scope:
# router.ex
pipeline :browser do
...
plug :fetch_current_scope_for_user
+ plug :assign_org_to_scope
end
# user_auth.ex
def assign_org_to_scope(conn, _opts) do
current_scope = conn.assigns.current_scope
if slug = conn.params["org"] do
org = MyApp.Accounts.get_organization_by_slug!(current_scope, slug)
assign(conn, :current_scope, MyApp.Accounts.Scope.put_organization(current_scope, org))
else
conn
end
end
For LiveViews, we'll also need to add a new :on_mount
hook and add it to live_session
's on_mount
option in the router:
# user_auth.ex
def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do
socket =
case socket.assigns.current_scope do
%{organization: nil} = scope ->
org = MyApp.Accounts.get_organization_by_slug!(socket.assigns.current_scope, slug)
Phoenix.Component.assign(socket, :current_scope, Scope.put_organization(scope, org))
_ ->
socket
end
{:cont, socket}
end
def on_mount(:assign_org_to_scope, _params, _session, socket), do: {:cont, socket}
This way, if a route is defined like live /organizations/:org/posts
, the assign_org_to_scope
plug would fetch the organization from the path and assign it to the scope. This code assumes that get_organization_by_slug!/2
raises an
Ecto.NoResultsError
which would be automatically converted to 404
, but you could also handle the error explicitly and,
for example, set an error flash and redirect to another page, like a dashboard. The get_organization_by_slug!/2
function
should also rely on the current scope to filter the organizations to those the user has access to.
Then, we are ready to define a new scope in our application's config/config.exs
to generate resources scoped to the organization:
config :my_app, :scopes,
user: [
...
],
organization: [
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:organization, :id],
route_prefix: "/orgs/:org",
schema_key: :org_id,
schema_type: :id,
schema_table: :organizations,
test_data_fixture: MyApp.AccountsFixtures,
test_login_helper: :register_and_log_in_user_with_org
]
For the generated tests, we'll also need to define a fixture in test/support/fixtures/accounts_fixtures.ex
and extend our test/support/conn_case.ex
:
defmodule MyApp.AccountsFixtures do
...
def organization_scope_fixture(scope \\ user_scope_fixture()) do
org = organization_fixture(scope)
Scope.put_organization(scope, org)
end
end
defmodule MyAppWeb.ConnCase do
...
def register_and_log_in_user_with_org(context) do
%{conn: conn, user: user, scope: scope} = register_and_log_in_user(context)
%{conn: conn, scope: MyApp.AccountsFixtures.organization_scope_fixture(scope)}
end
end
Now that our scope configuration includes the route_prefix
and route_access_path
, we can generate resources scoped to the organization, and all paths will be automatically generated with the correct organization slug:
$ mix phx.gen.live Blog Post posts title:string body:text --scope organization
This shows that scopes are quite flexible, allowing you to keep a well-defined data structure, even when your application grows.
Most of the time, your application will have a single scope module, like in this example. But sometimes, you might want to create a new scope module, for example to completely separate a user-facing scope from an admin scope, where also the context functions are supposed to only be called by one of the two.
When working with more complex scopes, it is often useful to create some helper functions, which can conveniently be added to the scope module:
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts
alias MyApp.Accounts.{User, Organization}
defstruct user: nil, organization: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
%{scope | organization: organization}
end
def for(opts) when is_list(opts) do
cond do
opts[:user] && opts[:org] ->
user = user(opts[:user])
org = org(opts[:org])
user
|> for_user()
|> put_organization(org)
opts[:user] ->
user = user(opts[:user])
for_user(user)
opts[:org] ->
%__MODULE__{organization: org(opts[:org])}
end
end
defp user(id) when is_integer(id) do
Accounts.get_user!(id)
end
defp user(email) when is_binary(email) do
Accounts.get_user_by_email(email)
end
defp org(id) when is_integer(id) do
Accounts.get_organization!(id)
end
defp org(slug) when is_binary(slug) do
Accounts.get_organization_by_slug!(slug)
end
end
Then, you can alias the Scope module in your project's .iex.exs
:
alias MyApp.Accounts.Scope
And when working with scoped context functions, you can just do:
iex> MyApp.Blog.list_posts(Scope.for(user: 1, org: "foo"))
...
iex> MyApp.Accounts.list_api_tokens(Scope.for(user: "john@doe.com"))
...