PermitEx

Role and permission management for Ecto and Phoenix applications.

PermitEx is a small RBAC toolkit inspired by Laravel Spatie Permission: permissions live in the database, roles collect permissions, users receive roles globally or inside an optional context, and your application checks the current scope.

Users always receive permissions through roles — there is no direct user-permission assignment.

Use it for:

Status

0.3.0 is an additive release over the initial public API. Optional features (cache, wildcards, super roles) default to off, so existing installs keep the same behaviour until you opt in. The migration remains part of the public contract — review table names and indexes before installing into an existing system.

Concepts

Permission

A permission is a string such as:

orders:view
orders:manage
settings:manage

With wildcards: true, roles may also hold orders:* or *.

PermitEx stores permissions as strings instead of atoms so they can be seeded, edited in admin screens, and exposed through APIs without creating atoms at runtime.

Role

A role groups permissions:

PermitEx.seed!(
permissions: [
{"orders:view", "View orders"},
{"orders:manage", "Create, edit, and delete orders"}
],
roles: [
{"admin", "Administrator", ["orders:view", "orders:manage"]},
{"viewer", "Read-only user", ["orders:view"]}
]
)

Context

Contexts are optional.

In a regular app, assign roles globally:

{:ok, role} = PermitEx.upsert_role("admin")
{:ok, _user_role} = PermitEx.assign_role(user.id, role)

In a SaaS app, pass your tenant, workspace, organization, project, or account id as the context:

{:ok, role} = PermitEx.upsert_context_role("admin", workspace.id)
{:ok, _user_role} = PermitEx.assign_role(user.id, role, workspace.id)

Installation

Add the dependency:

def deps do
[
{:permit_ex, "~> 0.3.0"}
]
end

Configure your repo:

config :permit_ex, repo: MyApp.Repo

Optional features:

config :permit_ex,
cache: true,
cache_ttl: :timer.minutes(5),
wildcards: true,
super_roles: ["super_admin"]

Install and run the migration:

mix permit_ex.install
# or, for integer user/context ids:
# mix permit_ex.install --id-type id
mix ecto.migrate

The installer copies a migration with these tables:

Quick Start

Seed global permissions and role templates:

PermitEx.seed!(
permissions: [
{"orders:view", "View orders"},
{"orders:manage", "Create, edit, and delete orders"},
{"settings:manage", "Manage settings"}
],
roles: [
{"admin", "Administrator", ["orders:view", "orders:manage", "settings:manage"]},
{"viewer", "Read-only user", ["orders:view"]}
]
)

Assign roles:

{:ok, _count} = PermitEx.sync_roles(user.id, ["admin"])

Load a scope:

scope = PermitEx.Scope.for_user(user)

Check permissions and roles:

PermitEx.can?(scope, "orders:manage")
PermitEx.can_any?(scope, ["orders:view", "orders:manage"])
PermitEx.has_role?(scope, "admin")
PermitEx.authorize(scope, "settings:manage")

Context-Specific Roles

Clone global role templates into a context:

{:ok, roles} = PermitEx.clone_roles_to_context(workspace.id)

Or seed directly into a context:

PermitEx.seed!(definitions, context_id: workspace.id)

Then assign roles inside that context:

{:ok, _count} = PermitEx.sync_roles(user.id, ["admin"], workspace.id)
scope = PermitEx.Scope.for_user(user, workspace)

When a role name exists globally and inside the context, PermitEx resolves the context-specific role first.

Sync and incremental APIs

Replace all permissions for a role:

{:ok, _role} =
PermitEx.sync_permissions(role, [
"orders:view",
"orders:manage"
])

Add or remove permissions without a full replace:

{:ok, _count} = PermitEx.give_permission(role, "orders:delete")
{:ok, _count} = PermitEx.revoke_permission(role, "orders:delete")

Replace all roles for a user:

{:ok, _count} = PermitEx.sync_roles(user.id, ["admin", "billing"], workspace.id)

Add or remove roles without replacing the full set:

{:ok, _user_role} = PermitEx.assign_role(user.id, "viewer", workspace.id)
{:ok, _count} = PermitEx.assign_roles(user.id, ["viewer", "support"], workspace.id)
{:ok, _count} = PermitEx.revoke_role(user.id, "viewer", workspace.id)

By default, sync APIs return an error for missing names:

{:error, {:roles_not_found, ["missing_role"]}}
{:error, {:permissions_not_found, ["orders:delete"]}}

Pass allow_missing?: true only for intentionally partial imports.

Locked roles return {:error, :role_locked} on permission/role mutations unless you pass force?: true.

Phoenix and APIs

PermitEx includes optional Plug, LiveView, and Absinthe adapters. They are compiled only when the corresponding dependency is available.

For controllers or JSON APIs:

plug PermitEx.Plug.RequirePermission, "orders:manage"
plug PermitEx.Plug.RequireRole, "admin"

For LiveView:

on_mount {PermitEx.LiveView.RequirePermission, "orders:view"}
on_mount {PermitEx.LiveView.RequireRole, "admin"}

More examples:

Publishing

Before publishing:

mix format --check-formatted
mix compile --warnings-as-errors
mix test
mix docs
mix hex.build

Publish with:

mix hex.publish

License

MIT. See LICENSE.