Skip to content

Commit

Permalink
Merge pull request #43 from epochtalk/role-cache
Browse files Browse the repository at this point in the history
Role cache
  • Loading branch information
akinsey authored May 23, 2023
2 parents 37169eb + 82d264b commit fff9476
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 47 deletions.
2 changes: 2 additions & 0 deletions lib/epochtalk_server/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ defmodule EpochtalkServer.Application do
{Redix, host: redix_config()[:host], name: redix_config()[:name]},
# Start the Ecto repository
EpochtalkServer.Repo,
# Start Role Cache
EpochtalkServer.Cache.Role,
# Warm frontend_config variable (referenced by api controllers)
# This task starts, does its thing and dies
{Task, &EpochtalkServer.Models.Configuration.warm_frontend_config/0},
Expand Down
90 changes: 90 additions & 0 deletions lib/epochtalk_server/cache/role.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
defmodule EpochtalkServer.Cache.Role do
use GenServer
use Ecto.Schema
alias EpochtalkServer.Models.Role

@moduledoc """
`Role` cache genserver, stores roles in memory for quick lookup
"""

## === genserver functions ====

@impl true
def init(:ok), do: {:ok, load()}

@impl true
def handle_call(:all, _from, {all_roles, lookup_cache}),
do: {:reply, all_roles, {all_roles, lookup_cache}}

@impl true
def handle_call({:lookup, lookups}, _from, {all_roles, lookup_cache}) when is_list(lookups) do
roles =
Map.take(lookup_cache, lookups)
|> Enum.map(fn {_k, v} -> v end)
|> Enum.sort(&(&1.id < &2.id))

{:reply, roles, {all_roles, lookup_cache}}
end

@impl true
def handle_call({:lookup, lookup}, _from, {all_roles, lookup_cache}) do
role = Map.get(lookup_cache, lookup)
{:reply, role, {all_roles, lookup_cache}}
end

@impl true
def handle_cast(:reload, {_all_roles, _lookup_cache}), do: {:noreply, load()}

## === cache api functions ====

@doc """
Start genserver and create a reference for supervision tree
"""
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end

@doc """
Returns all `Role`s
"""
@spec all() :: [Role.t()]
def all() do
GenServer.call(__MODULE__, :all)
end

@doc """
Returns a `Role` or list of `Role`s for specified lookup or list of lookups
"""
@spec by_lookup(lookup_or_lookups :: String.t() | [String.t()]) ::
Role.t() | [Role.t()] | [] | nil
def by_lookup(lookup_or_lookups) do
GenServer.call(__MODULE__, {:lookup, lookup_or_lookups})
end

@doc """
Reloads role cache with latest role configurations
Non-blocking; does not return anything
"""
@spec reload() :: no_return
def reload() do
GenServer.cast(__MODULE__, :reload)
end

## === private functions ====

# returns loaded role cache
defp load() do
all_roles = Role.all_repo()

lookup_cache = map_by_keyname(all_roles, :lookup)

{all_roles, lookup_cache}
end

defp map_by_keyname(roles, keyname) do
roles
|> Enum.reduce(%{}, fn role, lookup_cache ->
lookup_cache |> Map.put(Map.get(role, keyname), role)
end)
end
end
2 changes: 1 addition & 1 deletion lib/epochtalk_server/models/configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ defmodule EpochtalkServer.Models.Configuration do

# TODO(boka): directory release version

# tag takes precidence over revision
# tag takes precedence over revision
revision = if tag == "", do: hash, else: tag

frontend_config =
Expand Down
105 changes: 67 additions & 38 deletions lib/epochtalk_server/models/role.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule EpochtalkServer.Models.Role do
alias EpochtalkServer.Repo
alias EpochtalkServer.Models.User
alias EpochtalkServer.Models.Role
alias EpochtalkServer.Models.RoleUser
alias EpochtalkServer.Cache.Role, as: RoleCache

@postgres_integer_max 2_147_483_647
@postgres_varchar255_max 255
Expand Down Expand Up @@ -101,23 +101,28 @@ defmodule EpochtalkServer.Models.Role do

@doc """
Returns every `Role` record in the database
WARNING: Only use for startup/seeding; use Role.all elsewhere
"""
@spec all() :: [t()] | []
def all, do: from(r in Role, order_by: r.id) |> Repo.all()
@spec all_repo() :: [t()] | []
def all_repo, do: from(r in Role, order_by: r.id) |> Repo.all()

@doc """
Uses role cache to returns every `Role` record
"""
@spec all() :: [t()]
def all(), do: RoleCache.all()

