Skip to content

Commit 2ebae0b

Browse files
authored
feat: add authorizer application (#39)
* feat: add initial authorizer application * feat: add authorization rules * chore: add function delegation * chore: make format happy aggain
1 parent 38de225 commit 2ebae0b

File tree

19 files changed

+580
-0
lines changed

19 files changed

+580
-0
lines changed

apps/authorizer/.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

apps/authorizer/.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
authorizer-*.tar
24+

apps/authorizer/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Authorizer
2+
3+
**TODO: Add description**
4+
5+
## Installation
6+
7+
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8+
by adding `authorizer` to your list of dependencies in `mix.exs`:
9+
10+
```elixir
11+
def deps do
12+
[
13+
{:authorizer, "~> 0.1.0"}
14+
]
15+
end
16+
```
17+
18+
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19+
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20+
be found at [https://hexdocs.pm/authorizer](https://hexdocs.pm/authorizer).
21+

apps/authorizer/lib/authorizer.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule Authorizer do
2+
@moduledoc """
3+
Application to deal with request's to authorization server.
4+
"""
5+
6+
alias Authorizer.Rules.Commands.AdminAccess
7+
8+
@doc "Delegates to #{AdminAccess}.execute/1"
9+
defdelegate authorize_admin(conn), to: AdminAccess, as: :execute
10+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
defmodule Authorizer.Policies.AdminAllowed do
2+
@moduledoc """
3+
Authorization policy to ensure that an subject is an admin.
4+
"""
5+
6+
require Logger
7+
8+
alias Authorizer.Ports.ResourceManager
9+
alias Plug.Conn
10+
11+
@behaviour Authorizer.Policies.Behaviour
12+
13+
@subject_types ~w(user application)
14+
15+
@impl true
16+
def info do
17+
"""
18+
Ensures that a specific subject is allowed to do an admin action.
19+
In order to succeed it has to have `is_admin` set as `true`.
20+
"""
21+
end
22+
23+
@impl true
24+
def validate(%Conn{private: %{session: session}} = context) when is_map(session) do
25+
case session do
26+
%{subject_id: id, subject_type: type} when is_binary(id) and type in @subject_types ->
27+
Logger.debug("Policity #{__MODULE__} validated with success")
28+
{:ok, context}
29+
30+
_any ->
31+
Logger.error("Policy #{__MODULE__} failed on validation because session is invalid")
32+
{:error, :unauthorized}
33+
end
34+
end
35+
36+
def validate(%Conn{private: %{session: _}}) do
37+
Logger.error("Policy #{__MODULE__} failed on validation because session was not found")
38+
{:error, :unauthorized}
39+
end
40+
41+
@impl true
42+
def execute(%Conn{private: %{session: session}}, opts \\ [])
43+
when is_map(session) and is_list(opts) do
44+
# We look for the identity on shared context first
45+
identity = Keyword.get(opts, :identity)
46+
47+
with {:identity, {:ok, identity}} <- {:identity, get_identity(identity || session)},
48+
{:admin?, true} <- {:admin?, identity.is_admin} do
49+
Logger.debug("Policy #{__MODULE__} execution succeeded")
50+
{:ok, Keyword.put(opts, :identity, identity)}
51+
else
52+
{:identity, error} ->
53+
Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error))
54+
{:error, :unauthorized}
55+
56+
{:admin?, false} ->
57+
Logger.error("Policy #{__MODULE__} failed because subject is not an admin")
58+
{:error, :unauthorized}
59+
end
60+
end
61+
62+
defp get_identity(%{subject_id: subject_id, subject_type: "user"}),
63+
do: ResourceManager.get_identity(%{id: subject_id, username: nil})
64+
65+
defp get_identity(%{subject_id: subject_id, subject_type: "application"}),
66+
do: ResourceManager.get_identity(%{id: subject_id, client_id: nil})
67+
68+
defp get_identity(%{status: _} = identity), do: {:ok, identity}
69+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule Authorizer.Policies.Behaviour do
2+
@moduledoc """
3+
A policy is a set of verifications to make sure that a subject can do such action.
4+
"""
5+
6+
@doc "Return the policy description"
7+
@callback info() :: String.t()
8+
9+
@doc "Runs the input validations"
10+
@callback validate(conn :: Plug.Conn.t()) :: {:ok, context :: map()} | {:error, atom()}
11+
12+
@doc "Runs the authorization policy"
13+
@callback execute(context :: map(), opts :: Keyword.t()) ::
14+
{:ok, shared_context :: Keyword.t()} | {:error, :unauthorized}
15+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
defmodule Authorizer.Policies.SubjectActive do
2+
@moduledoc """
3+
Authorization policy to ensure that an subject is active.
4+
"""
5+
6+
require Logger
7+
8+
alias Authorizer.Ports.ResourceManager
9+
alias Plug.Conn
10+
11+
@behaviour Authorizer.Policies.Behaviour
12+
13+
@subject_types ~w(user application)
14+
15+
@impl true
16+
def info do
17+
"""
18+
Ensures that a specific subject is active.
19+
In order to succeed it has to have `status` set as `active`.
20+
"""
21+
end
22+
23+
@impl true
24+
def validate(%Conn{private: %{session: session}} = context) when is_map(session) do
25+
case session do
26+
%{subject_id: id, subject_type: type} when is_binary(id) and type in @subject_types ->
27+
Logger.debug("Policity #{__MODULE__} validated with success")
28+
{:ok, context}
29+
30+
_any ->
31+
Logger.error("Policy #{__MODULE__} failed on validation because session is invalid")
32+
{:error, :unauthorized}
33+
end
34+
end
35+
36+
def validate(%Conn{private: %{session: _}}) do
37+
Logger.error("Policy #{__MODULE__} failed on validation because session was not found")
38+
{:error, :unauthorized}
39+
end
40+
41+
@impl true
42+
def execute(%Conn{private: %{session: session}}, opts \\ [])
43+
when is_map(session) and is_list(opts) do
44+
# We look for the identity on shared context first
45+
identity = Keyword.get(opts, :identity)
46+
47+
with {:identity, {:ok, identity}} <- {:identity, get_identity(identity || session)},
48+
{:active?, "active"} <- {:active?, identity.status} do
49+
Logger.debug("Policy #{__MODULE__} execution succeeded")
50+
{:ok, Keyword.put(opts, :identity, identity)}
51+
else
52+
{:identity, error} ->
53+
Logger.error("Policy #{__MODULE__} failed to get identity", error: inspect(error))
54+
{:error, :unauthorized}
55+
56+
{:active?, status} ->
57+
Logger.error("Policy #{__MODULE__} failed because subject is status is #{status}")
58+
{:error, :unauthorized}
59+
end
60+
end
61+
62+
defp get_identity(%{subject_id: subject_id, subject_type: "user"}),
63+
do: ResourceManager.get_identity(%{id: subject_id, username: nil})
64+
65+
defp get_identity(%{subject_id: subject_id, subject_type: "application"}),
66+
do: ResourceManager.get_identity(%{id: subject_id, client_id: nil})
67+
68+
defp get_identity(%{status: _} = identity), do: {:ok, identity}
69+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule Authorizer.Ports.ResourceManager do
2+
@moduledoc """
3+
Port to access ResourceManager domain commands.
4+
"""
5+
6+
@typedoc "All possible responses"
7+
@type possible_responses :: {:ok, identity :: struct()} | {:error, :not_found | :invalid_params}
8+
9+
@doc "Delegates to ResourceManager.get_identity/1"
10+
@callback get_identity(input :: map()) :: possible_responses()
11+
12+
@doc "Gets a subject identity by the given input"
13+
@spec get_identity(input :: map()) :: possible_responses()
14+
def get_identity(input), do: implementation().get_identity(input)
15+
16+
defp implementation do
17+
:authorizer
18+
|> Application.get_env(__MODULE__)
19+
|> Keyword.get(:domain)
20+
end
21+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule Authorizer.Rules.Commands.AdminAccess do
2+
@moduledoc """
3+
Rule for authorizing a subject to do any action on admin endpoints.
4+
5+
In order to authorize we have to execute verify if the subject matches some
6+
requirements as:
7+
- It has admin flag enabled;
8+
- It is status is active;
9+
"""
10+
11+
require Logger
12+
13+
alias Authorizer.Policies.{AdminAllowed, SubjectActive}
14+
alias Plug.Conn
15+
16+
@steps [
17+
SubjectActive,
18+
AdminAllowed
19+
]
20+
21+
@doc """
22+
Run the authorization flow in order to verify if the subject matches all requirements.
23+
This will call the following policies:
24+
- #{SubjectActive};
25+
- #{AdminAllowed};
26+
"""
27+
@spec execute(conn :: Conn.t()) :: :ok | {:error, :unauthorized}
28+
def execute(%Conn{} = conn) do
29+
@steps
30+
|> Enum.reduce_while([], fn policy, opts -> run_policy(policy, conn, opts) end)
31+
|> case do
32+
{:error, :unauthorized} ->
33+
Logger.error("Failed on some of the policies")
34+
{:error, :unauthorized}
35+
36+
_success ->
37+
:ok
38+
end
39+
end
40+
41+
defp run_policy(policy, conn, opts) do
42+
with {:ok, context} <- policy.validate(conn),
43+
{:ok, shared_context} <- policy.execute(context, opts) do
44+
{:cont, shared_context}
45+
else
46+
error -> {:halt, error}
47+
end
48+
end
49+
end

