Skip to main content

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.

  ## 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` accepts an optional `:policy` module implementing
  `PermitEx.Policy`. The policy callback must return a boolean or
  `{:error, reason}`. Unexpected return values or raised exceptions propagate
  to the caller — wrap your policy in a `try/rescue` if you need guaranteed
  fail-closed semantics on errors.
  """

  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
    case can?(scope_or_permissions, permission) 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`.
  """
  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"},
      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) 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 "Lists permissions ordered by name."
  def list_permissions(opts \\ []) do
    repo(opts).all(from(p in Permission, order_by: p.name))
  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 "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, 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) 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 "Loads roles and permissions for a user in a single query."
  def scope_data_for(user_id, context_id \\ nil, 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

  @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 ->
      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, 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
    {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), 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 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