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.

Installation

def deps do
[{:ruxsat, "~> 0.1.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
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

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.

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

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 and :condition_failed. Invalid rules, such as unknown options or a wrong if: arity, fail at compile time.

Security model

Limitations

Roadmap

These are ideas only, and will be added only if they are required by real use: