From 26c988a29bfbbeeaf0564d1ae5d42199d48ffc09 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2020 13:46:48 -0300 Subject: [PATCH] feat: add authorizer plug --- apps/rest_api/lib/plugs/authentication.ex | 2 +- apps/rest_api/lib/plugs/authorization.ex | 46 +++++++++++ apps/rest_api/lib/ports/authorizer.ex | 23 ++++++ apps/rest_api/lib/routers/public.ex | 7 +- apps/rest_api/mix.exs | 1 + .../test/controllers/admin/user_test.exs | 8 +- .../test/plugs/authorization_test.exs | 80 +++++++++++++++++++ apps/rest_api/test/support/mocks.ex | 3 + config/config.exs | 1 + config/test.exs | 1 + 10 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 apps/rest_api/lib/plugs/authorization.ex create mode 100644 apps/rest_api/lib/ports/authorizer.ex create mode 100644 apps/rest_api/test/plugs/authorization_test.exs diff --git a/apps/rest_api/lib/plugs/authentication.ex b/apps/rest_api/lib/plugs/authentication.ex index f2b4120..a450969 100644 --- a/apps/rest_api/lib/plugs/authentication.ex +++ b/apps/rest_api/lib/plugs/authentication.ex @@ -1,6 +1,6 @@ defmodule RestAPI.Plugs.Authentication do @moduledoc """ - Provides authentication for public calls. + Provides authentication for public and admin calls. """ require Logger diff --git a/apps/rest_api/lib/plugs/authorization.ex b/apps/rest_api/lib/plugs/authorization.ex new file mode 100644 index 0000000..6906739 --- /dev/null +++ b/apps/rest_api/lib/plugs/authorization.ex @@ -0,0 +1,46 @@ +defmodule RestAPI.Plugs.Authorization do + @moduledoc """ + Provides authorization for public and admin calls. + """ + + require Logger + + alias RestAPI.Controllers.Fallback + alias RestAPI.Ports.Authorizer + + @behaviour Plug + + @impl true + def init(opts), do: opts + + @impl true + def call(%Plug.Conn{private: private} = conn, opts) when is_list(opts) do + with {:authenticated?, true} <- {:authenticated?, has_session?(private)}, + {:authorized?, true} <- {:authorized?, authorized?(conn, opts[:type])} do + conn + else + {:authenticated?, false} -> + Logger.info("Session not found") + Fallback.call(conn, {:error, :unauthorized}) + + {:authorized?, false} -> + Logger.info("Authorization failed in some policy") + Fallback.call(conn, {:error, :unauthorized}) + end + end + + defp has_session?(%{session: session}) when is_map(session), do: true + defp has_session?(_any), do: false + + defp authorized?(conn, "admin") do + conn + |> Authorizer.authorize_admin() + |> case do + :ok -> true + {:error, :unauthorized} -> false + end + end + + # We will start to authorize public endpoint on a next PR + defp authorized?(_conn, _type), do: true +end diff --git a/apps/rest_api/lib/ports/authorizer.ex b/apps/rest_api/lib/ports/authorizer.ex new file mode 100644 index 0000000..d3dc9aa --- /dev/null +++ b/apps/rest_api/lib/ports/authorizer.ex @@ -0,0 +1,23 @@ +defmodule RestAPI.Ports.Authorizer do + @moduledoc """ + Port to access Authorizer domain commands. + """ + + alias Plug.Conn + + @typedoc "All possible authorization responses" + @type possible_authorize_response :: :ok | {:error, :unauthorized} + + @doc "Delegates to Authorizer.authorize_admin/1" + @callback authorize_admin(conn :: Conn.t()) :: possible_authorize_response() + + @doc "Authorizes the subject using admin rule" + @spec authorize_admin(conn :: Conn.t()) :: possible_authorize_response() + def authorize_admin(conn), do: implementation().authorize_admin(conn) + + defp implementation do + :rest_api + |> Application.get_env(__MODULE__) + |> Keyword.get(:domain) + end +end diff --git a/apps/rest_api/lib/routers/public.ex b/apps/rest_api/lib/routers/public.ex index 2af2f57..bdb94b8 100644 --- a/apps/rest_api/lib/routers/public.ex +++ b/apps/rest_api/lib/routers/public.ex @@ -4,7 +4,7 @@ defmodule RestAPI.Routers.Public do use RestAPI.Router alias RestAPI.Controllers.Public - alias RestAPI.Plugs.Authentication + alias RestAPI.Plugs.{Authentication, Authorization} pipeline :rest_api do plug :accepts, ["json"] @@ -14,6 +14,10 @@ defmodule RestAPI.Routers.Public do plug Authentication end + pipeline :authorized_by_admin do + plug Authorization, type: "admin" + end + scope "/api/v1", Public do pipe_through :rest_api @@ -31,6 +35,7 @@ defmodule RestAPI.Routers.Public do scope "/admin/v1", RestAPI.Controller.Admin do pipe_through :authenticated + pipe_through :authorized_by_admin resources("/users", User, except: [:new]) end diff --git a/apps/rest_api/mix.exs b/apps/rest_api/mix.exs index ed2e444..3b32e2a 100644 --- a/apps/rest_api/mix.exs +++ b/apps/rest_api/mix.exs @@ -35,6 +35,7 @@ defmodule RestAPI.MixProject do # Umbrealla {:resource_manager, in_umbrella: true}, {:authenticator, in_umbrella: true}, + {:authorizer, in_umbrella: true}, # Domain {:phoenix, "~> 1.5.4"}, diff --git a/apps/rest_api/test/controllers/admin/user_test.exs b/apps/rest_api/test/controllers/admin/user_test.exs index 0227dcf..8d765b2 100644 --- a/apps/rest_api/test/controllers/admin/user_test.exs +++ b/apps/rest_api/test/controllers/admin/user_test.exs @@ -2,7 +2,7 @@ defmodule RestAPI.Controllers.Admin.User do use RestAPI.ConnCase, async: true alias ResourceManager.Identities.Commands.Inputs.CreateUser - alias RestAPI.Ports.{AuthenticatorMock, ResourceManagerMock} + alias RestAPI.Ports.{AuthenticatorMock, AuthorizerMock, ResourceManagerMock} @create_endpoint "/admin/v1/users" @@ -63,6 +63,8 @@ defmodule RestAPI.Controllers.Admin.User do }} end) + expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end) + assert %{ "id" => _id, "inserted_at" => _inserted_at, @@ -106,6 +108,8 @@ defmodule RestAPI.Controllers.Admin.User do CreateUser.cast_and_apply(input) end) + expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end) + assert %{ "detail" => "The given params failed in validation", "error" => "bad_request", @@ -144,6 +148,8 @@ defmodule RestAPI.Controllers.Admin.User do false end) + expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end) + assert %{ "detail" => "The given params failed in validation", "error" => "bad_request", diff --git a/apps/rest_api/test/plugs/authorization_test.exs b/apps/rest_api/test/plugs/authorization_test.exs new file mode 100644 index 0000000..a0743b6 --- /dev/null +++ b/apps/rest_api/test/plugs/authorization_test.exs @@ -0,0 +1,80 @@ +defmodule RestAPI.Plugs.AuthorizationTest do + use RestAPI.ConnCase, async: true + + alias RestAPI.Plugs.Authorization + alias RestAPI.Ports.AuthorizerMock + + describe "#{Authorization}.init/1" do + test "returns the given conn" do + assert [] == Authorization.init([]) + end + end + + describe "#{Authorization}.call/2" do + setup do + claims = default_claims() + {:ok, session: success_session(claims)} + end + + test "succeeds and authorizer the subject in public endpoint", ctx do + conn = %{ctx.conn | private: %{session: ctx.session}} + assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, type: "public") + end + + test "succeeds and authorizer the subject in admin endpoint", ctx do + conn = %{ctx.conn | private: %{session: ctx.session}} + + expect(AuthorizerMock, :authorize_admin, fn _conn -> :ok end) + + assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, type: "admin") + end + + test "succeeds and authorizer the subject as public if option not passed", ctx do + conn = %{ctx.conn | private: %{session: ctx.session}} + assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, []) + end + + test "fails if session not authenticated", %{conn: conn} do + assert %Plug.Conn{status: 401} = Authorization.call(conn, type: "admin") + end + + test "fails if subject unauthorized", ctx do + conn = %{ctx.conn | private: %{session: ctx.session}} + + expect(AuthorizerMock, :authorize_admin, fn _conn -> {:error, :unauthorized} end) + + assert %Plug.Conn{status: 401} = Authorization.call(conn, type: "admin") + end + end + + defp default_claims do + %{ + "jti" => "03eds74a-c291-4b5f", + "aud" => "02eff74a-c291-4b5f-a02f-4f92d8daf693", + "azp" => "my-application", + "sub" => "272459ce-7356-4460-b461-1ecf0ebf7c4e", + "typ" => "Bearer", + "identity" => "user", + "scope" => "admin:read" + } + end + + defp success_session(claims) do + %{ + id: "02eff44a-c291-4b5f-a02f-4f92d8dbf693", + jti: claims["jti"], + subject_id: claims["sub"], + subject_type: claims["identity"], + expires_at: claims["expires_at"], + scopes: parse_scopes(claims["scope"]), + azp: claims["azp"], + claims: claims + } + end + + defp parse_scopes(scope) when is_binary(scope) do + scope + |> String.split(" ", trim: true) + |> Enum.map(& &1) + end +end diff --git a/apps/rest_api/test/support/mocks.ex b/apps/rest_api/test/support/mocks.ex index 8242c5f..4a8e274 100644 --- a/apps/rest_api/test/support/mocks.ex +++ b/apps/rest_api/test/support/mocks.ex @@ -2,6 +2,9 @@ for module <- [ # Authenticator domain RestAPI.Ports.Authenticator, + # Authorizer domain + RestAPI.Ports.Authorizer, + # ResourceManager domain RestAPI.Ports.ResourceManager ] do diff --git a/config/config.exs b/config/config.exs index 4ff0851..44e98e6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -69,6 +69,7 @@ config :rest_api, RestAPI.Endpoint, config :rest_api, RestAPI.Application, children: [RestAPI.Telemetry, RestAPI.Endpoint] config :rest_api, RestAPI.Ports.Authenticator, domain: Authenticator +config :rest_api, RestAPI.Ports.Authorizer, domain: Authorizer config :rest_api, RestAPI.Ports.ResourceManager, domain: ResourceManager import_config "#{Mix.env()}.exs" diff --git a/config/test.exs b/config/test.exs index cc42960..719c3da 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,4 +49,5 @@ config :rest_api, RestAPI.Endpoint, server: false config :rest_api, RestAPI.Ports.Authenticator, domain: RestAPI.Ports.AuthenticatorMock +config :rest_api, RestAPI.Ports.Authorizer, domain: RestAPI.Ports.AuthorizerMock config :rest_api, RestAPI.Ports.ResourceManager, domain: RestAPI.Ports.ResourceManagerMock