diff --git a/apps/authorizer/.formatter.exs b/apps/authorizer/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/apps/authorizer/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/authorizer/.gitignore b/apps/authorizer/.gitignore new file mode 100644 index 0000000..5bd7e31 --- /dev/null +++ b/apps/authorizer/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +authorizer-*.tar + diff --git a/apps/authorizer/README.md b/apps/authorizer/README.md new file mode 100644 index 0000000..01d8dc0 --- /dev/null +++ b/apps/authorizer/README.md @@ -0,0 +1,21 @@ +# Authorizer + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `authorizer` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:authorizer, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/authorizer](https://hexdocs.pm/authorizer). + diff --git a/apps/authorizer/lib/authorizer.ex b/apps/authorizer/lib/authorizer.ex new file mode 100644 index 0000000..3bdf05e --- /dev/null +++ b/apps/authorizer/lib/authorizer.ex @@ -0,0 +1,10 @@ +defmodule Authorizer do + @moduledoc """ + Application to deal with request's to authorization server. + """ + + alias Authorizer.Rules.Commands.AdminAccess + + @doc "Delegates to #{AdminAccess}.execute/1" + defdelegate authorize_admin(conn), to: AdminAccess, as: :execute +end diff --git a/apps/authorizer/lib/policies/admin_allowed.ex b/apps/authorizer/lib/policies/admin_allowed.ex new file mode 100644 index 0000000..e0258ed --- /dev/null +++ b/apps/authorizer/lib/policies/admin_allowed.ex @@ -0,0 +1,69 @@ +defmodule Authorizer.Policies.AdminAllowed do + @moduledoc """ + Authorization policy to ensure that an subject is an admin. + """ + + require Logger + + alias Authorizer.Ports.ResourceManager + alias Plug.Conn + + @behaviour Authorizer.Policies.Behaviour + + @subject_types ~w(user application) + + @impl true + def info do + """ + Ensures that a specific subject is allowed to do an admin action. + In order to succeed it has to have `is_admin` set as `true`. + """ + end + + @impl true + def validate(%Conn{private: %{session: session}} = context) when is_map(session) do + case session do + %{subject_id: id, subject_type: type} when is_binary(id) and type in @subject_types -> + Logger.debug("Policity #{__MODULE__} validated with success") + {:ok, context} + + _any -> + Logger.error("Policy #{__MODULE__} failed on validation because session is invalid") + {:error, :unauthorized} + end + end + + def validate(%Conn{private: %{session: _}}) do + Logger.error("Policy #{__MODULE__} failed on validation because session was not found") + {:error, :unauthorized} + end + + @impl true + def execute(%Conn{private: %{session: session}}, opts \\ []) + when is_map(session) and is_list(opts) do + # We look for the identity on shared context first + identity = Keyword.get(opts, :identity) + + with {:identity, {:ok, identity}} <- {:identity, get_identity(identity || session)}, + {:admin?, true} <- {:admin?, identity.is_admin} do + Logger.debug("Policy #{__MODULE__} execution succeeded") + {:ok, Keyword.put(opts, :identity, identity)} + else + {:identity, error} -> + Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error)) + {:error, :unauthorized} + + {:admin?, false} -> + Logger.error("Policy #{__MODULE__} failed because subject is not an admin") + {:error, :unauthorized} + end + end + + defp get_identity(%{subject_id: subject_id, subject_type: "user"}), + do: ResourceManager.get_identity(%{id: subject_id, username: nil}) + + defp get_identity(%{subject_id: subject_id, subject_type: "application"}), + do: ResourceManager.get_identity(%{id: subject_id, client_id: nil}) + + defp get_identity(%{status: _} = identity), do: {:ok, identity} +end diff --git a/apps/authorizer/lib/policies/behaviour.ex b/apps/authorizer/lib/policies/behaviour.ex new file mode 100644 index 0000000..93af9b5 --- /dev/null +++ b/apps/authorizer/lib/policies/behaviour.ex @@ -0,0 +1,15 @@ +defmodule Authorizer.Policies.Behaviour do + @moduledoc """ + A policy is a set of verifications to make sure that a subject can do such action. + """ + + @doc "Return the policy description" + @callback info() :: String.t() + + @doc "Runs the input validations" + @callback validate(conn :: Plug.Conn.t()) :: {:ok, context :: map()} | {:error, atom()} + + @doc "Runs the authorization policy" + @callback execute(context :: map(), opts :: Keyword.t()) :: + {:ok, shared_context :: Keyword.t()} | {:error, :unauthorized} +end diff --git a/apps/authorizer/lib/policies/subject_active.ex b/apps/authorizer/lib/policies/subject_active.ex new file mode 100644 index 0000000..8bcc3d3 --- /dev/null +++ b/apps/authorizer/lib/policies/subject_active.ex @@ -0,0 +1,69 @@ +defmodule Authorizer.Policies.SubjectActive do + @moduledoc """ + Authorization policy to ensure that an subject is active. + """ + + require Logger + + alias Authorizer.Ports.ResourceManager + alias Plug.Conn + + @behaviour Authorizer.Policies.Behaviour + + @subject_types ~w(user application) + + @impl true + def info do + """ + Ensures that a specific subject is active. + In order to succeed it has to have `status` set as `active`. + """ + end + + @impl true + def validate(%Conn{private: %{session: session}} = context) when is_map(session) do + case session do + %{subject_id: id, subject_type: type} when is_binary(id) and type in @subject_types -> + Logger.debug("Policity #{__MODULE__} validated with success") + {:ok, context} + + _any -> + Logger.error("Policy #{__MODULE__} failed on validation because session is invalid") + {:error, :unauthorized} + end + end + + def validate(%Conn{private: %{session: _}}) do + Logger.error("Policy #{__MODULE__} failed on validation because session was not found") + {:error, :unauthorized} + end + + @impl true + def execute(%Conn{private: %{session: session}}, opts \\ []) + when is_map(session) and is_list(opts) do + # We look for the identity on shared context first + identity = Keyword.get(opts, :identity) + + with {:identity, {:ok, identity}} <- {:identity, get_identity(identity || session)}, + {:active?, "active"} <- {:active?, identity.status} do + Logger.debug("Policy #{__MODULE__} execution succeeded") + {:ok, Keyword.put(opts, :identity, identity)} + else + {:identity, error} -> + Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error)) + {:error, :unauthorized} + + {:active?, status} -> + Logger.error("Policy #{__MODULE__} failed because subject is status is #{status}") + {:error, :unauthorized} + end + end + + defp get_identity(%{subject_id: subject_id, subject_type: "user"}), + do: ResourceManager.get_identity(%{id: subject_id, username: nil}) + + defp get_identity(%{subject_id: subject_id, subject_type: "application"}), + do: ResourceManager.get_identity(%{id: subject_id, client_id: nil}) + + defp get_identity(%{status: _} = identity), do: {:ok, identity} +end diff --git a/apps/authorizer/lib/ports/resource_manager.ex b/apps/authorizer/lib/ports/resource_manager.ex new file mode 100644 index 0000000..5e9e363 --- /dev/null +++ b/apps/authorizer/lib/ports/resource_manager.ex @@ -0,0 +1,21 @@ +defmodule Authorizer.Ports.ResourceManager do + @moduledoc """ + Port to access ResourceManager domain commands. + """ + + @typedoc "All possible responses" + @type possible_responses :: {:ok, identity :: struct()} | {:error, :not_found | :invalid_params} + + @doc "Delegates to ResourceManager.get_identity/1" + @callback get_identity(input :: map()) :: possible_responses() + + @doc "Gets a subject identity by the given input" + @spec get_identity(input :: map()) :: possible_responses() + def get_identity(input), do: implementation().get_identity(input) + + defp implementation do + :authorizer + |> Application.get_env(__MODULE__) + |> Keyword.get(:domain) + end +end diff --git a/apps/authorizer/lib/rules/commands/admin_access.ex b/apps/authorizer/lib/rules/commands/admin_access.ex new file mode 100644 index 0000000..1089115 --- /dev/null +++ b/apps/authorizer/lib/rules/commands/admin_access.ex @@ -0,0 +1,49 @@ +defmodule Authorizer.Rules.Commands.AdminAccess do + @moduledoc """ + Rule for authorizing a subject to do any action on admin endpoints. + + In order to authorize we have to execute verify if the subject matches some + requirements as: + - It has admin flag enabled; + - It is status is active; + """ + + require Logger + + alias Authorizer.Policies.{AdminAllowed, SubjectActive} + alias Plug.Conn + + @steps [ + SubjectActive, + AdminAllowed + ] + + @doc """ + Run the authorization flow in order to verify if the subject matches all requirements. + This will call the following policies: + - #{SubjectActive}; + - #{AdminAllowed}; + """ + @spec execute(conn :: Conn.t()) :: :ok | {:error, :unauthorized} + def execute(%Conn{} = conn) do + @steps + |> Enum.reduce_while([], fn policy, opts -> run_policy(policy, conn, opts) end) + |> case do + {:error, :unauthorized} -> + Logger.error("Failed on some of the policies") + {:error, :unauthorized} + + _success -> + :ok + end + end + + defp run_policy(policy, conn, opts) do + with {:ok, context} <- policy.validate(conn), + {:ok, shared_context} <- policy.execute(context, opts) do + {:cont, shared_context} + else + error -> {:halt, error} + end + end +end diff --git a/apps/authorizer/mix.exs b/apps/authorizer/mix.exs new file mode 100644 index 0000000..af3bdcd --- /dev/null +++ b/apps/authorizer/mix.exs @@ -0,0 +1,49 @@ +defmodule Authorizer.MixProject do + use Mix.Project + + def project do + [ + app: :authorizer, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + elixirc_paths: elixirc_paths(Mix.env()), + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.11", + start_permanent: Mix.env() == :prod, + deps: deps(), + test_coverage: [tool: ExCoveralls] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # This makes sure the factory and any other modules in test/support are compiled + # when in the test environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + # Umbrella + {:resource_manager, in_umbrella: true}, + + # Domain + {:jason, "~> 1.0"}, + {:plug_cowboy, "~> 2.0"}, + + # Tools + {:dialyxir, "~> 1.0", only: :dev, runtime: false}, + {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.22", only: :dev, runtime: false}, + {:excoveralls, "~> 0.13", only: :test}, + {:mox, "~> 0.5", only: :test} + ] + end +end diff --git a/apps/authorizer/test/policies/admin_allowed_test.exs b/apps/authorizer/test/policies/admin_allowed_test.exs new file mode 100644 index 0000000..b960efe --- /dev/null +++ b/apps/authorizer/test/policies/admin_allowed_test.exs @@ -0,0 +1,67 @@ +defmodule Authorizer.Policies.AdminAllowedTest do + use Authorizer.DataCase, async: true + + alias Authorizer.Policies.AdminAllowed + alias Authorizer.Ports.ResourceManagerMock + + setup do + conn = %Plug.Conn{ + private: %{ + session: %{ + id: Ecto.UUID.generate(), + jti: Ecto.UUID.generate(), + subject_id: Ecto.UUID.generate(), + subject_type: "user", + expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(), 10_000), + scopes: ["admin:read", "admin.write"], + azp: "Watcher Ex" + } + } + } + + {:ok, conn: conn} + end + + describe "#{AdminAllowed}.validate/1" do + test "succeed on validations if required params on conn", %{conn: conn} do + assert {:ok, ^conn} = AdminAllowed.validate(conn) + end + + test "fails if conn do not have a session", %{conn: conn} do + assert {:error, :unauthorized} == AdminAllowed.validate(%{conn | private: %{session: nil}}) + end + + test "fails if conn session is invalid", %{conn: conn} do + assert {:error, :unauthorized} == AdminAllowed.validate(%{conn | private: %{session: %{}}}) + end + end + + describe "#{AdminAllowed}.execute/1" do + test "succeeds if identity is an admin", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{is_admin: true}} + end) + + assert {:ok, _shared_context} = AdminAllowed.execute(conn) + end + + test "fails if identity is not an admin", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{is_admin: false}} + end) + + assert {:error, :unauthorized} == AdminAllowed.execute(conn) + end + + test "fails if identity was not found", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:error, :not_found} + end) + + assert {:error, :unauthorized} == AdminAllowed.execute(conn) + end + end +end diff --git a/apps/authorizer/test/policies/subject_active_test.exs b/apps/authorizer/test/policies/subject_active_test.exs new file mode 100644 index 0000000..25c2d3c --- /dev/null +++ b/apps/authorizer/test/policies/subject_active_test.exs @@ -0,0 +1,67 @@ +defmodule Authorizer.Policies.SubjectActiveTest do + use Authorizer.DataCase, async: true + + alias Authorizer.Policies.SubjectActive + alias Authorizer.Ports.ResourceManagerMock + + setup do + conn = %Plug.Conn{ + private: %{ + session: %{ + id: Ecto.UUID.generate(), + jti: Ecto.UUID.generate(), + subject_id: Ecto.UUID.generate(), + subject_type: "user", + expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(), 10_000), + scopes: ["admin:read", "admin.write"], + azp: "Watcher Ex" + } + } + } + + {:ok, conn: conn} + end + + describe "#{SubjectActive}.validate/1" do + test "succeed on validations if required params on conn", %{conn: conn} do + assert {:ok, ^conn} = SubjectActive.validate(conn) + end + + test "fails if conn do not have a session", %{conn: conn} do + assert {:error, :unauthorized} == SubjectActive.validate(%{conn | private: %{session: nil}}) + end + + test "fails if conn session is invalid", %{conn: conn} do + assert {:error, :unauthorized} == SubjectActive.validate(%{conn | private: %{session: %{}}}) + end + end + + describe "#{SubjectActive}.execute/1" do + test "succeeds if identity is active", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{status: "active"}} + end) + + assert {:ok, _shared_context} = SubjectActive.execute(conn) + end + + test "fails if identity is not active", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{status: "blocked"}} + end) + + assert {:error, :unauthorized} == SubjectActive.execute(conn) + end + + test "fails if identity was not found", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:error, :not_found} + end) + + assert {:error, :unauthorized} == SubjectActive.execute(conn) + end + end +end diff --git a/apps/authorizer/test/rules/commands/admin_access_test.exs b/apps/authorizer/test/rules/commands/admin_access_test.exs new file mode 100644 index 0000000..f696af7 --- /dev/null +++ b/apps/authorizer/test/rules/commands/admin_access_test.exs @@ -0,0 +1,62 @@ +defmodule Authorizer.Rules.Commands.AdminAccessTest do + use Authorizer.DataCase, async: true + + alias Authorizer.Ports.ResourceManagerMock + alias Authorizer.Rules.Commands.AdminAccess + + setup do + conn = %Plug.Conn{ + private: %{ + session: %{ + id: Ecto.UUID.generate(), + jti: Ecto.UUID.generate(), + subject_id: Ecto.UUID.generate(), + subject_type: "user", + expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(), 10_000), + scopes: ["admin:read", "admin.write"], + azp: "Watcher Ex" + } + } + } + + {:ok, conn: conn} + end + + describe "#{AdminAccess}.execute/1" do + test "succeeds if subject is active and is an admin", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{status: "active", is_admin: true}} + end) + + assert :ok == AdminAccess.execute(conn) + end + + test "fails if identity is not an admin", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{status: "active", is_admin: false}} + end) + + assert {:error, :unauthorized} == AdminAccess.execute(conn) + end + + test "fails if identity is not active", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:ok, %{status: "blocked", is_admin: true}} + end) + + assert {:error, :unauthorized} == AdminAccess.execute(conn) + end + + test "fails if identity was not found", %{conn: conn} do + expect(ResourceManagerMock, :get_identity, fn %{id: user_id} -> + assert conn.private.session.subject_id == user_id + {:error, :not_found} + end) + + assert {:error, :unauthorized} == AdminAccess.execute(conn) + end + end +end diff --git a/apps/authorizer/test/support/data_case.ex b/apps/authorizer/test/support/data_case.ex new file mode 100644 index 0000000..85e9796 --- /dev/null +++ b/apps/authorizer/test/support/data_case.ex @@ -0,0 +1,16 @@ +defmodule Authorizer.DataCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + import Ecto + import Ecto.Query + import Mox + import Authorizer.{DataCase, Factory} + + setup :verify_on_exit! + end + end +end diff --git a/apps/authorizer/test/support/factory.ex b/apps/authorizer/test/support/factory.ex new file mode 100644 index 0000000..b885fd8 --- /dev/null +++ b/apps/authorizer/test/support/factory.ex @@ -0,0 +1,18 @@ +defmodule Authorizer.Factory do + @moduledoc false + + @doc "Extract the azp from the givem token claims" + @spec extract_azp(claims :: map()) :: String.t() | nil + def extract_azp(%{"azp" => azp}) when is_binary(azp), do: azp + def extract_azp(_any), do: nil + + @doc "Extract the given scopes on claims and converts into a list of string" + @spec extract_scopes(claims :: map()) :: list(String.t()) + def extract_scopes(%{"scope" => scope}) when is_binary(scope) do + scope + |> String.split(" ", trim: true) + |> Enum.map(& &1) + end + + def extract_scopes(_any), do: [] +end diff --git a/apps/authorizer/test/support/mocks.ex b/apps/authorizer/test/support/mocks.ex new file mode 100644 index 0000000..c8b87e8 --- /dev/null +++ b/apps/authorizer/test/support/mocks.ex @@ -0,0 +1,6 @@ +for module <- [ + # ResourceManager domain + Authorizer.Ports.ResourceManager + ] do + Mox.defmock(:"#{module}Mock", for: module) +end diff --git a/apps/authorizer/test/test_helper.exs b/apps/authorizer/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/authorizer/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/config/config.exs b/config/config.exs index 8291fa3..4ff0851 100644 --- a/config/config.exs +++ b/config/config.exs @@ -53,6 +53,12 @@ config :authenticator, Authenticator.Repo, port: 5432, pool_size: 10 +############# +# Authorizer +############# + +config :authorizer, Authorizer.Ports.ResourceManager, domain: ResourceManager + ########## # Rest API ########## diff --git a/config/test.exs b/config/test.exs index c7b2dd6..cc42960 100644 --- a/config/test.exs +++ b/config/test.exs @@ -34,6 +34,12 @@ config :authenticator, Authenticator.Repo, pool: Ecto.Adapters.SQL.Sandbox, show_sensitive_data_on_connection_error: true +############# +# Authorizer +############# + +config :authorizer, Authorizer.Ports.ResourceManager, domain: Authorizer.Ports.ResourceManagerMock + ########## # Rest API ##########