apps/authorizer/mix.exs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule Authorizer.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :authorizer,
7+
version: "0.1.0",
8+
build_path: "../../_build",
9+
config_path: "../../config/config.exs",
10+
elixirc_paths: elixirc_paths(Mix.env()),
11+
deps_path: "../../deps",
12+
lockfile: "../../mix.lock",
13+
elixir: "~> 1.11",
14+
start_permanent: Mix.env() == :prod,
15+
deps: deps(),
16+
test_coverage: [tool: ExCoveralls]
17+
]
18+
end
19+
20+
# Run "mix help compile.app" to learn about applications.
21+
def application do
22+
[
23+
extra_applications: [:logger]
24+
]
25+
end
26+
27+
# This makes sure the factory and any other modules in test/support are compiled
28+
# when in the test environment.
29+
defp elixirc_paths(:test), do: ["lib", "test/support"]
30+
defp elixirc_paths(_), do: ["lib"]
31+
32+
defp deps do
33+
[
34+
# Umbrella
35+
{:resource_manager, in_umbrella: true},
36+
37+
# Domain
38+
{:jason, "~> 1.0"},
39+
{:plug_cowboy, "~> 2.0"},
40+
41+
# Tools
42+
{:dialyxir, "~> 1.0", only: :dev, runtime: false},
43+
{:credo, "~> 1.4", only: [:dev, :test], runtime: false},
44+
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
45+
{:excoveralls, "~> 0.13", only: :test},
46+
{:mox, "~> 0.5", only: :test}
47+
]
48+
end
49+
end

0 commit comments

Comments
 (0)