Current section
Files
Jump to
Current section
Files
lib/permit_ex.ex
defmodule PermitEx do
@moduledoc """
Role and permission management for Ecto and Phoenix applications.
`PermitEx` keeps the core authorization model intentionally small:
users receive roles globally or inside an optional context, roles receive
permissions, and permissions are checked against the current scope.
"""
import Ecto.Query
alias PermitEx.{Config, Permission, Role, RolePermission, UserRole}
@type permission :: String.t() | atom()
@type role :: Role.t() | Ecto.UUID.t() | String.t()
@type scope :: %{optional(:permissions) => Enumerable.t()}
@doc """
Returns true when the given scope or permission collection includes `permission`.
"""
def can?(scope_or_permissions, permission)
def can?(%{permissions: permissions}, permission), do: can?(permissions, permission)
def can?(%MapSet{} = permissions, permission) do
MapSet.member?(permissions, normalize_permission(permission))
end
def can?(permissions, permission) when is_list(permissions) do
normalized = normalize_permission(permission)
Enum.any?(permissions, &(normalize_permission(&1) == normalized))
end
def can?(_scope_or_permissions, _permission), do: false
@doc "Alias for `can?/2`."
def has_permission?(scope_or_permissions, permission),
do: can?(scope_or_permissions, permission)
@doc "Returns true when the given scope or role collection includes `role`."
def has_role?(scope_or_roles, role)
def has_role?(%{roles: roles}, role), do: has_role?(roles, role)
def has_role?(roles, role) when is_list(roles) do
normalized = normalize_role(role)
Enum.any?(roles, fn
%Role{name: name} -> name == normalized
value -> normalize_role(value) == normalized
end)
end
def has_role?(%MapSet{} = roles, role), do: MapSet.member?(roles, normalize_role(role))
def has_role?(_scope_or_roles, _role), do: false
@doc "Returns `:ok` or `{:error, :unauthorized}` for command-style flows."
def authorize(scope_or_permissions, permission) do
if can?(scope_or_permissions, permission), do: :ok, else: {:error, :unauthorized}
end
@doc """
Checks a permission and an optional resource policy.
Pass a policy module with `:policy`. The policy module must implement
`c:PermitEx.Policy.authorize/3`.
"""
def allowed?(scope, permission, resource \\ nil, opts \\ []) do
with true <- can?(scope, permission),
:ok <- authorize_policy(scope, resource, Keyword.get(opts, :policy)) do
true
else
_ -> false
end
end
@doc "Creates a permission."
def create_permission(attrs, opts \\ []) do
repo(opts).insert(Permission.changeset(%Permission{}, stringify_keys(attrs)))
end
@doc "Creates or updates a permission by name."
def upsert_permission(name, attrs \\ %{}, opts \\ []) when is_binary(name) do
attrs = attrs |> stringify_keys() |> Map.put("name", name)
repo(opts).insert(
Permission.changeset(%Permission{}, attrs),
on_conflict: {:replace, [:description, :updated_at]},
conflict_target: :name
)
end
@doc "Creates a global role or a context role when `context_id` is present."
def create_role(attrs, opts \\ []) do
repo(opts).insert(Role.changeset(%Role{}, stringify_keys(attrs)))
end
@doc "Creates or updates a global role by name."
def upsert_role(name, attrs \\ %{}, opts \\ []) when is_binary(name) do
attrs = attrs |> stringify_keys() |> Map.put("name", name)
repo(opts).insert(
Role.changeset(%Role{}, attrs),
on_conflict: {:replace, [:description, :locked, :updated_at]},
conflict_target: {:unsafe_fragment, "(name) WHERE context_id IS NULL"}
)
end
@doc "Creates or updates a context role by name."
def upsert_context_role(name, context_id, attrs \\ %{}, opts \\ [])
when is_binary(name) and is_binary(context_id) do
attrs =
attrs |> stringify_keys() |> Map.put("name", name) |> Map.put("context_id", context_id)
repo(opts).insert(
Role.changeset(%Role{}, attrs),
on_conflict: {:replace, [:description, :locked, :updated_at]},
conflict_target: {:unsafe_fragment, "(context_id, name) WHERE context_id IS NOT NULL"}
)
end
@doc "Lists permissions ordered by name."
def list_permissions(opts \\ []) do
repo(opts).all(from(p in Permission, order_by: p.name))
end
@doc "Gets a permission by name."
def get_permission_by_name(name, opts \\ []) when is_binary(name) do
repo(opts).get_by(Permission, name: name)
end
@doc "Deletes a permission."
def delete_permission(%Permission{} = permission, opts \\ []) do
repo(opts).delete(permission)
end
@doc "Lists global roles and roles for the given context."
def list_roles(context_id \\ nil, opts \\ []) do
Role
|> scope_roles(context_id)
|> order_by([r], asc: r.name)
|> preload([:role_permissions])
|> repo(opts).all()
end
@doc "Gets a global or context-specific role by name."
def get_role_by_name(name, context_id \\ nil, opts \\ []) when is_binary(name) do
repo = repo(opts)
case find_role_id({:name, name, name}, repo, context_id) do
nil -> nil
role_id -> repo.get(Role, role_id)
end
end
@doc "Lists roles that belong to one context."
def roles_for_context(context_id, opts \\ []) when is_binary(context_id) do
Role
|> where([r], r.context_id == ^context_id)
|> order_by([r], asc: r.name)
|> repo(opts).all()
end
@doc "Deletes a role."
def delete_role(%Role{} = role, opts \\ []) do
repo(opts).delete(role)
end
@doc "Lists permissions assigned to a role."
def list_role_permissions(role_ref, opts \\ []) do
repo = repo(opts)
with %Role{} = role <- get_role(role_ref, repo, context_from_opts(opts)) do
from(rp in RolePermission,
join: p in Permission,
on: p.id == rp.permission_id,
where: rp.role_id == ^role.id,
order_by: p.name,
select: p
)
|> repo.all()
else
nil -> []
end
end
@doc """
Replaces all permissions assigned to a role.
Accepts a role struct, role id, or role name. Permissions can be names, ids,
atoms, or `%PermitEx.Permission{}` structs. Missing permissions return
`{:error, {:permissions_not_found, missing}}` unless `allow_missing?: true`
is passed.
"""
def sync_role_permissions(role_ref, permissions, opts \\ []) when is_list(permissions) do
repo = repo(opts)
with %Role{} = role <- get_role(role_ref, repo, context_from_opts(opts)) do
case resolve_permission_ids(permissions, repo, opts) do
{:ok, permission_ids} ->
repo.transaction(fn ->
repo.delete_all(from(rp in RolePermission, where: rp.role_id == ^role.id))
entries =
Enum.map(permission_ids, fn permission_id ->
%{role_id: role.id, permission_id: permission_id, inserted_at: now()}
end)
insert_all_if_any(repo, RolePermission, entries,
on_conflict: :nothing,
conflict_target: [:role_id, :permission_id]
)
role
end)
{:error, _reason} = error ->
error
end
else
nil -> {:error, :role_not_found}
end
end
@doc "Alias for `sync_role_permissions/3`."
def sync_permissions(role_ref, permissions, opts \\ []),
do: sync_role_permissions(role_ref, permissions, opts)
@doc "Assigns one role to a user in a context."
def assign_role(user_id, role_or_id, context_id \\ nil, opts \\ []) do
repo = repo(opts)
case resolve_role_ids([role_or_id], context_id, repo, opts) do
{:ok, [role_id]} ->
%UserRole{}
|> UserRole.changeset(%{user_id: user_id, role_id: role_id, context_id: context_id})
|> repo.insert(
on_conflict: :nothing,
conflict_target: user_role_conflict_target(context_id)
)
{:error, {:roles_not_found, _missing}} = error ->
error
end
end
@doc "Assigns many roles to a user without removing existing roles."
def assign_roles(user_id, roles, context_id \\ nil, opts \\ []) when is_list(roles) do
repo = repo(opts)
case resolve_role_ids(roles, context_id, repo, opts) do
{:ok, role_ids} ->
entries =
Enum.map(role_ids, fn role_id ->
%{user_id: user_id, context_id: context_id, role_id: role_id, inserted_at: now()}
end)
{count, _} =
insert_all_if_any(repo, UserRole, entries,
on_conflict: :nothing,
conflict_target: user_role_conflict_target(context_id)
)
{:ok, count}
{:error, {:roles_not_found, _missing}} = error ->
error
end
end
@doc "Removes one role from a user in a context."
def revoke_role(user_id, role_or_id, context_id \\ nil, opts \\ []) do
repo = repo(opts)
case resolve_role_ids([role_or_id], context_id, repo, opts) do
{:ok, [role_id]} ->
{count, _} =
UserRole
|> where([ur], ur.user_id == ^user_id and ur.role_id == ^role_id)
|> scope_user_roles(context_id)
|> repo.delete_all()
{:ok, count}
{:error, {:roles_not_found, _missing}} = error ->
error
end
end
@doc """
Replaces all roles assigned to a user in a context.
This is the Spatie-style `syncRoles` equivalent. It accepts role structs, ids
or names and leaves the user with exactly the resolved roles.
"""
def sync_user_roles(user_id, roles, context_id \\ nil, opts \\ []) when is_list(roles) do
repo = repo(opts)
case resolve_role_ids(roles, context_id, repo, opts) do
{:ok, role_ids} ->
repo.transaction(fn ->
UserRole
|> where([ur], ur.user_id == ^user_id)
|> scope_user_roles(context_id)
|> repo.delete_all()
entries =
Enum.map(role_ids, fn role_id ->
%{user_id: user_id, context_id: context_id, role_id: role_id, inserted_at: now()}
end)
{count, _} =
insert_all_if_any(repo, UserRole, entries,
on_conflict: :nothing,
conflict_target: user_role_conflict_target(context_id)
)
count
end)
{:error, {:roles_not_found, _missing}} = error ->
error
end
end
@doc "Alias for `sync_user_roles/4`."
def sync_roles(user_id, roles, context_id \\ nil, opts \\ []),
do: sync_user_roles(user_id, roles, context_id, opts)
@doc "Loads roles assigned to a user in a context."
def roles_for(user_id, context_id \\ nil, opts \\ []) do
from(ur in UserRole,
join: r in Role,
on: r.id == ur.role_id,
where: ur.user_id == ^user_id,
order_by: r.name,
select: r
)
|> scope_user_roles(context_id)
|> repo(opts).all()
end
@doc "Lists user ids assigned to a role."
def users_with_role(role_ref, context_id \\ nil, opts \\ []) do
repo = repo(opts)
with %Role{} = role <- get_role(role_ref, repo, context_id) do
UserRole
|> where([ur], ur.role_id == ^role.id)
|> scope_user_roles(context_id)
|> order_by([ur], asc: ur.user_id)
|> select([ur], ur.user_id)
|> repo.all()
else
nil -> []
end
end
@doc "Lists role assignments for a user."
def list_user_roles(user_id, context_id \\ nil, opts \\ []) do
UserRole
|> where([ur], ur.user_id == ^user_id)
|> scope_user_roles(context_id)
|> order_by([ur], asc: ur.role_id)
|> repo(opts).all()
end
@doc "Loads permission names for the user in a context."
def permissions_for(user_id, context_id \\ nil, opts \\ []) do
from(ur in UserRole,
join: rp in RolePermission,
on: rp.role_id == ur.role_id,
join: p in Permission,
on: p.id == rp.permission_id,
where: ur.user_id == ^user_id,
select: p.name
)
|> scope_user_roles(context_id)
|> repo(opts).all()
|> MapSet.new()
end
@doc """
Clones global role templates into a context.
A global role is any role with `context_id == nil`. The cloned context role
receives the same name, description, locked flag, and permissions. Existing
context roles are updated idempotently.
PermitEx.clone_roles_to_context(workspace.id)
PermitEx.clone_roles_to_context(workspace.id, roles: ["admin", "viewer"])
"""
def clone_roles_to_context(context_id, opts \\ []) when is_binary(context_id) do
repo = repo(opts)
role_names = Keyword.get(opts, :roles)
repo.transaction(fn ->
list_global_role_templates(repo, role_names)
|> Enum.map(fn role ->
permission_names = permission_names_for_role(role.id, repo)
{:ok, context_role} =
upsert_context_role(
role.name,
context_id,
%{description: role.description, locked: role.locked},
repo: repo
)
{:ok, _role} = sync_role_permissions(context_role, permission_names, repo: repo)
context_role
end)
end)
end
@doc "Alias for `clone_roles_to_context/2`."
def sync_context_roles_from_templates(context_id, opts \\ []),
do: clone_roles_to_context(context_id, opts)
@doc """
Seeds permissions and roles in one transaction.
Expected shape:
PermitEx.seed!(
permissions: [
{"orders:view", "View orders"},
{"orders:manage", "Manage orders"}
],
roles: [
{"admin", "Context admin", ["orders:view", "orders:manage"]},
{"viewer", "Read-only user", ["orders:view"]}
]
)
"""
def seed!(definitions, opts \\ []) when is_list(definitions) do
repo = repo(opts)
repo.transaction(fn ->
definitions
|> Keyword.get(:permissions, [])
|> Enum.each(fn {name, description} ->
{:ok, _permission} = upsert_permission(name, %{description: description}, repo: repo)
end)
definitions
|> Keyword.get(:roles, [])
|> Enum.each(fn {name, description, permissions} ->
{:ok, role} = upsert_role(name, %{description: description}, repo: repo)
{:ok, _role} = sync_role_permissions(role, permissions, repo: repo)
end)
:ok
end)
end
def normalize_permission(permission) when is_atom(permission), do: Atom.to_string(permission)
def normalize_permission(permission) when is_binary(permission), do: permission
def normalize_permission(permission), do: to_string(permission)
def normalize_role(%Role{name: name}), do: name
def normalize_role(role) when is_atom(role), do: Atom.to_string(role)
def normalize_role(role) when is_binary(role), do: role
def normalize_role(role), do: to_string(role)
defp repo(opts), do: Keyword.get(opts, :repo) || Config.repo!()
defp context_from_opts(opts), do: Keyword.get(opts, :context_id, Keyword.get(opts, :tenant_id))
defp scope_roles(query, nil), do: where(query, [r], is_nil(r.context_id))
defp scope_roles(query, context_id) do
where(query, [r], is_nil(r.context_id) or r.context_id == ^context_id)
end
defp scope_user_roles(query, nil), do: where(query, [ur], is_nil(ur.context_id))
defp scope_user_roles(query, context_id) do
where(query, [ur], ur.context_id == ^context_id)
end
defp user_role_conflict_target(nil) do
{:unsafe_fragment, "(user_id, role_id) WHERE context_id IS NULL"}
end
defp user_role_conflict_target(_context_id) do
{:unsafe_fragment, "(user_id, context_id, role_id) WHERE context_id IS NOT NULL"}
end
defp get_role(%Role{} = role, _repo, _context_id), do: role
defp get_role(role_ref, repo, context_id) when is_binary(role_ref) do
case resolve_role_ids([role_ref], context_id, repo, []) do
{:ok, [role_id]} -> repo.get(Role, role_id)
{:error, _reason} -> nil
end
end
defp resolve_permission_ids(permissions, repo, opts) do
permissions
|> Enum.map(&permission_lookup/1)
|> resolve_permission_lookups(repo, Keyword.get(opts, :allow_missing?, false))
end
defp resolve_role_ids(roles, context_id, repo, opts) do
roles
|> Enum.map(&role_lookup/1)
|> resolve_role_lookups(repo, context_id, Keyword.get(opts, :allow_missing?, false))
end
defp permission_lookup(%Permission{id: id}), do: {:id, id, id}
defp permission_lookup(value) when is_atom(value), do: {:name, Atom.to_string(value), value}
defp permission_lookup(value) when is_binary(value) do
{kind, resolved} = lookup_value(value)
{kind, resolved, value}
end
defp role_lookup(%Role{id: id}), do: {:id, id, id}
defp role_lookup(value) when is_atom(value), do: {:name, Atom.to_string(value), value}
defp role_lookup(value) when is_binary(value) do
{kind, resolved} = lookup_value(value)
{kind, resolved, value}
end
defp lookup_value(value) do
case Ecto.UUID.cast(value) do
{:ok, uuid} -> {:id, uuid}
:error -> {:name, value}
end
end
defp resolve_permission_lookups(lookups, repo, allow_missing?) do
{ids, missing} =
Enum.reduce(lookups, {[], []}, fn lookup, {ids, missing} ->
case find_permission_id(lookup, repo) do
nil -> {ids, [lookup_label(lookup) | missing]}
id -> {[id | ids], missing}
end
end)
resolved_or_missing(ids, missing, :permissions_not_found, allow_missing?)
end
defp resolve_role_lookups(lookups, repo, context_id, allow_missing?) do
{ids, missing} =
Enum.reduce(lookups, {[], []}, fn lookup, {ids, missing} ->
case find_role_id(lookup, repo, context_id) do
nil -> {ids, [lookup_label(lookup) | missing]}
id -> {[id | ids], missing}
end
end)
resolved_or_missing(ids, missing, :roles_not_found, allow_missing?)
end
defp find_permission_id({:id, id, _label}, repo) do
Permission
|> where([p], p.id == ^id)
|> select([p], p.id)
|> repo.one()
end
defp find_permission_id({:name, name, _label}, repo) do
Permission
|> where([p], p.name == ^name)
|> select([p], p.id)
|> repo.one()
end
defp find_role_id({:id, id, _label}, repo, _context_id) do
Role
|> where([r], r.id == ^id)
|> select([r], r.id)
|> repo.one()
end
defp find_role_id({:name, name, _label}, repo, nil) do
Role
|> where([r], r.name == ^name and is_nil(r.context_id))
|> select([r], r.id)
|> repo.one()
end
defp find_role_id({:name, name, _label}, repo, context_id) do
Role
|> where([r], r.name == ^name and (r.context_id == ^context_id or is_nil(r.context_id)))
|> order_by([r], asc: is_nil(r.context_id))
|> limit(1)
|> select([r], r.id)
|> repo.one()
end
defp list_global_role_templates(repo, nil) do
Role
|> where([r], is_nil(r.context_id))
|> order_by([r], asc: r.name)
|> repo.all()
end
defp list_global_role_templates(repo, role_names) when is_list(role_names) do
normalized_names = Enum.map(role_names, &normalize_role/1)
Role
|> where([r], is_nil(r.context_id) and r.name in ^normalized_names)
|> order_by([r], asc: r.name)
|> repo.all()
end
defp permission_names_for_role(role_id, repo) do
from(rp in RolePermission,
join: p in Permission,
on: p.id == rp.permission_id,
where: rp.role_id == ^role_id,
select: p.name
)
|> repo.all()
end
defp authorize_policy(_scope, _resource, nil), do: :ok
defp authorize_policy(scope, resource, policy) when is_atom(policy) do
case policy.authorize(scope, resource, []) do
:ok -> :ok
true -> :ok
false -> {:error, :unauthorized}
{:error, _reason} = error -> error
end
end
defp resolved_or_missing(ids, [], _reason, _allow_missing?),
do: {:ok, ids |> Enum.reverse() |> Enum.uniq()}
defp resolved_or_missing(ids, _missing, _reason, true),
do: {:ok, ids |> Enum.reverse() |> Enum.uniq()}
defp resolved_or_missing(_ids, missing, reason, false) do
{:error, {reason, missing |> Enum.reverse() |> Enum.uniq()}}
end
defp lookup_label({_kind, _value, label}), do: label
defp insert_all_if_any(_repo, _schema, [], _opts), do: {0, nil}
defp insert_all_if_any(repo, schema, entries, opts), do: repo.insert_all(schema, entries, opts)
defp now, do: DateTime.utc_now(:microsecond)
defp stringify_keys(attrs) when is_map(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
end