From a04488917768b6f5a5ebc0ecf125fd5637c3e4ae Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 12 Oct 2020 11:47:07 -0300 Subject: [PATCH 1/4] feat: add initial authorizer application --- apps/authorizer/.formatter.exs | 4 ++ apps/authorizer/.gitignore | 24 +++++++++ apps/authorizer/README.md | 21 ++++++++ apps/authorizer/lib/authorizer.ex | 5 ++ apps/authorizer/lib/policies/admin_allowed.ex | 52 +++++++++++++++++++ apps/authorizer/lib/policies/behaviour.ex | 14 +++++ .../authorizer/lib/policies/subject_active.ex | 52 +++++++++++++++++++ apps/authorizer/lib/ports/resource_manager.ex | 21 ++++++++ apps/authorizer/mix.exs | 49 +++++++++++++++++ apps/authorizer/test/support/mocks.ex | 6 +++ apps/authorizer/test/test_helper.exs | 1 + config/config.exs | 6 +++ config/test.exs | 6 +++ 13 files changed, 261 insertions(+) create mode 100644 apps/authorizer/.formatter.exs create mode 100644 apps/authorizer/.gitignore create mode 100644 apps/authorizer/README.md create mode 100644 apps/authorizer/lib/authorizer.ex create mode 100644 apps/authorizer/lib/policies/admin_allowed.ex create mode 100644 apps/authorizer/lib/policies/behaviour.ex create mode 100644 apps/authorizer/lib/policies/subject_active.ex create mode 100644 apps/authorizer/lib/ports/resource_manager.ex create mode 100644 apps/authorizer/mix.exs create mode 100644 apps/authorizer/test/support/mocks.ex create mode 100644 apps/authorizer/test/test_helper.exs 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..7e47883 --- /dev/null +++ b/apps/authorizer/lib/authorizer.ex @@ -0,0 +1,5 @@ +defmodule Authorizer do + @moduledoc """ + Application to deal with request's to authorization server. + """ +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..5d1796f --- /dev/null +++ b/apps/authorizer/lib/policies/admin_allowed.ex @@ -0,0 +1,52 @@ +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: %{subject_id: subject_id, subject_type: subject_type} = context}) + when is_binary(subject_id) and subject_type in @subject_types, + do: {:ok, context} + + def validate(_any), do: {:error, :invalid_session} + + @impl true + def execute(context, _opts) when is_map(context) do + with {:identity, {:ok, identity}} <- {:identity, get_identity(context)}, + {:admin?, true} <- {:admin?, identity.is_admin} do + Logger.debug("Policy #{__MODULE__} succeeded") + :ok + else + {:identity, error} -> + Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error)) + {:error, :identity_not_found} + + {:admin?, false} -> + Logger.error("Policy #{__MODULE__} failed because subject is not an admin") + {:error, :not_an_admin} + 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}) +end diff --git a/apps/authorizer/lib/policies/behaviour.ex b/apps/authorizer/lib/policies/behaviour.ex new file mode 100644 index 0000000..4c6dffb --- /dev/null +++ b/apps/authorizer/lib/policies/behaviour.ex @@ -0,0 +1,14 @@ +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 | {: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..db1edb8 --- /dev/null +++ b/apps/authorizer/lib/policies/subject_active.ex @@ -0,0 +1,52 @@ +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: %{subject_id: subject_id, subject_type: subject_type} = context}) + when is_binary(subject_id) and subject_type in @subject_types, + do: {:ok, context} + + def validate(_any), do: {:error, :invalid_session} + + @impl true + def execute(context, _opts) when is_map(context) do + with {:identity, {:ok, identity}} <- {:identity, get_identity(context)}, + {:active?, "active"} <- {:active?, identity.status} do + Logger.debug("Policy #{__MODULE__} succeeded") + :ok + else + {:identity, error} -> + Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error)) + {:error, :identity_not_found} + + {:active?, false} -> + Logger.error("Policy #{__MODULE__} failed because subject is not active") + {:error, :not_active} + 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}) +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..0665782 --- /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 + :resource_manager + |> Application.get_env(__MODULE__) + |> Keyword.get(:command) + 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/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 ########## From e460663e5556bc58e8b5fceac22b6b49d2d4d062 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2020 11:40:52 -0300 Subject: [PATCH 2/4] feat: add authorization rules --- apps/authorizer/lib/policies/admin_allowed.ex | 37 +++++++--- apps/authorizer/lib/policies/behaviour.ex | 3 +- .../authorizer/lib/policies/subject_active.ex | 41 ++++++++---- apps/authorizer/lib/ports/resource_manager.ex | 4 +- .../lib/rules/commands/admin_access.ex | 49 ++++++++++++++ apps/authorizer/mix.exs | 3 - .../test/policies/admin_allowed_test.exs | 67 +++++++++++++++++++ .../test/policies/subject_active_test.exs | 67 +++++++++++++++++++ .../test/rules/commands/admin_access_test.exs | 62 +++++++++++++++++ apps/authorizer/test/support/data_case.ex | 16 +++++ apps/authorizer/test/support/factory.ex | 18 +++++ config/test.exs | 3 +- 12 files changed, 341 insertions(+), 29 deletions(-) create mode 100644 apps/authorizer/lib/rules/commands/admin_access.ex create mode 100644 apps/authorizer/test/policies/admin_allowed_test.exs create mode 100644 apps/authorizer/test/policies/subject_active_test.exs create mode 100644 apps/authorizer/test/rules/commands/admin_access_test.exs create mode 100644 apps/authorizer/test/support/data_case.ex create mode 100644 apps/authorizer/test/support/factory.ex diff --git a/apps/authorizer/lib/policies/admin_allowed.ex b/apps/authorizer/lib/policies/admin_allowed.ex index 5d1796f..e0258ed 100644 --- a/apps/authorizer/lib/policies/admin_allowed.ex +++ b/apps/authorizer/lib/policies/admin_allowed.ex @@ -21,26 +21,41 @@ defmodule Authorizer.Policies.AdminAllowed do end @impl true - def validate(%Conn{private: %{subject_id: subject_id, subject_type: subject_type} = context}) - when is_binary(subject_id) and subject_type in @subject_types, - do: {:ok, context} + 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} - def validate(_any), do: {:error, :invalid_session} + _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(context, _opts) when is_map(context) do - with {:identity, {:ok, identity}} <- {:identity, get_identity(context)}, + 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__} succeeded") - :ok + 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, :identity_not_found} + {:error, :unauthorized} {:admin?, false} -> Logger.error("Policy #{__MODULE__} failed because subject is not an admin") - {:error, :not_an_admin} + {:error, :unauthorized} end end @@ -49,4 +64,6 @@ defmodule Authorizer.Policies.AdminAllowed do 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 index 4c6dffb..93af9b5 100644 --- a/apps/authorizer/lib/policies/behaviour.ex +++ b/apps/authorizer/lib/policies/behaviour.ex @@ -10,5 +10,6 @@ defmodule Authorizer.Policies.Behaviour do @callback validate(conn :: Plug.Conn.t()) :: {:ok, context :: map()} | {:error, atom()} @doc "Runs the authorization policy" - @callback execute(context :: map(), opts :: Keyword.t()) :: :ok | {:error, :unauthorized} + @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 index db1edb8..8bcc3d3 100644 --- a/apps/authorizer/lib/policies/subject_active.ex +++ b/apps/authorizer/lib/policies/subject_active.ex @@ -21,26 +21,41 @@ defmodule Authorizer.Policies.SubjectActive do end @impl true - def validate(%Conn{private: %{subject_id: subject_id, subject_type: subject_type} = context}) - when is_binary(subject_id) and subject_type in @subject_types, - do: {:ok, context} + 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} - def validate(_any), do: {:error, :invalid_session} + _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(context, _opts) when is_map(context) do - with {:identity, {:ok, identity}} <- {:identity, get_identity(context)}, + 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__} succeeded") - :ok + 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, :identity_not_found} + {:error, :unauthorized} - {:active?, false} -> - Logger.error("Policy #{__MODULE__} failed because subject is not active") - {:error, :not_active} + {:active?, status} -> + Logger.error("Policy #{__MODULE__} failed because subject is status is #{status}") + {:error, :unauthorized} end end @@ -49,4 +64,6 @@ defmodule Authorizer.Policies.SubjectActive do 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 index 0665782..5e9e363 100644 --- a/apps/authorizer/lib/ports/resource_manager.ex +++ b/apps/authorizer/lib/ports/resource_manager.ex @@ -14,8 +14,8 @@ defmodule Authorizer.Ports.ResourceManager do def get_identity(input), do: implementation().get_identity(input) defp implementation do - :resource_manager + :authorizer |> Application.get_env(__MODULE__) - |> Keyword.get(:command) + |> 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 index af3bdcd..5505b6a 100644 --- a/apps/authorizer/mix.exs +++ b/apps/authorizer/mix.exs @@ -31,9 +31,6 @@ defmodule Authorizer.MixProject do defp deps do [ - # Umbrella - {:resource_manager, in_umbrella: true}, - # Domain {:jason, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, 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/config/test.exs b/config/test.exs index cc42960..ff1469b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -38,7 +38,8 @@ config :authenticator, Authenticator.Repo, # Authorizer ############# -config :authorizer, Authorizer.Ports.ResourceManager, domain: Authorizer.Ports.ResourceManagerMock +config :authorizer, Authorizer.Ports.ResourceManager, + domain: Authorizer.Ports.ResourceManagerMock ########## # Rest API From c2648785000b3d101835726083f2bc670b31b772 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2020 11:42:48 -0300 Subject: [PATCH 3/4] chore: add function delegation --- apps/authorizer/lib/authorizer.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/authorizer/lib/authorizer.ex b/apps/authorizer/lib/authorizer.ex index 7e47883..3bdf05e 100644 --- a/apps/authorizer/lib/authorizer.ex +++ b/apps/authorizer/lib/authorizer.ex @@ -2,4 +2,9 @@ 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 From 4dd2301329dcbe0fe509eeb4e3697470bc9a3b84 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2020 12:00:07 -0300 Subject: [PATCH 4/4] chore: make format happy aggain --- apps/authorizer/mix.exs | 3 +++ config/test.exs | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/authorizer/mix.exs b/apps/authorizer/mix.exs index 5505b6a..af3bdcd 100644 --- a/apps/authorizer/mix.exs +++ b/apps/authorizer/mix.exs @@ -31,6 +31,9 @@ defmodule Authorizer.MixProject do defp deps do [ + # Umbrella + {:resource_manager, in_umbrella: true}, + # Domain {:jason, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, diff --git a/config/test.exs b/config/test.exs index ff1469b..cc42960 100644 --- a/config/test.exs +++ b/config/test.exs @@ -38,8 +38,7 @@ config :authenticator, Authenticator.Repo, # Authorizer ############# -config :authorizer, Authorizer.Ports.ResourceManager, - domain: Authorizer.Ports.ResourceManagerMock +config :authorizer, Authorizer.Ports.ResourceManager, domain: Authorizer.Ports.ResourceManagerMock ########## # Rest API