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.
Users always receive permissions **through roles** — there is no direct
user-permission assignment.
## Atom safety
Permission and role identifiers can be passed as atoms for convenience
(`can?(scope, :orders_manage)`), but atoms must always be **compile-time
literals**. Never derive them from user input via `String.to_atom/1` — the
atom table is not garbage-collected and exhausting it crashes the VM. Use
strings for any value that originates from user input or external data.
## Policy contract
`allowed?/4` and `authorize/4` accept an optional `:policy` module implementing
`PermitEx.Policy`. The policy callback receives the full `opts` keyword list.
Unexpected return values or raised exceptions propagate to the caller — wrap
your policy in a `try/rescue` if you need guaranteed fail-closed semantics.
## Optional features (all off by default)
config :permit_ex,
cache: true,
cache_ttl: :timer.minutes(5),
wildcards: true,
super_roles: ["super_admin"]
"""
import Ecto.Query
alias PermitEx.{Cache, Config, Permission, Role, RolePermission, Telemetry, UserRole}
@type permission :: String.t() | atom()
@type role :: Role.t() | Ecto.UUID.t() | String.t()
@type scope :: %{optional(:permissions) => Enumerable.t(), optional(:roles) => Enumerable.t()}
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
@doc """
Returns true when the given scope or permission collection includes `permission`.
When `config :permit_ex, wildcards: true`, granted permissions may use `*`
(`orders:*` or `*`). When `super_roles` is configured and the scope has one
of those roles, every permission check succeeds.
"""
def can?(scope_or_permissions, permission)
def can?(%{permissions: permissions} = scope, permission) do
super_role?(scope) or permission_granted?(permissions, permission)
end
def can?(%MapSet{} = permissions, permission), do: permission_granted?(permissions, permission)
def can?(permissions, permission) when is_list(permissions),
do: permission_granted?(permissions, permission)
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 at least one of the permissions is granted."
def can_any?(scope_or_permissions, permissions) when is_list(permissions) do
Enum.any?(permissions, &can?(scope_or_permissions, &1))
end
@doc "Returns true when every permission is granted."
def can_all?(scope_or_permissions, permissions) when is_list(permissions) do
Enum.all?(permissions, &can?(scope_or_permissions, &1))
end
@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: role_granted?(roles, role)
def has_role?(roles, role) when is_list(roles), do: role_granted?(roles, role)
def has_role?(%MapSet{} = roles, role), do: MapSet.member?(roles, normalize_role(role))
def has_role?(_scope_or_roles, _role), do: false
@doc "Returns true when at least one of the roles is present."
def has_any_role?(scope_or_roles, roles) when is_list(roles) do
Enum.any?(roles, &has_role?(scope_or_roles, &1))
end
@doc "Returns true when every role is present."
def has_all_roles?(scope_or_roles, roles) when is_list(roles) do
Enum.all?(roles, &has_role?(scope_or_roles, &1))
end
@doc "Returns `:ok` or `{:error, :unauthorized}` for command-style flows."
def authorize(scope_or_permissions, permission) do
case can?(scope_or_permissions, permission) do
true -> :ok
false -> {:error, :unauthorized}
end
end
@doc """
Checks a permission and an optional resource policy.
Returns `:ok` or `{:error, reason}`. Pass a policy with `:policy`.
PermitEx.authorize(scope, "orders:manage", order, policy: MyApp.OrderPolicy)
PermitEx.authorize(scope, "orders:manage", policy: MyApp.OrderPolicy, resource: order)
"""
def authorize(scope, permission, resource_or_opts, opts \\ [])
def authorize(scope, permission, opts, extra) when is_list(opts) and is_list(extra) do
merged = Keyword.merge(opts, extra)
do_authorize(scope, permission, Keyword.get(merged, :resource), merged)
end
def authorize(scope, permission, resource, opts) when is_list(opts) do
do_authorize(scope, permission, resource, opts)
end
@doc "Returns `:ok` or `{:error, :unauthorized}` when the scope has the role."
def authorize_role(scope_or_roles, role) do
case has_role?(scope_or_roles, role) do
true -> :ok
false -> {:error, :unauthorized}
end
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`. Full `opts` are forwarded to the policy.
"""
def allowed?(scope, permission, resource \\ nil, opts \\ []) do
case do_authorize(scope, permission, resource, opts) do
:ok -> true
{:error, _} -> false
end
end
# ---------------------------------------------------------------------------
# Permissions CRUD
# ---------------------------------------------------------------------------
@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 "Updates a permission."
def update_permission(%Permission{} = permission, attrs, opts \\ []) do
permission
|> Permission.changeset(stringify_keys(attrs))
|> repo(opts).update()
end
@doc "Gets a permission by id."
def get_permission(id, opts \\ []) when is_binary(id) do
repo(opts).get(Permission, id)
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
Telemetry.span_result([:permit_ex, :mutation], %{operation: :delete_permission}, fn ->
result = repo(opts).delete(permission)
Cache.invalidate_all()
result
end)
end
# ---------------------------------------------------------------------------
# Roles CRUD
# ---------------------------------------------------------------------------
@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"},
returning: [:id]
)
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) or is_integer(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"},
returning: [:id]
)
end
@doc "Updates a role. Locked roles require `force?: true`."
def update_role(%Role{} = role, attrs, opts \\ []) do
with :ok <- ensure_role_unlocked(role, opts) do
role
|> Role.changeset(stringify_keys(attrs))
|> repo(opts).update()
|> tap_invalidate_all()
end
end
@doc "Gets a role by id."
def get_role_by_id(id, opts \\ []) when is_binary(id) do
repo(opts).get(Role, id)
end
@doc """
Returns a map of role name to permission names for the given context.
Useful for rendering admin permission matrices and exposing role definitions
via API. Roles with no permissions appear with an empty list.
PermitEx.role_matrix()
#=> %{"admin" => ["orders:manage", "orders:view"], "viewer" => ["orders:view"]}
PermitEx.role_matrix(workspace.id)
"""
def role_matrix(context_id \\ nil, opts \\ []) do
from(r in Role,
left_join: rp in RolePermission,
on: rp.role_id == r.id,
left_join: p in Permission,
on: p.id == rp.permission_id,
order_by: [asc: r.name, asc: p.name],
select: {r.name, p.name}
)
|> scope_roles(context_id)
|> repo(opts).all()
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Map.new(fn {role, perms} -> {role, Enum.reject(perms, &is_nil/1)} end)
end
@doc "Lists global roles and roles for the given context, with permissions preloaded."
def list_roles(context_id \\ nil, opts \\ []) do
Role
|> scope_roles(context_id)
|> order_by([r], asc: r.name)
|> preload([: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) or is_integer(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.
Locked roles return `{:error, :role_locked}` unless `force?: true` is passed.
"""
def delete_role(%Role{} = role, opts \\ []) do
with :ok <- ensure_role_unlocked(role, opts) do
Telemetry.span_result([:permit_ex, :mutation], %{operation: :delete_role}, fn ->
result = repo(opts).delete(role)
Cache.invalidate_all()
result
end)
end
end
# ---------------------------------------------------------------------------
# Role permissions
# ---------------------------------------------------------------------------
@doc "Lists permissions assigned to a role."
def list_role_permissions(role_ref, opts \\ []) do
repo = repo(opts)
with %Role{} = role <- resolve_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 """
Adds one permission to a role without removing existing ones.
Locked roles require `force?: true`.
"""
def give_permission(role_ref, permission, opts \\ []) do
give_permissions(role_ref, [permission], opts)
end
@doc """
Adds many permissions to a role without removing existing ones.
Locked roles require `force?: true`.
"""
def give_permissions(role_ref, permissions, opts \\ []) when is_list(permissions) do
repo = repo(opts)
with %Role{} = role <- resolve_role(role_ref, repo, context_from_opts(opts)),
:ok <- ensure_role_unlocked(role, opts),
{:ok, permission_ids} <- resolve_permission_ids(permissions, repo, opts) do
Telemetry.span_result([:permit_ex, :mutation], %{operation: :give_permissions}, fn ->
entries =
Enum.map(permission_ids, fn permission_id ->
%{role_id: role.id, permission_id: permission_id, inserted_at: now()}
end)
{count, _} =
insert_all_if_any(repo, RolePermission, entries,
on_conflict: :nothing,
conflict_target: [:role_id, :permission_id]
)
Cache.invalidate_all()
{:ok, count}
end)
else
nil -> {:error, :role_not_found}
{:error, _} = error -> error
end
end
@doc """
Removes one permission from a role.
Locked roles require `force?: true`.
"""
def revoke_permission(role_ref, permission, opts \\ []) do
revoke_permissions(role_ref, [permission], opts)
end
@doc """
Removes many permissions from a role.
Locked roles require `force?: true`.
"""
def revoke_permissions(role_ref, permissions, opts \\ []) when is_list(permissions) do
repo = repo(opts)
with %Role{} = role <- resolve_role(role_ref, repo, context_from_opts(opts)),
:ok <- ensure_role_unlocked(role, opts),
{:ok, permission_ids} <- resolve_permission_ids(permissions, repo, opts) do
Telemetry.span_result([:permit_ex, :mutation], %{operation: :revoke_permissions}, fn ->
{count, _} =
from(rp in RolePermission,
where: rp.role_id == ^role.id and rp.permission_id in ^permission_ids
)
|> repo.delete_all()
Cache.invalidate_all()
{:ok, count}
end)
else
nil -> {:error, :role_not_found}
{:error, _} = error -> error
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.
Locked roles require `force?: true`.
"""
def sync_role_permissions(role_ref, permissions, opts \\ []) when is_list(permissions) do
repo = repo(opts)
with %Role{} = role <- resolve_role(role_ref, repo, context_from_opts(opts)),
:ok <- ensure_role_unlocked(role, opts) do
case resolve_permission_ids(permissions, repo, opts) do
{:ok, permission_ids} ->
Telemetry.span_result(
[:permit_ex, :mutation],
%{operation: :sync_role_permissions},
fn ->
result =
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)
Cache.invalidate_all()
result
end
)
{:error, _reason} = error ->
error
end
else
nil -> {:error, :role_not_found}
{:error, _} = error -> error
end
end
@doc "Alias for `sync_role_permissions/3`."
def sync_permissions(role_ref, permissions, opts \\ []),
do: sync_role_permissions(role_ref, permissions, opts)
# ---------------------------------------------------------------------------
# User roles
# ---------------------------------------------------------------------------
@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]} ->
Telemetry.span_result([:permit_ex, :mutation], %{operation: :assign_role}, fn ->
result =
%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)
)
Cache.invalidate(user_id, context_id)
result
end)
{: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} ->
Telemetry.span_result([:permit_ex, :mutation], %{operation: :assign_roles}, fn ->
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)
)
Cache.invalidate(user_id, context_id)
{:ok, count}
end)
{: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]} ->
Telemetry.span_result([:permit_ex, :mutation], %{operation: :revoke_role}, fn ->
{count, _} =
UserRole
|> where([ur], ur.user_id == ^user_id and ur.role_id == ^role_id)
|> scope_user_roles(context_id)
|> repo.delete_all()
Cache.invalidate(user_id, context_id)
{:ok, count}
end)
{: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} ->
Telemetry.span_result([:permit_ex, :mutation], %{operation: :sync_user_roles}, fn ->
result =
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)
Cache.invalidate(user_id, context_id)
result
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 <- resolve_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 user ids that have the given permission through any role in the context.
"""
def users_with_permission(permission_ref, context_id \\ nil, opts \\ []) do
repo = repo(opts)
with {:ok, [permission_id]} <- resolve_permission_ids([permission_ref], repo, opts) do
from(ur in UserRole,
join: rp in RolePermission,
on: rp.role_id == ur.role_id,
where: rp.permission_id == ^permission_id,
order_by: ur.user_id,
distinct: true,
select: ur.user_id
)
|> scope_user_roles(context_id)
|> repo.all()
else
{:error, _} -> []
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
{_roles, permissions} = scope_data_for(user_id, context_id, opts)
permissions
end
@doc """
Loads roles and permissions for a user in a single query.
Results are cached when `config :permit_ex, cache: true`. Pass
`skip_cache: true` to force a database load.
"""
def scope_data_for(user_id, context_id \\ nil, opts \\ []) do
skip_cache? = Keyword.get(opts, :skip_cache, false)
Telemetry.span(
[:permit_ex, :scope, :load],
%{user_id: user_id, context_id: context_id},
fn ->
if not skip_cache? do
case Cache.get(user_id, context_id) do
{:ok, data} ->
{data, %{cache_hit: true}}
:miss ->
data = load_scope_data(user_id, context_id, opts)
Cache.put(user_id, context_id, data)
{data, %{cache_hit: false}}
end
else
data = load_scope_data(user_id, context_id, opts)
{data, %{cache_hit: false}}
end
end
)
end
# ---------------------------------------------------------------------------
# Context templates & seed
# ---------------------------------------------------------------------------
@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) or is_integer(context_id) do
repo = repo(opts)
role_names = Keyword.get(opts, :roles)
repo.transaction(fn ->
roles = list_global_role_templates(repo, role_names)
permissions_by_role_id = batch_permission_names_for_roles(Enum.map(roles, & &1.id), repo)
Enum.map(roles, fn role ->
permission_names = Map.get(permissions_by_role_id, role.id, [])
{: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,
opts |> Keyword.put(:repo, repo) |> Keyword.put(:force?, true)
)
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.
Pass `context_id:` to create context roles instead of global templates.
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"]}
]
)
PermitEx.seed!(definitions, context_id: workspace.id)
"""
def seed!(definitions, opts \\ []) when is_list(definitions) do
repo = repo(opts)
context_id = Keyword.get(opts, :context_id)
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} =
if context_id do
upsert_context_role(name, context_id, %{description: description}, repo: repo)
else
upsert_role(name, %{description: description}, repo: repo)
end
{:ok, _role} =
sync_role_permissions(role, permissions, repo: repo, force?: true)
end)
:ok
end)
end
@doc """
Invalidates cached scope data.
Pass a `context_id` for one scope (`nil` = global). Pass `:all` as the second
argument to drop every cached scope for that user.
"""
def invalidate_cache(user_id, context_id \\ nil), do: Cache.invalidate(user_id, context_id)
@doc "Invalidates the entire scope cache."
def invalidate_cache_all, do: Cache.invalidate_all()
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)
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
defp do_authorize(scope, permission, resource, opts) do
start = System.monotonic_time()
result =
with true <- can?(scope, permission),
:ok <- authorize_policy(scope, resource, Keyword.get(opts, :policy), opts) do
:ok
else
false -> {:error, :unauthorized}
{:error, _reason} = error -> error
end
:telemetry.execute(
[:permit_ex, :authorize, :stop],
%{duration: System.monotonic_time() - start},
%{
permission: normalize_permission(permission),
result: elem_result(result),
check: :authorize
}
)
result
end
defp elem_result(:ok), do: :ok
defp elem_result({:error, _}), do: :error
defp permission_granted?(permissions, permission) do
normalized = normalize_permission(permission)
cond do
collection_member?(permissions, normalized) ->
true
Config.wildcards?() ->
wildcard_match?(permissions, normalized)
true ->
false
end
end
defp wildcard_match?(permissions, requested) do
permissions
|> to_permission_list()
|> Enum.any?(fn granted ->
granted == "*" or resource_wildcard?(granted, requested)
end)
end
defp resource_wildcard?(granted, requested) do
case String.split(granted, ":", parts: 2) do
[resource, "*"] ->
case String.split(requested, ":", parts: 2) do
[^resource, _action] -> true
_ -> false
end
_ ->
false
end
end
defp collection_member?(%MapSet{} = set, value), do: MapSet.member?(set, value)
defp collection_member?(list, value) when is_list(list) do
Enum.any?(list, &(normalize_permission(&1) == value))
end
defp collection_member?(_, _), do: false
defp to_permission_list(%MapSet{} = set), do: MapSet.to_list(set)
defp to_permission_list(list) when is_list(list), do: Enum.map(list, &normalize_permission/1)
defp to_permission_list(_), do: []
defp role_granted?(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
defp role_granted?(%MapSet{} = roles, role), do: MapSet.member?(roles, normalize_role(role))
defp role_granted?(_, _), do: false
defp super_role?(%{roles: roles}) do
super = Config.super_roles()
super != [] and
Enum.any?(super, fn name ->
role_granted?(roles, name)
end)
end
defp super_role?(_), do: false
defp load_scope_data(user_id, context_id, opts) do
rows =
from(ur in UserRole,
join: r in Role,
on: r.id == ur.role_id,
left_join: rp in RolePermission,
on: rp.role_id == r.id,
left_join: p in Permission,
on: p.id == rp.permission_id,
where: ur.user_id == ^user_id,
select: {r, p.name}
)
|> scope_user_roles(context_id)
|> repo(opts).all()
roles = rows |> Enum.map(&elem(&1, 0)) |> Enum.uniq_by(& &1.id)
permissions = rows |> Enum.map(&elem(&1, 1)) |> Enum.reject(&is_nil/1) |> MapSet.new()
{roles, permissions}
end
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 resolve_role(%Role{} = role, _repo, _context_id), do: role
defp resolve_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 ensure_role_unlocked(%Role{locked: true}, opts) do
if Keyword.get(opts, :force?, false), do: :ok, else: {:error, :role_locked}
end
defp ensure_role_unlocked(%Role{}, _opts), do: :ok
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
{id_lookups, name_lookups} = Enum.split_with(lookups, &match?({:id, _, _}, &1))
found =
Map.merge(
batch_find_permissions_by_id(id_lookups, repo),
batch_find_permissions_by_name(name_lookups, repo)
)
{ids, missing} =
Enum.reduce(lookups, {[], []}, fn {kind, value, label}, {ids, missing} ->
case Map.fetch(found, {kind, value}) do
{:ok, id} -> {[id | ids], missing}
:error -> {ids, [label | 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
{id_lookups, name_lookups} = Enum.split_with(lookups, &match?({:id, _, _}, &1))
found =
Map.merge(
batch_find_roles_by_id(id_lookups, repo),
batch_find_roles_by_name(name_lookups, repo, context_id)
)
{ids, missing} =
Enum.reduce(lookups, {[], []}, fn {kind, value, label}, {ids, missing} ->
case Map.fetch(found, {kind, value}) do
{:ok, id} -> {[id | ids], missing}
:error -> {ids, [label | missing]}
end
end)
resolved_or_missing(ids, missing, :roles_not_found, allow_missing?)
end
defp batch_find_permissions_by_id([], _repo), do: %{}
defp batch_find_permissions_by_id(lookups, repo) do
ids = Enum.map(lookups, fn {:id, id, _} -> id end)
Permission
|> where([p], p.id in ^ids)
|> select([p], p.id)
|> repo.all()
|> Map.new(&{{:id, &1}, &1})
end
defp batch_find_permissions_by_name([], _repo), do: %{}
defp batch_find_permissions_by_name(lookups, repo) do
names = Enum.map(lookups, fn {:name, name, _} -> name end)
Permission
|> where([p], p.name in ^names)
|> select([p], {p.name, p.id})
|> repo.all()
|> Map.new(fn {name, id} -> {{:name, name}, id} end)
end
defp batch_find_roles_by_id([], _repo), do: %{}
defp batch_find_roles_by_id(lookups, repo) do
ids = Enum.map(lookups, fn {:id, id, _} -> id end)
Role
|> where([r], r.id in ^ids)
|> select([r], r.id)
|> repo.all()
|> Map.new(&{{:id, &1}, &1})
end
defp batch_find_roles_by_name([], _repo, _context_id), do: %{}
defp batch_find_roles_by_name(lookups, repo, nil) do
names = Enum.map(lookups, fn {:name, name, _} -> name end)
Role
|> where([r], r.name in ^names and is_nil(r.context_id))
|> select([r], {r.name, r.id})
|> repo.all()
|> Map.new(fn {name, id} -> {{:name, name}, id} end)
end
defp batch_find_roles_by_name(lookups, repo, context_id) do
names = Enum.map(lookups, fn {:name, name, _} -> name end)
Role
|> where([r], r.name in ^names and (r.context_id == ^context_id or is_nil(r.context_id)))
|> select([r], {r.name, r.id, r.context_id})
|> repo.all()
|> Enum.group_by(&elem(&1, 0))
|> Map.new(fn {name, entries} ->
{_, id, _} =
Enum.min_by(entries, fn {_, _, ctx} ->
case ctx do
nil -> 1
_ -> 0
end
end)
{{:name, name}, id}
end)
end
defp batch_permission_names_for_roles([], _repo), do: %{}
defp batch_permission_names_for_roles(role_ids, repo) do
from(rp in RolePermission,
join: p in Permission,
on: p.id == rp.permission_id,
where: rp.role_id in ^role_ids,
select: {rp.role_id, p.name}
)
|> repo.all()
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
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 authorize_policy(_scope, _resource, nil, _opts), do: :ok
defp authorize_policy(scope, resource, policy, opts) when is_atom(policy) do
case policy.authorize(scope, resource, opts) 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 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 tap_invalidate_all({:ok, _} = result) do
Cache.invalidate_all()
result
end
defp tap_invalidate_all(other), do: other
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