defmodule LetMe.Builder do @moduledoc false def get_acc_attribute(module, name) do module |> Module.get_attribute(name, []) |> Enum.reverse() end def introspection_functions(%{} = rules) do quote do defp __rules__, do: unquote(Macro.escape(rules)) @impl LetMe.Policy def list_rules, do: Map.values(__rules__()) @impl LetMe.Policy def list_rules(opts), do: LetMe.filter_rules(list_rules(), opts) @impl LetMe.Policy def filter_allowed_actions(rules, subject, object) when is_list(rules) do LetMe.filter_allowed_actions(rules, subject, object, __MODULE__) end @impl LetMe.Policy def fetch_rule(action) when is_atom(action), do: Map.fetch(__rules__(), action) @impl LetMe.Policy def fetch_rule!(action) when is_atom(action), do: Map.fetch!(__rules__(), action) @impl LetMe.Policy def get_rule(action) when is_atom(action), do: Map.get(__rules__(), action) end end def schema_functions(schemas) when is_list(schemas) do schema_function_clauses = for {object_name, schema} <- schemas do quote do def get_schema(unquote(object_name)), do: unquote(schema) end end object_function_clauses = for {object_name, schema} <- schemas do quote do def get_object_name(unquote(schema)), do: unquote(object_name) end end quote do @impl LetMe.Policy unquote(schema_function_clauses) def get_schema(object_name) when is_atom(object_name), do: nil @impl LetMe.Policy unquote(object_function_clauses) def get_object_name(%schema{}), do: get_object_name(schema) def get_object_name(schema), do: nil end end def authorize_functions(%{} = rules, opts) do check_module = Keyword.fetch!(opts, :check_module) error_reason = Keyword.fetch!(opts, :error_reason) error_message = Keyword.fetch!(opts, :error_message) rule_clauses = Enum.map(rules, &permit_function_clause(&1, check_module)) typespec = rules |> Map.keys() |> Enum.reduce(&{:|, [], [&1, &2]}) quote do @typedoc """ Valid actions to be passed to the various `authorize` functions. These are auto-generated from the defined policy rules. """ @type action() :: unquote(typespec) @impl LetMe.Policy @spec authorize?(action(), any, any, keyword) :: boolean() def authorize?(action, subject, object \\ nil, opts \\ []) unquote(rule_clauses) def authorize?(action, _, _, _) when is_atom(action) do Logger.warning( "Permission checked for rule that does not exist: #{action}", action: action, policy_module: unquote(check_module) ) false end @impl LetMe.Policy @spec authorize(action(), any, any, keyword) :: :ok | {:error, unquote(error_reason)} def authorize(action, subject, object \\ nil, opts \\ []) do if authorize?(action, subject, object, opts), do: :ok, else: {:error, unquote(error_reason)} end @impl LetMe.Policy @spec authorize!(action(), any, any, keyword) :: :ok def authorize!(action, subject, object \\ nil, opts \\ []) do if authorize?(action, subject, object, opts), do: :ok, else: raise(LetMe.UnauthorizedError, message: unquote(error_message)) end end end defp permit_function_clause( {rule_name, %LetMe.Rule{} = rule}, check_module ) do pre_hook_calls = build_pre_hook_calls(rule.pre_hooks, check_module) allow_condition = build_conditions(rule.allow, check_module) deny_condition = build_conditions(rule.deny, check_module) # check for conditions that are always true or false to prevent # "this check/guard will always yield the same result" warning combined_condition = case {allow_condition, deny_condition} do {false, _} -> false {_, true} -> false {_, false} -> allow_condition {true, _} -> quote(do: !unquote(deny_condition)) _ -> quote(do: !unquote(deny_condition) && unquote(allow_condition)) end quote do def authorize?(unquote(rule_name), subject, object, opts) do unquote(pre_hook_calls) unquote(combined_condition) end end end defp build_pre_hook_calls([], _), do: nil defp build_pre_hook_calls(pre_hooks, check_module) when is_list(pre_hooks) do functions = Enum.map(pre_hooks, fn pre_hook when is_atom(pre_hook) -> {check_module, pre_hook, []} {module, function} -> {module, function, []} {module, function, args} when is_list(args) -> {module, function, [args]} {_, _, args} -> raise ArgumentError, """ Invalid pre-hook options Expected pre-hook options to be a keyword list, got: #{inspect(args)} """ end) quote do {subject, object} = Enum.reduce( unquote(Macro.escape(functions)), {subject, object}, fn {module, function, args}, {subject, object} -> args = args |> List.flatten() |> Keyword.merge(opts) |> case do [] -> [] args -> [args] end apply(module, function, [subject, object] ++ args) end ) end end defp build_conditions([], _), do: false defp build_conditions([checks], check_module) do build_check(checks, check_module) end defp build_conditions(conditions, check_module) when is_list(conditions) do quote do Enum.any?(unquote(Enum.map(conditions, &build_check(&1, check_module)))) end end defp build_check([], _), do: false defp build_check([check], check_module) do build_check(check, check_module) end defp build_check(checks, check_module) when is_list(checks) do quote do Enum.all?(unquote(Enum.map(checks, &build_check(&1, check_module)))) end end defp build_check(true, _) do quote do true end end defp build_check(false, _) do quote do false end end defp build_check(function, check_module) when is_atom(function) do quote do apply(unquote(check_module), unquote(function), [subject, object]) end end defp build_check({function, opts}, check_module) when is_atom(function) do quote do apply(unquote(check_module), unquote(function), [ subject, object, unquote(opts) ]) end end end