Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: Get and Cache List of Roles + Helper Functions: has_role?/2 #7

Merged
merged 24 commits into from
Sep 17, 2020
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
50cce5d
add dependency on HTTPoison and Jason to make HTTP requests for /appr…
nelsonic Sep 13, 2020
e05a58a
create get_approles/2 function to load list of roles for an app https…
nelsonic Sep 13, 2020
7c41353
use :ets to store roles list in cache https://github.com/dwyl/auth/is…
nelsonic Sep 14, 2020
5b719bb
create get_role_from_cache/1 for #1
nelsonic Sep 14, 2020
99ec7d3
implement has_role/2 (happy path) #1
nelsonic Sep 14, 2020
abeb6e1
add tests for unhappy path and refactor get_role_from_cache/1 to pass…
nelsonic Sep 14, 2020
ffebff5
mix format
nelsonic Sep 14, 2020
0aa6124
add test to confirm that has_role/2 works with integers too! e.g. has…
nelsonic Sep 14, 2020
1fb2056
bump version to 0.4.0 with latest functions #1
nelsonic Sep 14, 2020
90a16a8
implement has_role_any?/2 to check if person has any of listed roles #1
nelsonic Sep 14, 2020
7dcab44
tidy up docs to add ? to has_role?/2 and has_role_any?/2 #1
nelsonic Sep 14, 2020
1e9f126
add docs / usage example to README.md for #1
nelsonic Sep 14, 2020
012bc3d
Remove redundunt function call
th0mas Sep 15, 2020
c2a863c
Correct wrong function signature
th0mas Sep 15, 2020
efa5031
Remove func signature - I think this is easier
th0mas Sep 15, 2020
5a7147c
Set functions to private
th0mas Sep 15, 2020
87f3834
Return an explicit error
th0mas Sep 15, 2020
b2b1540
Log error when role not found to alert dev of typo or role.name issue…
nelsonic Sep 16, 2020
3f55f0e
add function defintion for has_role?/2 String, List so Plug.Conn is n…
nelsonic Sep 16, 2020
1ea682c
bump version to 0.5.0 as new fn insert_roles_into_ets/1 added
nelsonic Sep 16, 2020
25ebaea
has_role?2 and has_role_any?2 now both accept List of Integers as fir…
nelsonic Sep 16, 2020
6988e2b
allow atom as role or roles in has_role/2 and has_role_any?2 respecti…
nelsonic Sep 16, 2020
3a902ea
Clarify independence from auth & auth_plug in README.md https://githu…
nelsonic Sep 16, 2020
ad51cc1
tidy @doc comments for consistency
nelsonic Sep 16, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ rbac-*.tar
*.beam
/config/*.secret.exs
.elixir_ls/
.env
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
language: elixir
elixir:
- 1.10.2
- 1.10.4
otp_release:
- 22.1.8
- 23.0.3
env:
- MIX_ENV=test
script:
Expand Down
139 changes: 132 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# `rbac`

Role Based Access Control (RBAC) gives you
Role Based Access Control (**`RBAC`**) gives you
a human-friendly way of controlling access
to specific data/features in your App(s).

Expand All @@ -20,7 +20,9 @@ to specific data/features in your App(s).

## Why?

RBAC lets you easily manage roles and permissions in any application
You want an _easy_ way to restrict access to features fo your Elixir/Phoenix App
based on a sane model of roles.
**`RBAC`** lets you _easily_ manage roles and permissions in any application
and see at a glance exactly which permissions a person has in the system.
It reduces complexity over traditional
Access Control List (ACL) based permissions systems.
Expand All @@ -29,7 +31,7 @@ Access Control List (ACL) based permissions systems.

## What?

The purpose of RBAC is to provide a framework
The purpose of **`RBAC`** is to provide a framework
for application administrators and developers
to manage the permissions assigned to the people using the App(s).

Expand All @@ -39,7 +41,7 @@ to manage the permissions assigned to the people using the App(s).

Anyone who is interested in developing secure applications
used by many people with differing needs and permissions
should learn about RBAC.
should learn about **`RBAC`**.


## _How_?
Expand All @@ -52,20 +54,143 @@ Install by adding `rbac` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:rbac, "~> 0.1.0"}
{:rbac, "~> 0.3.0"}
]
end
```

API/Function reference available at
[https://hexdocs.pm/rbac](https://hexdocs.pm/rbac).
### Setup

Open the `application.ex` file of your project
and locate the `def start(_type, _args) do` definition, e.g:

```elixir
def start(_type, _args) do
# List all child processes to be supervised
children = [
# Start the Ecto repository
Auth.Repo,
# Start the endpoint when the application starts
{Phoenix.PubSub, name: Auth.PubSub},
AuthWeb.Endpoint
# Starts a worker by calling: Auth.Worker.start_link(arg)
# {Auth.Worker, arg},
]

# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Auth.Supervisor]
Supervisor.start_link(children, opts)
end
```

Add the following code at the top of the function definition:

```elixir
# initialize RBAC Cache:
RBAC.init_roles_cache(
"https://dwylauth.herokuapp.com",
AuthPlug.Token.client_id()
nelsonic marked this conversation as resolved.
Show resolved Hide resolved
)
```


### Usage

Once you have added the initialization code,
you can easily check that a person has a required role
using the following code:

```elixir
RBAC.has_role?(conn, "admin")
> true
```

Or if you want to check that the person has has any role in a list of potential roles:

```elixir
RBAC.has_role_any?(conn, ["admin", "commenter"])
> true
```

We prefer to make our code as declarative and human-friendly as possible,
hence the `String` role names.
However both the role-checking functions also accept a list of integers,
corresponding to the `role.id` of the required role, e.g:

```elixir
RBAC.has_role?(conn, 2)
> true
```

If the person does not have the **`superadmin`** role,
`has_role?/2` will return `false`

```elixir
RBAC.has_role?(conn, 1)
> false
```

Or supply a list of integers to `has_role_any?/2` if you prefer:

```elixir
RBAC.has_role_any?(conn, [1,2,3])
> true
```

You can even _mix_ the type in the list:

```elixir
RBAC.has_role_any?(conn, ["admin",2,3])
> true
```

But we recommend picking one, and think advise using strings for code legibility.
e.g:

```elixir
RBAC.has_role?(conn, "building_admin")
```

Is very clear which role is required.
Whereas using an `int` (_especially for custom roles_) is a bit more terse:

```elixir
RBAC.has_role?(conn, 13)
```

It requires the developer/code reviewer/maintainer
to either know what the role is,
or look it up in a list.




API/Function reference available at
[https://hexdocs.pm/rbac](https://hexdocs.pm/rbac).

<!--
## Trouble Shooting

If your app does not have a valid `AUTH_API_KEY` you may see the following error:

```
Generated auth app
** (Mix) Could not start application auth: exited in: Auth.Application.start(:normal, [])
** (EXIT) an exception was raised:
** (Protocol.UndefinedError) protocol Enumerable not implemented for "Internal Server Error" of type BitString. This protocol is implemented for the following type(s): Ecto.Adapters.SQL.Stream, Postgrex.Stream, DBConnection.PrepareStream, DBConnection.Stream, StreamData, IO.Stream, Map, Date.Range, List, GenEvent.Stream, HashSet, MapSet, Range, HashDict, Function, Stream, File.Stream
(elixir 1.10.4) lib/enum.ex:1: Enumerable.impl_for!/1
(elixir 1.10.4) lib/enum.ex:141: Enumerable.reduce/3
(elixir 1.10.4) lib/enum.ex:3383: Enum.map/2
(rbac 0.4.0) lib/rbac.ex:69: RBAC.parse_body_response/1
(rbac 0.4.0) lib/rbac.ex:88: RBAC.init_roles_cache/2
(auth 1.2.4) lib/auth/application.ex:9: Auth.Application.start/2
(kernel 7.0) application_master.erl:277: :application_master.start_it_old/4
The command "mix ecto.setup" failed and exited with 1 during .
```

Simply follow the instructions to get your `AUTH_API_KEY` and export it as an environment variable.
-->

<br /><br />

Expand Down
113 changes: 113 additions & 0 deletions lib/rbac.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,117 @@ defmodule RBAC do
def transform_role_list_to_string(roles) do
[Map.delete(roles, :__meta__)] |> transform_role_list_to_string()
end

@doc """
`get_approles/2` fetches the roles for the app
"""
def get_approles(auth_url, client_id) do
url = "#{auth_url}/approles/#{client_id}"
HTTPoison.start()
nelsonic marked this conversation as resolved.
Show resolved Hide resolved

HTTPoison.get(url)
|> parse_body_response()
end

@doc """
`parse_body_response/1` parses the response
so your app can use the resulting JSON (list of roles).
"""
@spec parse_body_response({atom, String.t()}) :: String.t()
def parse_body_response({:error, err}), do: {:error, err}
nelsonic marked this conversation as resolved.
Show resolved Hide resolved

def parse_body_response({:ok, response}) do
body = Map.get(response, :body)
# IO.inspect(body)
# make keys of map atoms for easier access in templates
if body == nil do
{:error, :no_body}
else
{:ok, str_key_map} = Jason.decode(body)

atom_key_map =
Enum.map(str_key_map, fn role ->
for {key, val} <- role, into: %{}, do: {String.to_atom(key), val}
end)

{:ok, atom_key_map}
end

# https://stackoverflow.com/questions/31990134
end

@doc """
`init_roles/2 fetches the list of roles for an app
from the auth app (auth_url) based on the client_id
and caches the list for fast access.
ETS is an in-memory cache you get for *Free* in Elixir/Erlang.
See: https://elixir-lang.org/getting-started/mix-otp/ets.html
and: https://elixirschool.com/en/lessons/specifics/ets
"""
def init_roles_cache(auth_url, client_id) do
{:ok, roles} = RBAC.get_approles(auth_url, client_id)
:ets.new(:roles_cache, [:set, :protected, :named_table])
# insert full list:
:ets.insert(:roles_cache, {"roles", roles})
# insert individual roles for fast lookup:
Enum.each(roles, fn role ->
:ets.insert(:roles_cache, {role.name, role})
:ets.insert(:roles_cache, {role.id, role})
end)
end

@doc """
`get_role_from_cache/1 retrieves a role from ets cache
"""
def get_role_from_cache(term) do
case :ets.lookup(:roles_cache, term) do
# not found:
[] -> %{id: 0}
nelsonic marked this conversation as resolved.
Show resolved Hide resolved
# extract role:
[{_term, role}] -> role
end
end

@doc """
`has_role?/2 confirms if the person has the given role
e.g:
has_role?(conn, "home_admin") > true
has_role?(conn, "potus") > false
"""
def has_role?(conn, role_name) do
role = get_role_from_cache(role_name)

person_roles =
conn.assigns.person.roles
nelsonic marked this conversation as resolved.
Show resolved Hide resolved
|> String.split(",", trim: true)
|> Enum.map(&String.to_integer/1)

Enum.member?(person_roles, role.id)
end

@doc """
`has_role_any/2 checks if the person has any one (or more)
of the roles listed. Allows multiple roles to access content.
e.g:
has_role_any?(conn, ["home_admin", "building_owner") > true
has_role_any?(conn, ["potus", "el_presidente") > false
"""
def has_role_any?(conn, roles_list) do
list_ids = Enum.map(roles_list, fn role ->
r = get_role_from_cache(role)
r.id
end)

# list of integers
person_roles =
nelsonic marked this conversation as resolved.
Show resolved Hide resolved
conn.assigns.person.roles
|> String.split(",", trim: true)
|> Enum.map(&String.to_integer/1)

# find the first occurence of a role by id:
found = Enum.find(person_roles, fn rid ->
Enum.member?(list_ids, rid)
end)
not is_nil(found)
end
end
11 changes: 10 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Rbac.MixProject do
def project do
[
app: :rbac,
version: "0.3.0",
version: "0.4.0",
elixir: "~> 1.10",
start_permanent: Mix.env() == :prod,
deps: deps(),
Expand All @@ -30,9 +30,18 @@ defmodule Rbac.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# Httpoison for HTTP Requests: hex.pm/packages/httpoison
{:httpoison, "~> 1.7.0"},

# Decoding JSON data: https://hex.pm/packages/jason
{:jason, "~> 1.2.2"},

# Check test coverage
{:excoveralls, "~> 0.13.1", only: :test},

# auth_plug for client_id/1 in testsing: hex.pm/packages/auth_plug
{:auth_plug, "~> 1.2", only: [:dev, :test]},

# Create Documentation for publishing Hex.docs:
{:ex_doc, "~> 0.22.2", only: :dev},
{:credo, "~> 1.4", only: [:dev], runtime: false}
Expand Down
10 changes: 9 additions & 1 deletion mix.lock
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
%{
"auth_plug": {:hex, :auth_plug, "1.2.1", "7b8af3bc119452b0da01e1f9c848d17ce62e96893c5c3c1b4eb36f3fc986c990", [:mix], [{:joken, "~> 2.2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6004562a15294f36df3fd844d1c63b776807d5c1509faa9be02d5f8e8bb123d9"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"},
"credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"},
"earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"},
"ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"},
"excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"},
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
"httpoison": {:hex, :httpoison, "1.7.0", "abba7d086233c2d8574726227b6c2c4f6e53c4deae7fe5f6de531162ce9929a0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "975cc87c845a103d3d1ea1ccfd68a2700c211a434d8428b10c323dc95dc5b980"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
"jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"},
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
"makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"},
}
Loading