Skip to content

Commit cb23442

Browse files
authored
feat: add authorizer plug (#41)
1 parent 171e76d commit cb23442

File tree

10 files changed

+169
-3
lines changed

10 files changed

+169
-3
lines changed

apps/rest_api/lib/plugs/authentication.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule RestAPI.Plugs.Authentication do
22
@moduledoc """
3-
Provides authentication for public calls.
3+
Provides authentication for public and admin calls.
44
"""
55

66
require Logger
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule RestAPI.Plugs.Authorization do
2+
@moduledoc """
3+
Provides authorization for public and admin calls.
4+
"""
5+
6+
require Logger
7+
8+
alias RestAPI.Controllers.Fallback
9+
alias RestAPI.Ports.Authorizer
10+
11+
@behaviour Plug
12+
13+
@impl true
14+
def init(opts), do: opts
15+
16+
@impl true
17+
def call(%Plug.Conn{private: private} = conn, opts) when is_list(opts) do
18+
with {:authenticated?, true} <- {:authenticated?, has_session?(private)},
19+
{:authorized?, true} <- {:authorized?, authorized?(conn, opts[:type])} do
20+
conn
21+
else
22+
{:authenticated?, false} ->
23+
Logger.info("Session not found")
24+
Fallback.call(conn, {:error, :unauthorized})
25+
26+
{:authorized?, false} ->
27+
Logger.info("Authorization failed in some policy")
28+
Fallback.call(conn, {:error, :unauthorized})
29+
end
30+
end
31+
32+
defp has_session?(%{session: session}) when is_map(session), do: true
33+
defp has_session?(_any), do: false
34+
35+
defp authorized?(conn, "admin") do
36+
conn
37+
|> Authorizer.authorize_admin()
38+
|> case do
39+
:ok -> true
40+
{:error, :unauthorized} -> false
41+
end
42+
end
43+
44+
# We will start to authorize public endpoint on a next PR
45+
defp authorized?(_conn, _type), do: true
46+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule RestAPI.Ports.Authorizer do
2+
@moduledoc """
3+
Port to access Authorizer domain commands.
4+
"""
5+
6+
alias Plug.Conn
7+
8+
@typedoc "All possible authorization responses"
9+
@type possible_authorize_response :: :ok | {:error, :unauthorized}
10+
11+
@doc "Delegates to Authorizer.authorize_admin/1"
12+
@callback authorize_admin(conn :: Conn.t()) :: possible_authorize_response()
13+
14+
@doc "Authorizes the subject using admin rule"
15+
@spec authorize_admin(conn :: Conn.t()) :: possible_authorize_response()
16+
def authorize_admin(conn), do: implementation().authorize_admin(conn)
17+
18+
defp implementation do
19+
:rest_api
20+
|> Application.get_env(__MODULE__)
21+
|> Keyword.get(:domain)
22+
end
23+
end

apps/rest_api/lib/routers/public.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule RestAPI.Routers.Public do
44
use RestAPI.Router
55

66
alias RestAPI.Controllers.Public
7-
alias RestAPI.Plugs.Authentication
7+
alias RestAPI.Plugs.{Authentication, Authorization}
88

99
pipeline :rest_api do
1010
plug :accepts, ["json"]
@@ -14,6 +14,10 @@ defmodule RestAPI.Routers.Public do
1414
plug Authentication
1515
end
1616

17+
pipeline :authorized_by_admin do
18+
plug Authorization, type: "admin"
19+
end
20+
1721
scope "/api/v1", Public do
1822
pipe_through :rest_api
1923

@@ -31,6 +35,7 @@ defmodule RestAPI.Routers.Public do
3135

3236
scope "/admin/v1", RestAPI.Controller.Admin do
3337
pipe_through :authenticated
38+
pipe_through :authorized_by_admin
3439

3540
resources("/users", User, except: [:new])
3641
end

apps/rest_api/mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ defmodule RestAPI.MixProject do
3535
# Umbrealla
3636
{:resource_manager, in_umbrella: true},
3737
{:authenticator, in_umbrella: true},
38+
{:authorizer, in_umbrella: true},
3839

3940
# Domain
4041
{:phoenix, "~> 1.5.4"},

apps/rest_api/test/controllers/admin/user_test.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule RestAPI.Controllers.Admin.User do
22
use RestAPI.ConnCase, async: true
33

44
alias ResourceManager.Identities.Commands.Inputs.CreateUser
5-
alias RestAPI.Ports.{AuthenticatorMock, ResourceManagerMock}
5+
alias RestAPI.Ports.{AuthenticatorMock, AuthorizerMock, ResourceManagerMock}
66

77
@create_endpoint "/admin/v1/users"
88

@@ -63,6 +63,8 @@ defmodule RestAPI.Controllers.Admin.User do
6363
}}
6464
end)
6565