@doc """
Returns id for the `banned` `Role`
"""
@spec get_banned_role_id() :: integer | nil
def get_banned_role_id(),
do: Repo.one(from(r in Role, select: r.id, where: r.lookup == "banned"))
def get_banned_role_id(), do: RoleCache.by_lookup("banned").id

@doc """
Returns id for the `newbie` `Role`
"""
@spec get_newbie_role_id() :: integer | nil
def get_newbie_role_id(),
do: Repo.one(from(r in Role, select: r.id, where: r.lookup == "newbie"))
def get_newbie_role_id(), do: RoleCache.by_lookup("newbie").id

@doc """
Returns default `Role`, for base installation this is the `user` role, if `:epochtalk_server[:frontend_config]["newbie_enabled"]`
Expand All @@ -127,7 +132,7 @@ defmodule EpochtalkServer.Models.Role do
def get_default() do
config = Application.get_env(:epochtalk_server, :frontend_config)
newbie_enabled = config["newbie_enabled"]
by_lookup(if newbie_enabled, do: "newbie", else: "user")
RoleCache.by_lookup(if newbie_enabled, do: "newbie", else: "user")
end

@doc """
Expand All @@ -138,41 +143,44 @@ defmodule EpochtalkServer.Models.Role do
def get_default_unauthenticated() do
config = Application.get_env(:epochtalk_server, :frontend_config)
login_required = config["login_required"]
by_lookup(if login_required, do: "private", else: "anonymous")
RoleCache.by_lookup(if login_required, do: "private", else: "anonymous")
end

@doc """
Returns a `Role` or list of roles, for specified lookup(s)
Returns a `Role` for specified lookup
WARNING: Only used for startup/seeding; use Role.by_lookup elsewhere
"""
@spec by_lookup(lookup_or_lookups :: String.t() | [String.t()]) :: t() | [t()] | [] | nil
def by_lookup(lookups) when is_list(lookups) do
from(r in Role, where: r.lookup in ^lookups) |> Repo.all()
end

def by_lookup(lookup), do: Repo.get_by(Role, lookup: lookup)
@spec by_lookup_repo(lookup :: String.t() | [String.t()]) :: t() | nil
def by_lookup_repo(lookup), do: Repo.get_by(Role, lookup: lookup)

@doc """
Returns a list containing a user's roles
Uses role cache to return `Role` or list of `Role`s for specified lookup(s)
"""
@spec by_user_id(user_id :: integer) :: [t()]
def by_user_id(user_id) do
query =
from ru in RoleUser,
join: r in Role,
on: true,
where: ru.user_id == ^user_id and r.id == ru.role_id,
select: r,
order_by: [asc: r.priority]

case Repo.all(query) do
# user has no roles, return default role
[] -> [get_default()]
# user has roles, return them
users_roles -> users_roles
end
# if banned, only [ banned ] is returned for roles
|> Role.handle_banned_user_role()
end
@spec by_lookup(lookup_or_lookups :: String.t() | [String.t()]) :: t() | [t()] | [] | nil
def by_lookup(lookup_or_lookups), do: RoleCache.by_lookup(lookup_or_lookups)

# @doc """
# Returns a list containing a user's roles
# """
# @spec by_user_id(user_id :: integer) :: [t()]
# def by_user_id(user_id) do
# query =
# from ru in RoleUser,
# join: r in Role,
# on: true,
# where: ru.user_id == ^user_id and r.id == ru.role_id,
# select: r,
# order_by: [asc: r.priority]
#
# case Repo.all(query) do
# # user has no roles, return default role
# [] -> [get_default()]
# # user has roles, return them
# users_roles -> users_roles
# end
# # if banned, only [ banned ] is returned for roles
# |> Role.handle_banned_user_role()
# end

## CREATE OPERATIONS

Expand All @@ -189,7 +197,7 @@ defmodule EpochtalkServer.Models.Role do

