View Source Janus.Policy behaviour (Janus v0.3.2)

Define composable authorization policies for actors in your system.

A policy is a data structure created for an actor in your system that defines the schemas that actor can access, the actions they can take, and any restrictions to the set of resources that can be accessed. These policies are generally created implicitly for actors passed to functions defined by Janus.Authorization, but they can also be created with build_policy/2.

creating-a-policy-modules

Creating a policy modules

While you can create a policy module with use Janus.Policy, you will usually invoke use Janus and implement build_policy/2:

defmodule MyApp.Policy do
  use Janus

  @impl true
  def build_policy(policy, _actor) do
    policy
  end
end

An implementation for build_policy/1 is injected into the policy module.

Policy modules can now be used to generate policy structs explicitly (though they will usually be created implicitly when calling functions defined by Janus.Authorization).

iex> policy = MyApp.Policy.build_policy(:my_user)
%Janus.Policy{actor: :my_user, rules: %{...}}

iex> MyApp.SecondaryPolicy.build_policy(policy)
%Janus.Policy{actor: :my_user, rules: %{...}}

permissions-with-allow-and-deny

Permissions with allow and deny

Permissions are primarily defined using allow/4 and deny/4, which allows or denies an action on a resource if a set of conditions match. Both functions take the same arguments and options. When permissions are being checked, multiple allow rules combine using logical-or, with deny rules overriding allow.

For example, the following policy would allow a moderator to edit their own comments and any comments flagged for review, but not those made by an admin.

@impl true
def build_policy(policy, %User{role: :moderator} = user) do
  policy
  |> allow(Comment, :update, where: [user: [id: user.id]])
  |> allow(Comment, :update, where: [flagged_for_review: true])
  |> deny(Comment, :update, where: [user: [role: :admin]])
end

While set of keyword options passed to allow and deny are reminiscent of keyword-based Ecto queries, but since they are functions and not macros, there is no need to use the ^value syntax used in Ecto. For example, the following would result in an error:

allow(policy, Comment, :update, where: [user: [id: ^user.id]])

where-and-where_not-conditions

:where and :where_not conditions

These conditions match if the associated fields are equal to each other. For instance, the moderation example above could also be written as:

@impl true
def build_policy(policy, %User{role: :moderator} = user) do
  policy
  |> allow(Comment, :update, where: [user_id: user.id])
  |> allow(Comment, :update,
    where: [flagged_for_review: true],
    where_not: [user: [role: :admin]]
  )
end

Multiple conditions within the same allow/deny are combined with a logical-and, so this might be translated to English as "allow moderators to edit comments they made or to edit comments flagged for review that were not made by an admin".

or_where-conditions

:or_where conditions

You can also use :or_where to combine with all previous conditions. For instance, the two examples above could also be written as:

@impl true
def build_policy(policy, %User{role: :moderator} = user) do
  policy
  |> allow(Comment, :update,
    where: [flagged_for_review: true],
    where_not: [user: [role: :admin]],
    or_where: [user_id: user.id]
  )
end

An :or_where condition applies to all clauses before it. Using some pseudocode for demonstration, the above would read:

# (flagged_for_review AND NOT user.role == :admin) OR user_id == user.id

These clauses could be reordered to have a different meaning:

policy
|> allow(Comment, :update,
  where: [flagged_for_review: true],
  or_where: [user_id: user.id],
  where_not: [user: [role: :admin]]
)

# (flagged_for_review OR user_id == user.id) AND NOT user.role == :admin

attribute-checks-with-functions

Attribute checks with functions

When equality is not a sufficient check for an attribute, a function can be supplied.

For instance, a published_at field might be used to schedule posts. Users may only have permission to read posts where published_at is in the past, but we can only check for equality using the basic keyword syntax presented above. In these cases, you can defer this check using an arity-3 function:

@impl true
def build_policy(policy, _actor) do
  policy
  |> allow(Comment, :read, where: [published_at: &in_the_past?/3])
end

def in_the_past?(:boolean, record, :published_at) do
  if value = Map.get(record, :published_at) do
    DateTime.compare(DateTime.utc_now(), value) == :gt
  end
end

def in_the_past?(:dynamic, binding, :published_at) do
  now = DateTime.utc_now()
  Ecto.Query.dynamic(^now > as(^binding).published_at)
end

As seen in the example above, functions must define at least two clauses based on their first argument, :boolean or :dynamic, so that they can handle both operations on a single record and operations that should compose with an Ecto query.

working-with-rulesets

Working with rulesets

Policies can also be defined by attaching rulesets created using allow/3 and deny/3. Instead of taking a policy as a first argument, these functions take a schema (or a ruleset).

Rulesets are specific to an individual schema and can be attached to a policy using attach/2. For example:

@impl true
def build_policy(policy, actor) do
  policy
  |> attach(rules_for(Thread, actor))
  |> attach(rules_for(Post, actor))
end

defp rules_for(Thread, %User{id: user_id}) do
  Thread
  |> allow(:read, where: [archived: false])
  |> allow([:create, :update], where: [creator_id: user_id])
end

defp rules_for(Thread, nil) do
  Thread
  |> allow(:read, where: [archived: false, visibility: :public])
end

defp rules_for(Post, _actor) do
  Post
  |> allow(:read, where: [thread: allows(:read)])
end

Depending on your specific needs, rulesets may allow you to organize policies in a way that is easier to maintain. In the above example, delegating to a private rules_for/2 function that returns a ruleset allows us to pattern-match on a nil user where it matters and share a ruleset where it doesn't.

This pattern has tradeoffs, however. You would need to ensure that the pattern-matching for each schema is exhaustive, for instance, otherwise a FunctionClauseError might be raised.

hooks

Hooks

Functions can be registered as hooks that run prior to authorization calls. See attach_hook/4 for more information.

Link to this section Summary

Callbacks

Builds an authorization policy, delegating to build_policy/2.

Builds an authorization policy containing rules for the given actor.

Functions

Creates or updates a ruleset for a schema to allow an action if matched by conditions.

Allows an action on the schema if matched by conditions.

Specifies that a condition should match if another action is allowed.

Attach a ruleset created using allow/3 and deny/3 to a policy.

Attach a hook to the policy.

Attach a new hook to the policy.

Creates or updates a ruleset for a schema to deny an action if matched by conditions.

Denies an action on the schema if matched by conditions.

Detach a hook from the policy.

Link to this section Types

@type ruleset() :: %{
  schema: Janus.schema_module(),
  rules: %{required(Janus.action()) => Janus.Policy.Rule.t()}
}
@type t() :: %Janus.Policy{
  actor: Janus.actor(),
  config: map(),
  hooks: %{optional(Janus.schema_module() | :all) => keyword(hook())},
  rules: %{
    required({Janus.schema_module(), Janus.action()}) => Janus.Policy.Rule.t()
  }
}

Link to this section Callbacks

@callback build_policy(t() | Janus.actor()) :: t()

Builds an authorization policy, delegating to build_policy/2.

If given a policy, calls build_policy/2 with the policy and the actor associated with the policy. If given an actor, creates an empty policy associated with that actor and passes it to build_policy/2.

An implementation for this callback is injected into modules invoking either use Janus or use Janus.Policy.

@callback build_policy(t(), Janus.actor()) :: t()

Builds an authorization policy containing rules for the given actor.

See Janus.Policy for API documentation on building policies.

Link to this section Functions

Link to this function

allow(policy, schema, action)

View Source
@spec allow(t(), Janus.schema_module(), Janus.action() | [Janus.action()]) :: t()
@spec allow(
  Janus.schema_module() | ruleset(),
  Janus.action() | [Janus.action()],
  keyword()
) ::
  ruleset()

Creates or updates a ruleset for a schema to allow an action if matched by conditions.

Must be attached to a policy using attach/2.

See "Permissions with allow and deny" for a description of conditions.

examples

Examples

thread_rules =
  Thread
  |> allow(:read)
  |> allow(:create, where: [creator_id: user.id])

attach(policy, thread_rules)
Link to this function

allow(policy, schema, action, opts)

View Source
@spec allow(t(), Janus.schema_module(), Janus.action() | [Janus.action()], keyword()) ::
  t()

Allows an action on the schema if matched by conditions.

See "Permissions with allow and deny" for a description of conditions.

examples

Examples

policy
|> allow(FirstResource, :read)
|> allow(SecondResource, :create, where: [creator: [id: user.id]])

Specifies that a condition should match if another action is allowed.

If used as the value for an association, the condition will match if the action is allowed for the association.

examples

Examples

Allow users to edit any posts they can delete.

policy
|> allow(Post, :update, where: allows(:delete))
|> allow(Post, :delete, where: [user_id: user.id])

Don't allow users to edit posts they can't read.

policy
|> allow(Post, :read, where: [archived: false])
|> allow(Post, :update, where: [user_id: user.id])
|> deny(Post, :update, where_not: allows(:read))

example-with-associations

Example with associations

Let's say we have some posts with comments. Posts are visible unless they are archived, and all comments of visible posts are also visible. To start, we can duplicate the condition:

policy
|> allow(Post, :read, where: [archived: false])
|> allow(Comment, :read, where: [post: [archived: false]])

If we add additional clauses to the condition for posts, however, we will have to duplicate them for comments. We can use allows instead:

policy
|> allow(Post, :read, where: [archived: false])
|> allow(Comment, :read, where: [post: allows(:read)])

Now let's say we add a feature that allows for draft posts, which should not be visible unless a published_at is set. We can modify only the condition for Post and that change will propogate to comments.

policy
|> allow(Post, :read, where: [archived: false], where_not: [published_at: nil])
|> allow(Comment, :read, where: [post: allows(:read)])
@spec attach(policy :: t(), ruleset()) :: t()

Attach a ruleset created using allow/3 and deny/3 to a policy.

examples

Examples

thread_rules =
  Thread
  |> allow(:read)
  |> deny(:read, where: [scope: :private])

attach(policy, thread_rules)
Link to this function

attach_hook(policy, name, schema \\ :all, fun)

View Source
@spec attach_hook(t(), atom(), :all | Janus.schema_module(), hook()) :: t()

Attach a hook to the policy.

Expects the following arguments:

  • policy - the %Janus.Policy{} struct to attach to
  • name - an atom identifying the hook
  • schema (default :all) - an Ecto schema module identifying the resource or query source that the hook should be applied to
  • fun - the hook function, see "Hooks" below

If the given name is already present, an error will be raised. If you wish to replace a hook, you can use detach_hook/3 before re-attaching the hook. If you only wish to add a hook if it is hasn't already been added, use attach_new_hook/4 instead.

Hooks will be run in the order that they are attached.

hooks

Hooks

Hooks are anonymous or captured functions that accept three arguments:

  • operation - one of :authorize or :scope
  • object - either a struct (for :authorize) or a queryable (for :scope) that is being authorized
  • action - the action being authorized

Hooks must return one of the following:

  • {:cont, object} - additional hooks and authorization continue
  • :halt - halt authorization, running no additional hooks and returning {:error, :not_authorized} for an :authorize operation and an empty query for a :scope operation

examples

Examples

When writing hooks, you must ensure that all possible arguments are handled. This can be done using a "catch-all" clause. For example:

policy
|> attach_hook(:preload_user, fn
  :authorize, %Post{} = resource, _action ->
    {:cont, Repo.preload(resource, :user)}

  _operation, object, _action ->
    {:cont, object}
end)

policy
|> attach_hook(:preload_user, Post, fn
  :authorize, resource, _action ->
    {:cont, Repo.preload(resource, :user)}

  _operation, object, _action ->
    {:cont, object}
end)

Hooks can also be captured functions. For example:

policy
|> attach_hook(:preload_user, Post, &preload_user/3)

# elsewhere in your module

defp preload_user(:authorize, resource, _action) do
  {:cont, Repo.preload(resource, :user)}
end

defp preload_user(:scope, query, _action) do
  {:cont, from(query, preload: :user)}
end

If :halt is returned from a hook, no further hooks will be run and nothing will be authorized. This could be used to perform a check on banned users, for example:

@impl true
def build_policy(policy, user) do
  policy
  |> attach_hook(:ensure_unbanned, fn _op, object, _action ->
    if Accounts.banned?(user.id) do
      :halt
    else
      {:cont, object}
    end
  end)
end

This may be required if policies are being cached, since the hook runs every time the authorization call happens, instead of only once when the policy is built.

Link to this function

attach_new_hook(policy, name, schema \\ :all, fun)

View Source
@spec attach_new_hook(t(), atom(), :all | Janus.schema_module(), hook()) :: t()

Attach a new hook to the policy.

Like attach_hook/4, except it only attaches the hook if the name isn't present for the given schema.

Link to this function

deny(policy, schema, action)

View Source
@spec deny(
  Janus.schema_module() | ruleset(),
  Janus.action() | [Janus.action()],
  keyword()
) :: ruleset()

Creates or updates a ruleset for a schema to deny an action if matched by conditions.

Must be attached to a policy using attach/2.

See "Permissions with allow and deny" for a description of conditions.

examples

Examples

thread_rules =
  Thread
  |> allow(:read)
  |> deny(:read, where: [scope: :private])

attach(policy, thread_rules)
Link to this function

deny(policy, schema, action, opts)

View Source
@spec deny(t(), Janus.schema_module(), Janus.action() | [Janus.action()], keyword()) ::
  t()

Denies an action on the schema if matched by conditions.

See "Permissions with allow and deny" for a description of conditions.

examples

Examples

policy
|> allow(FirstResource, :read)
|> deny(FirstResource, :read, where: [scope: :private])
Link to this function

detach_hook(policy, name, schema \\ :all)

View Source
@spec detach_hook(t(), atom(), :all | Janus.schema_module()) :: t()

Detach a hook from the policy.