Current section

Files

Jump to
ex_janus priv templates policy.ex.eex
Raw

priv/templates/policy.ex.eex

defmodule <%= module %> do
@moduledoc """
Authorization policy and helpers for <%= app %> resources.
This module exposes a set of general-purpose authorization helpers as
well as `Ecto.Repo` wrappers that can be used to enforce authorization
when data is loaded, inserted, updated, etc.
## Policy structs
All of the functions in this module can accept either a policy struct
or a user, in which case a policy struct will be created for that user
under the hood. If it is desired to cache the policy struct to re-use
it for multiple calls, for instance in a `Plug.Conn` assign,
`build_policy/1` can be used:
iex> <%= module %>.build_policy(user)
%Janus.Policy{}
In the examples that follow, a policy can take the place of a user
anywhere one is passed as an argument.
## Authorization helpers
The following functions can be used to authorize actions on resources:
* `authorize/4` - authorize a specific action on an already-loaded
resource
* `any_authorized?/3` - check whether the user has any access to
resources in the given query or schema
* `scope/4` - produce an `Ecto.Query` that filters results to those
authorized for the given action/user
See `Janus.Authorization` for more documentation on the above.
## Authorized Repo operations
This module additionally defines various `authorized_*` functions that
provide authorizing alternatives to `Repo` functions. They include:
* `authorized_fetch_by/3`
* `authorized_fetch_all/2`
* `authorized_insert/2`
* `authorized_update/2`
* `authorized_delete/2`
For instance, `Chronos.Policy.authorized_update/2` can be used in
place of `Repo.update/2` to ensure that the given user is authorized
to make that update.
All of these functions accept accept an `:authorize` option containing
an `{action, user}` or `{action, policy}` tuplet. For example:
<%= module %>.authorized_fetch_by(Post, [id: id],
authorize: {:read, current_user}
)
To skip authorization checks, `authorize: false` can be passed in. For
example, the following would never return `{:error, :not_authorized}`:
<%= module %>.authorized_fetch_by(Post, [id: id],
authorize: false
)
"""
use Janus, repo: <%= app %>.Repo
alias <%= app %>.Repo
@impl true
def build_policy(policy, _user) do
# Attach permissions here using the `Janus.Policy` API
policy
end
@doc """
Fetches a single result from the query, ensuring it is authorized.
Results in:
* `{:ok, result}` if the result is found and authorized
* `{:error, :not_authorized}` if the result is found and not
authorized
* `{:error, :not_found}` if the result is not found
* Raises if more than one entry
## Options
* `:authorize` - The `{action, user}` tuplet or `false` to skip
authorization, see "Authorized Repo operations" for more
* `:load_associations` - Whether to load associations if necessary
for authorization
* Any option accepted by `c:Ecto.Repo.get_by/3`
## Examples
auth = {:read, current_user}
result = authorized_fetch_by(Post, [title: "My post"], authorize: auth)
case result do
{:ok, post} -> # authorized and found
{:error, :not_authorized} -> # found but not authorized
{:error, :not_found} -> # not found
end
# additional opts passed to `Repo`
authorized_fetch_by(Post, [title: "My post"], authorize: auth, prefix: "public")
# skip authorization check
authorized_fetch_by(Post, [title: "My post"], authorize: false)
"""
def authorized_fetch_by(queryable, clauses, opts \\ []) do
{auth, auth_opts, repo_opts} = pop_authorize_opts!(opts, [:load_associations])
resource = Repo.get_by(queryable, clauses, repo_opts)
case {resource, auth} do
{nil, _} -> {:error, :not_found}
{resource, {action, actor}} -> authorize(resource, action, actor, auth_opts)
{resource, false} -> {:ok, resource}
end
end
@doc """
Fetches all entries matching the given query that are authorized.
Results in:
* `{:ok, list_of_results}` if the query is authorized
* `{:error, :not_authorized}` if the query is not authorized
Note that `list_of_results` may be empty.
## Options
* `:authorize` - The `{action, user}` tuplet or `false` to skip
authorization, see "Authorized Repo operations" for more
* `:preload_authorized` - preload authorized entries into
associations, see `Janus.Authorization.scope/4` for more
* Any option accepted by `c:Ecto.Repo.all/2`
## Examples
auth = {:read, current_user}
result = authorized_fetch_all(Post, authorize: auth)
case result do
{:ok, posts} -> # query succeeded, authorized posts returned
{:error, :not_authorized} -> # not authorized to `:read` any Post
end
# further refine the result using a query
from(p in Post, where: p.inserted_at > ago(1, "day"))
|> authorized_fetch_all(authorize: auth)
# preload authorized associations
from(p in Post, where: p.inserted_at > ago(1, "day"))
|> authorized_fetch_all(authorize: auth, preload_authorized: :author)
# preload nested authorized associations
from(p in Post, where: p.inserted_at > ago(1, "day"))
|> authorized_fetch_all(authorize: auth, preload_authorized: [author: :profile])
# skip authorization filter
from(p in Post, where: p.inserted_at > ago(1, "day"))
|> authorized_fetch_all(authorize: false)
"""
def authorized_fetch_all(queryable, opts \\ []) do
{auth, auth_opts, repo_opts} = pop_authorize_opts!(opts, [:preload_authorized])
with {:auth, {action, actor}} <- {:auth, auth},
{:any?, true} <- {:any?, any_authorized?(queryable, action, actor)} do
{:ok, queryable |> scope(action, actor, auth_opts) |> Repo.all(repo_opts)}
else
{:auth, false} -> {:ok, Repo.all(queryable, repo_opts)}
{:any?, false} -> {:error, :not_authorized}
end
end
@doc """
Deletes a struct (or changeset) using its primary key if the operation
is authorized.
Results in:
* `{:ok, record}` if the authorized delete succeeded
* `{:error, changeset}` if the delete failed either due to an
authorization error or another validation
## Options
* `:authorize` - The `{action, user}` tuplet or `false` to skip
authorization, see "Authorized Repo operations" for more
* Any option accepted by `c:Ecto.Repo.delete/2`
## Examples
auth = {:delete, current_user}
{:ok, post} = authorized_fetch_by(Post, [title: "My post"], authorize: auth)
case authorized_delete(changeset, authorize: auth) do
{:ok, %Post{}} -> # successfully deleted
{:error, %Ecto.Changeset{}} -> # something went wrong
end
# skip authorization check
authorized_delete(changeset, authorize: false)
"""
def authorized_delete(struct_or_changeset, opts \\ [])
def authorized_delete(%Ecto.Changeset{} = changeset, opts) do
{auth, [], repo_opts} = pop_authorize_opts!(opts)
case auth do
{action, policy} ->
changeset
|> validate_authorized(action, policy,
message: "is not authorized to delete this resource"
)
|> Repo.delete(repo_opts)
false ->
Repo.delete(changeset, repo_opts)
end
end
def authorized_delete(struct, opts) do
authorized_delete(Ecto.Changeset.change(struct), opts)
end
@doc """
Updates a changeset using its primary key if the operation is
authorized.
Results in:
* `{:ok, record}` if the underlying data was successfully updated
* `{:error, changeset}` if the update failed either due to an
authorization error or another validation
## Options
* `:authorize` - The `{action, user}` tuplet or `false` to skip
authorization, see "Authorized Repo operations" for more
* Any option accepted by `c:Ecto.Repo.update/2`
## Examples
auth = {:update, current_user}
{:ok, post} = authorized_fetch_by(Post, [title: "My post"], authorize: auth)
changeset = Ecto.Changeset.change(post, title: "My new title")
case authorized_update(changeset, authorize: auth) do
{:ok, %Post{}} -> # successfully updated
{:error, %Ecto.Changeset{}} -> # something went wrong
end
# skip authorization check
authorized_update(changeset, authorize: false)
"""
def authorized_update(changeset, opts \\ []) do
{auth, [], repo_opts} = pop_authorize_opts!(opts)
case auth do
{action, policy} ->
changeset
|> validate_authorized(action, policy)
|> rollback_unless_authorized(:update, repo_opts, {action, policy})
false ->
Repo.update(changeset, repo_opts)
end
end
@doc """
Inserts a struct defined via `Ecto.Schema` or a changeset, ensuring
the resulting struct is authorized.
Results in:
* `{:ok, record}` if the data was successfully inserted
* `{:error, changeset}` if the insert failed either due to an
authorization error or another validation
## Options
* `:authorize` - The `{action, user}` tuplet or `false` to skip
authorization, see "Authorized Repo operations" for more
* Any option accepted by `c:Ecto.Repo.insert/2`
## Examples
auth = {:insert, current_user}
changeset = %Post{} |> Post.changeset(%{title: "My post"})
case authorized_insert(changeset, authorize: auth) do
{:ok, %Post{}} -> # successfully inserted
{:error, %Ecto.Changeset{}} -> # something went wrong
end
# skip authorization check
authorized_insert(changeset, authorize: false)
"""
def authorized_insert(struct_or_changeset, opts \\ [])
def authorized_insert(%Ecto.Changeset{} = changeset, opts) do
{auth, [], repo_opts} = pop_authorize_opts!(opts)
case auth do
{action, policy} ->
rollback_unless_authorized(changeset, :insert, repo_opts, {action, policy})
false ->
Repo.insert(changeset, repo_opts)
end
end
def authorized_insert(struct, opts) do
authorized_insert(Ecto.Changeset.change(struct), opts)
end
defp rollback_unless_authorized(changeset, op, opts, {action, policy}) do
Repo.transaction(fn ->
with {:ok, resource} <- apply(Repo, op, [changeset, opts]),
{:ok, resource} <- authorize(resource, action, policy) do
resource
else
{:error, %Ecto.Changeset{} = changeset} ->
Repo.rollback(changeset)
{:error, :not_authorized} ->
changeset
|> Ecto.Changeset.add_error(
:current_user,
"is not authorized to make these changes"
)
|> Repo.rollback()
end
end)
end
defp pop_authorize_opts!(opts, extra_keys \\ []) do
case Keyword.pop(opts, :authorize) do
{{action, actor}, rest} ->
{extra, rest} = Keyword.split(rest, extra_keys)
{{action, build_policy(actor)}, extra, rest}
{false, rest} ->
{_, rest} = Keyword.split(rest, extra_keys)
{false, [], rest}
{nil, _} ->
raise ArgumentError, "required option `:authorize` missing from `#{inspect(opts)}`"
end
end
@doc """
Validates that the resource being changes is authorized for the given
action/user.
## Options
* `:message` - the message in case the authorization check fails on
the resource, defaults to "is not authorized to change this resource"
* `:error_key` - the key to which the error will be added if
authorization fails, defaults to `:current_user`
## Examples
iex> %MyResource{}
...> |> MyResource.changeset(attrs)
...> |> MyPolicy.validate_authorized(:update, current_user)
%Ecto.Changeset{}
"""
def validate_authorized(%Ecto.Changeset{} = changeset, action, actor_or_policy, opts \\ []) do
policy = build_policy(actor_or_policy)
%{message: message, error_key: key} =
opts
|> Keyword.validate!(
message: "is not authorized to change this resource",
error_key: :current_user
)
|> Map.new()
case authorize(changeset.data, action, policy) do
{:ok, _} -> changeset
{:error, :not_authorized} -> Ecto.Changeset.add_error(changeset, key, message)
end
end
end