## UPDATE OPERATIONS
@doc """
Updates an existing `Role` in the database
Updates an existing `Role` in the database and reloads role cache
"""
@spec update(attrs :: map()) ::
{:ok, role :: Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
Expand All @@ -198,10 +206,12 @@ defmodule EpochtalkServer.Models.Role do
|> Repo.get(attrs["id"])
|> update_changeset(attrs)
|> Repo.update()
|> reload_role_cache_on_success()
end

@doc """
Updates the permissions of an existing `Role` in the database
and reloads role cache
"""
@spec set_permissions(id :: integer, permissions_attrs :: map()) ::
{:ok, role :: Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
Expand All @@ -210,20 +220,27 @@ defmodule EpochtalkServer.Models.Role do
|> Repo.get(id)
|> change(%{permissions: permissions})
|> Repo.update()
|> reload_role_cache_on_success()
end

@doc """
Updates the priority_restrictions of an existing `Role` in the database
and reloads role cache
"""
@spec set_priority_restrictions(id :: integer, priority_restrictions :: list() | nil) ::
{:ok, role :: Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def set_priority_restrictions(id, []), do: set_priority_restrictions(id, nil)
def set_priority_restrictions(id, []) do
id
|> set_priority_restrictions(nil)
|> reload_role_cache_on_success()
end

def set_priority_restrictions(id, priority_restrictions) do
Role
|> Repo.get(id)
|> change(%{priority_restrictions: priority_restrictions})
|> Repo.update()
|> reload_role_cache_on_success()
end

## === External Helper Functions ===
Expand Down Expand Up @@ -278,6 +295,18 @@ defmodule EpochtalkServer.Models.Role do

## === Private Helper Functions ===

defp reload_role_cache_on_success(result) do
case result do
{:ok, role} ->
# reload cache on success
RoleCache.reload()
{:ok, role}

default ->
default
end
end

defp mask_permissions(target, source) do
merge_keys = [:highlight_color, :permissions, :priority, :priority_restrictions]
filtered_source_keys = Map.keys(source) |> Enum.filter(&Enum.member?(merge_keys, &1))
Expand Down
2 changes: 1 addition & 1 deletion lib/epochtalk_server/models/role_permission.ex
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ defmodule EpochtalkServer.Models.RolePermission do
def maybe_init!() do
if Repo.one(from rp in RolePermission, select: count(rp.value)) == 0,
do:
Enum.each(Role.all(), fn role ->
Enum.each(Role.all_repo(), fn role ->
Enum.each(Permission.all(), fn permission ->
%RolePermission{}
|> changeset(%{
Expand Down
1 change: 0 additions & 1 deletion lib/epochtalk_server/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ defmodule EpochtalkServer.Session do
username = Redix.command!(:redix, ["HGET", user_key, "username"])
avatar = Redix.command!(:redix, ["HGET", user_key, "avatar"])
role_key = generate_key(user_id, "roles")
# TODO(boka): store and look up roles from redis instead of postgres
roles = Redix.command!(:redix, ["SMEMBERS", role_key]) |> Role.by_lookup()

# session is active, populate data
Expand Down
2 changes: 0 additions & 2 deletions lib/epochtalk_server_web/controllers/user_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,6 @@ defmodule EpochtalkServerWeb.UserController do

@doc """
Logs out the logged in `User`
- TODO(boka): check if user is on page that requires auth
"""
def logout(conn, _attrs) do
with {:auth, true} <- {:auth, Guardian.Plug.authenticated?(conn)},
Expand Down
5 changes: 4 additions & 1 deletion lib/epochtalk_server_web/helpers/acl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ defmodule EpochtalkServerWeb.Helpers.ACL do
user_roles =
if user == nil,
do:
if(login_required, do: [Role.by_lookup("private")], else: [Role.by_lookup("anonymous")]),
if(login_required,
do: [Role.by_lookup("private")],
else: [Role.by_lookup("anonymous")]
),
else: user.roles

authed_permissions = Role.get_masked_permissions(user_roles)
Expand Down
10 changes: 7 additions & 3 deletions priv/repo/seed_roles_permissions.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ json_file = "#{__DIR__}/seeds/roles_permissions.json"
alias EpochtalkServer.Models.RolePermission
alias EpochtalkServer.Models.Role
alias EpochtalkServer.Repo
alias EpochtalkServer.Cache.Role, as: RoleCache

# helper function to create role permissions changeset before upsert
create_role_permission_changeset = fn({role_lookup, permissions}) ->
role = Role.by_lookup(role_lookup)
role = Role.by_lookup_repo(role_lookup)
permissions
|> Iteraptor.to_flatmap
|> Enum.map(fn {permission_path, value} ->
Expand All @@ -27,11 +28,14 @@ Repo.transaction(fn ->
|> Enum.each(&RolePermission.upsert_value(&1))

# generate permissions map for each role then update role
Role.all
Role.all_repo
|> Enum.map(fn role -> {role, RolePermission.permissions_map_by_role_id(role.id)} end)
|> Enum.each(fn {role, permissions} -> Role.set_permissions(role.id, permissions) end)
end)
|> case do
{:ok, _} -> IO.puts("Successfully Seeded Role Permissions")
{:ok, _} ->
# reload role cache after successful role permissions transaction
RoleCache.reload()
IO.puts("Successfully Seeded Role Permissions")
{:error, _} -> IO.puts("Error Seeding Role Permissions")
end
Loading

0 comments on commit fff9476

Please sign in to comment.