66+
expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end)
67+
6668
assert %{
6769
"id" => _id,
6870
"inserted_at" => _inserted_at,
@@ -106,6 +108,8 @@ defmodule RestAPI.Controllers.Admin.User do
106108
CreateUser.cast_and_apply(input)
107109
end)
108110

111+
expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end)
112+
109113
assert %{
110114
"detail" => "The given params failed in validation",
111115
"error" => "bad_request",
@@ -144,6 +148,8 @@ defmodule RestAPI.Controllers.Admin.User do
144148
false
145149
end)
146150

151+
expect(AuthorizerMock, :authorize_admin, fn %Plug.Conn{} -> :ok end)
152+
147153
assert %{
148154
"detail" => "The given params failed in validation",
149155
"error" => "bad_request",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
defmodule RestAPI.Plugs.AuthorizationTest do
2+
use RestAPI.ConnCase, async: true
3+
4+
alias RestAPI.Plugs.Authorization
5+
alias RestAPI.Ports.AuthorizerMock
6+
7+
describe "#{Authorization}.init/1" do
8+
test "returns the given conn" do
9+
assert [] == Authorization.init([])
10+
end
11+
end
12+
13+
describe "#{Authorization}.call/2" do
14+
setup do
15+
claims = default_claims()
16+
{:ok, session: success_session(claims)}
17+
end
18+
19+
test "succeeds and authorizer the subject in public endpoint", ctx do
20+
conn = %{ctx.conn | private: %{session: ctx.session}}
21+
assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, type: "public")
22+
end
23+
24+
test "succeeds and authorizer the subject in admin endpoint", ctx do
25+
conn = %{ctx.conn | private: %{session: ctx.session}}
26+
27+
expect(AuthorizerMock, :authorize_admin, fn _conn -> :ok end)
28+
29+
assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, type: "admin")
30+
end
31+
32+
test "succeeds and authorizer the subject as public if option not passed", ctx do
33+
conn = %{ctx.conn | private: %{session: ctx.session}}
34+
assert %Plug.Conn{private: %{session: _}} = Authorization.call(conn, [])
35+
end
36+
37+
test "fails if session not authenticated", %{conn: conn} do
38+
assert %Plug.Conn{status: 401} = Authorization.call(conn, type: "admin")
39+
end
40+
41+
test "fails if subject unauthorized", ctx do
42+
conn = %{ctx.conn | private: %{session: ctx.session}}
43+
44+
expect(AuthorizerMock, :authorize_admin, fn _conn -> {:error, :unauthorized} end)
45+
46+
assert %Plug.Conn{status: 401} = Authorization.call(conn, type: "admin")
47+
end
48+
end
49+
50+
defp default_claims do
51+
%{
52+
"jti" => "03eds74a-c291-4b5f",
53+
"aud" => "02eff74a-c291-4b5f-a02f-4f92d8daf693",
54+
"azp" => "my-application",
55+
"sub" => "272459ce-7356-4460-b461-1ecf0ebf7c4e",
56+
"typ" => "Bearer",
57+
"identity" => "user",
58+
"scope" => "admin:read"
59+
}
60+
end
61+
62+
defp success_session(claims) do
63+
%{
64+
id: "02eff44a-c291-4b5f-a02f-4f92d8dbf693",
65+
jti: claims["jti"],
66+
subject_id: claims["sub"],
67+
subject_type: claims["identity"],
68+
expires_at: claims["expires_at"],
69+
scopes: parse_scopes(claims["scope"]),
70+
azp: claims["azp"],
71+
claims: claims
72+
}
73+
end
74+
75+
defp parse_scopes(scope) when is_binary(scope) do
76+
scope
77+
|> String.split(" ", trim: true)
78+
|> Enum.map(& &1)
79+
end
80+
end

apps/rest_api/test/support/mocks.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ for module <- [
22
# Authenticator domain
33
RestAPI.Ports.Authenticator,
44

5+
# Authorizer domain
6+
RestAPI.Ports.Authorizer,
7+
58
# ResourceManager domain
69
RestAPI.Ports.ResourceManager
710
] do

config/config.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ config :rest_api, RestAPI.Endpoint,
6969

7070
config :rest_api, RestAPI.Application, children: [RestAPI.Telemetry, RestAPI.Endpoint]
7171
config :rest_api, RestAPI.Ports.Authenticator, domain: Authenticator
72+
config :rest_api, RestAPI.Ports.Authorizer, domain: Authorizer
7273
config :rest_api, RestAPI.Ports.ResourceManager, domain: ResourceManager
7374

7475
import_config "#{Mix.env()}.exs"

config/test.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ config :rest_api, RestAPI.Endpoint,
4949
server: false
5050

5151
config :rest_api, RestAPI.Ports.Authenticator, domain: RestAPI.Ports.AuthenticatorMock
52+
config :rest_api, RestAPI.Ports.Authorizer, domain: RestAPI.Ports.AuthorizerMock
5253
config :rest_api, RestAPI.Ports.ResourceManager, domain: RestAPI.Ports.ResourceManagerMock

0 commit comments

Comments
 (0)