Ruxsat
Small, explicit authorization for Elixir.
You declare allow rules for actions on resources. Everything else is denied.
When the logic gets complex, you write a plain Elixir function. There is no
policy engine, no process state and no framework dependency.
Why
Most apps need the same few rules: public reads, roles, "authors edit their own posts", and a handful of special cases. The usual options sit at two extremes:
- Policy frameworks bring DSL layers, check modules and evaluation order rules you have to learn first. That is a lot for a dozen rules.
- Hand-written
can?/3functions are simple but easy to get subtly wrong. A catch-all clause goes missing,nil == nilpasses as ownership, and there is no way to ask why access was denied.
Ruxsat sits in between. Each rule is one line, and anything complex is a plain
function. The library handles the parts that are easy to get wrong: deny by
default, nil-safe ownership, compile-time validation of rules and explain/3.
Installation
def deps do
[{:ruxsat, "~> 0.2.0"}]
end
To call allow without parentheses, add to .formatter.exs:
import_deps: [:ruxsat]
Example
defmodule MyApp.Authorization do
use Ruxsat
alias MyApp.{Comment, Post}
allow :read, Post, where: [published: true]
allow :read, Post, owner: true
allow :create, Post, role: :editor
allow :update, Post, role: [:admin, :editor]
allow :update, Post, owner: true
allow :delete, Post, role: :admin
allow :update, Comment, owner: :author_id
allow :publish, Post, if: &__MODULE__.can_publish?/2
def can_publish?(user, post), do: user.verified and not post.archived
end
MyApp.Authorization.can?(user, :update, post)
#=> true
MyApp.Authorization.authorize(user, :delete, post)
#=> {:error, :forbidden}
MyApp.Authorization.authorize!(user, :delete, post)
#=> ** (Ruxsat.ForbiddenError) forbidden: :delete on MyApp.Post
Subjects and resources are plain structs or maps. The subject can be nil,
for example a guest.
Rules
- No matching rule means access is denied.
- Several rules for the same action: any one passing is enough.
- Several options in one rule: all must pass.
- There are no deny rules, so rules never conflict and their order does not matter.
The resource can be a struct (post) or a module or atom (Post,
:dashboard). Passing the module is useful before a record exists:
MyApp.Authorization.can?(user, :create, Post)
A plain map resource has no type, so it matches no rules.
Roles
allow :delete, Post, role: :admin
allow :update, Post, role: [:admin, :editor]
A role rule checks subject.role, which can be an atom or a list of atoms.
If your roles are stored differently, use if:.
Ownership
allow :update, Post, owner: true # post.user_id == user.id
allow :update, Comment, owner: :author_id # comment.author_id == user.id
A nil id never counts as ownership. Ownership cannot be proven without a
resource instance, so can?(user, :update, Post) is false for owner rules.
Field values
allow :read, Post, where: [published: true]
allow :review, Post, role: :editor, where: [status: :draft]
Every listed field must equal its value. Values must be literals: atoms,
booleans, numbers or strings. nil, lists and operators are not supported on
purpose. For anything more complex, use if:.
Custom conditions
allow :update, Post, if: &__MODULE__.can_edit?/2
allow :update, Post, if: &can_edit?/2
allow :read, Post, if: fn user, post -> post.public or user.staff end
The function receives (subject, resource) and must return a boolean. Any
other return value raises. You can combine it with other options:
role: :editor, if: &within_quota?/2.
can? vs authorize
-
can?/3returns a boolean. Use it to decide what the UI shows. -
authorize/3returns:okor{:error, :forbidden}. Use it where the operation is performed:def delete_post(user, post) dowith :ok <- Authorization.authorize(user, :delete, post) doRepo.delete(post)endend -
authorize!/3returns:okor raisesRuxsat.ForbiddenError.
Filtering records
can?/3 checks one record. To load only the records a subject may access,
use filter/3. It turns the same rules into data:
MyApp.Authorization.filter(user, :read, Post)
#=> :all # e.g. an admin rule passed
#=> {:any, [[published: true], [user_id: 42]]} # published posts or their own
#=> :none # nothing
{:any, sets} means a record matches when all fields in at least one set
match. A record matches the filter exactly when can?/3 allows it.
Ruxsat does not build queries and does not depend on Ecto. With Ecto, the translation is a few lines in your app:
import Ecto.Query
def authorized(query, user, action, schema) do
case MyApp.Authorization.filter(user, action, schema) do
:all -> query
:none -> where(query, false)
{:any, sets} -> where(query, ^Enum.reduce(sets, dynamic(false), &or_set/2))
end
end
defp or_set(set, any) do
all =
Enum.reduce(set, dynamic(true), fn {key, value}, all ->
dynamic([r], ^all and field(r, ^key) == ^value)
end)
dynamic(^any or ^all)
end
Functions can't be turned into data, so filter/3 raises if any rule for that
action uses if:. Write that query by hand.
Debugging
MyApp.Authorization.explain(user, :update, post)
#=> {:denied,
# [{%Ruxsat.Rule{role: [:admin, :editor], ...}, :missing_role},
# {%Ruxsat.Rule{owner: :user_id, ...}, :not_owner}]}
MyApp.Authorization.explain(user, :archive, post)
#=> {:denied, :no_rules}
MyApp.Authorization.rules()
#=> [%Ruxsat.Rule{action: :read, resource: MyApp.Post, ...}, ...]
The possible reasons are :missing_role, :not_owner, :where_mismatch and
:condition_failed. Invalid rules, such as unknown options or a wrong if:
arity, fail at compile time.
Security model
can?/3is not a security boundary. Hiding a button does not protect anything. Callauthorize/3orauthorize!/3in the code that actually performs the operation, such as a context function or command handler.authorize/3deliberately returns only:forbidden. To see the reasons, useexplain/3, and do not send its output to clients.- Ruxsat never touches the database. A check costs whatever your
if:functions cost, so avoid hidden queries in them or load the data beforehand.
Limitations
- No deny rules, wildcards or action lists.
- No query building.
filter/3returns data, and turning it into a query is up to you. It does not support rules that useif:. where:supports only equality with literal values.- No Plug, Phoenix or LiveView integration.
- The role field (
:role) and the id field (:id) are fixed. Useif:for anything else.
Roadmap
These are ideas only, and will be added only if they are required by real use:
- an optional Ecto helper package, if copying the recipe gets tedious;
- optional Plug helpers in a separate package.