Skip to content

Commit

Permalink
add audit_log
Browse files Browse the repository at this point in the history
  • Loading branch information
thehaigo committed Aug 3, 2021
1 parent 13a72f2 commit 11fc5d7
Show file tree
Hide file tree
Showing 22 changed files with 554 additions and 100 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -32,3 +32,4 @@ npm-debug.log
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
.DS_Store
60 changes: 46 additions & 14 deletions lib/bytepacked/accounts.ex
Expand Up @@ -4,7 +4,7 @@ defmodule Bytepacked.Accounts do
"""

import Ecto.Query, warn: false
alias Bytepacked.Repo
alias Bytepacked.{AuditLog, Repo}
alias Bytepacked.Accounts.{User, UserToken, UserNotifier}

## Database getters
Expand Down Expand Up @@ -73,10 +73,19 @@ defmodule Bytepacked.Accounts do
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
def register_user(attrs, audit_context) do
user_changeset = User.registration_changeset(%User{}, attrs)

Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> AuditLog.multi(audit_context, "accounts.register_user", fn context, %{user: user} ->
%{context | user: user, params: %{email: user.email}}
end)
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end

@doc """
Expand Down Expand Up @@ -133,24 +142,28 @@ defmodule Bytepacked.Accounts do
If the token matches, the user email is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
def update_user_email(user, token) do
def update_user_email(user, token, audit_context) do
context = "change:#{user.email}"

with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context, audit_context)) do
:ok
else
_ -> :error
end
end

defp user_email_multi(user, email, context) do
defp user_email_multi(user, email, context, audit_context) do
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()

Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
|> AuditLog.multi(audit_context, "accounts.update_email.finish", %{
user_id: user.id,
email: email
})
end

@doc """
Expand All @@ -162,11 +175,23 @@ defmodule Bytepacked.Accounts do
{:ok, %{to: ..., body: ...}}
"""
def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
def deliver_update_email_instructions(
%User{} = user,
current_email,
update_email_url_fun,
audit_context
)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

Repo.insert!(user_token)
{:ok, _} =
Ecto.Multi.new()
|> AuditLog.multi(audit_context, "accounts.update_email.init", %{
user_id: user.id,
email: user.email
})
|> Ecto.Multi.insert(:user_token, user_token)
|> Repo.transaction()
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end

Expand Down Expand Up @@ -195,7 +220,7 @@ defmodule Bytepacked.Accounts do
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
def update_user_password(user, password, attrs, audit_context) do
changeset =
user
|> User.password_changeset(attrs)
Expand All @@ -204,6 +229,7 @@ defmodule Bytepacked.Accounts do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> AuditLog.multi(audit_context, "accounts.update_password", %{user_id: user.id})
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
Expand Down Expand Up @@ -296,10 +322,15 @@ defmodule Bytepacked.Accounts do
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun, audit_context)
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
Repo.insert!(user_token)
{:ok, _} =
Ecto.Multi.new()
|> AuditLog.multi(audit_context, "accounts.reset_password.init", %{user_id: user.id})
|> Ecto.Multi.insert(:user_token, user_token)
|> Repo.transaction()

UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
end

Expand Down Expand Up @@ -336,10 +367,11 @@ defmodule Bytepacked.Accounts do
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
def reset_user_password(user, attrs, audit_context) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> AuditLog.multi(audit_context, "accounts.reset_password.finish", %{user_id: user.id})
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
Expand Down
140 changes: 140 additions & 0 deletions lib/bytepacked/audit_log.ex
@@ -0,0 +1,140 @@
defmodule Bytepacked.AuditLog do
@moduledoc """
The audit log struct.
The audit log has `audit_context`, `action`, and `params`.
If `audit_context` contains a user, their email address will be
automatically written to the log as `:user_email`. After building
the log, the params are automatically validated according to the
@params module attribute.
"""

use Bytepacked.Schema
import Ecto.Query

alias Bytepacked.{Accounts, Repo}

defmodule InvalidParameterError do
defexception [:message]
end

@foreign_key_type :binary_id
schema "audit_logs" do
field :action, :string
field :ip_address, Bytepacked.Extensions.Ecto.IPAddress
field :user_agent, :string
field :user_email, :string
field :params, :map, default: %{}

belongs_to :user, Accounts.User

timestamps(updated_at: false)
end

# Listing of all known actions and their parameters
@params %{
"accounts.login" => ~w(email),
"accounts.register_user" => ~w(email),
"accounts.reset_password.init" => ~w(user_id),
"accounts.reset_password.finish" => ~w(user_id),
"accounts.update_email.init" => ~w(user_id email),
"accounts.update_email.finish" => ~w(user_id email),
"accounts.update_password" => ~w(user_id),
}

@doc """
Returns a system audit log
"""
def system() do
%__MODULE__{ user: nil }
end
@doc """
Creats an audit log.
"""
def audit!(audit_context, action, params) do
Repo.insert!(build!(audit_context, action, params))
end

@doc """
Adds an audit log to the `multi`.
It can receive a function or parameters as the 4th argument.
In case it receives a function, it will have an `audit_context`
and Ecto multi results so far, and it must return an (possibly
updated) audit log.
The parameters needs to be passed directly into `audit_context`
inside the function.
In case it receives a map with the parameters, those parameters
will be validated in a lazy way, inside a Multi.run/3 function.
It may raise an exception in case of invalid params.
"""
def multi(multi, audit_context, action, fun) when is_function(fun, 2) do
Ecto.Multi.run(multi, :audit, fn repo, results ->
audit_log = build!(fun.(audit_context, results), action, %{})
{:ok, repo.insert!(audit_log)}
end)
end

def multi(multi, audit_context, action, params) when is_map(params) do
Ecto.Multi.insert(multi, :audit, fn _ ->
build!(audit_context, action, params)
end)
end

@doc """
Lists audits for `user`.
"""
def list_by_user(%Accounts.User{} = user, clauses \\ []) do
Repo.all(from(__MODULE__, where: [user_id: ^user.id], where: ^clauses, order_by: [asc: :id]))
end

def list_all_from_system(clauses \\ []) do
Repo.all(
from(
a in __MODULE__,
where: is_nil(a.user_id),
where: ^clauses,
order_by: [asc: :id]
)
)
end

## Building
defp build!(%__MODULE__{} = audit_context, action, params)
when is_binary(action) and is_map(params) do
%{audit_context | action: action, params: Map.merge(audit_context.params, params)}
|> Map.replace(:user_email, audit_context.user && audit_context.user.email)
|> validate_params!()
end

defp validate_params!(struct) do
action = struct.action
params = struct.params

expected_keys = Map.fetch!(@params, action)

actual_keys =
params
|> Map.keys()
|> Enum.map(&to_string/1)

case {expected_keys -- actual_keys, actual_keys -- expected_keys} do
{[], []} ->
:ok

{_, [_ | _] = extra_keys} ->
raise InvalidParameterError,
"extra keys #{inspect(extra_keys)} for action #{action} in #{inspect(params)}"

{missing_keys, _} ->
raise InvalidParameterError,
"missing keys #{inspect(missing_keys)} for action #{action} in #{inspect(params)}"
end

struct
end
end
30 changes: 30 additions & 0 deletions lib/bytepacked/extensions/ecto/ip_address.ex
@@ -0,0 +1,30 @@
defmodule Bytepacked.Extensions.Ecto.IPAddress do
use Ecto.Type

@impl true
def type(), do: :inet

@impl true
def cast(string) when is_binary(string) do
parts = String.split(string, ".")

case Enum.map(parts, &Integer.parse/1) do
[{a, ""}, {b, ""}, {c, ""}, {d, ""}]
when a in 0..255 and b in 0..255 and c in 0..255 and d in 0..255 ->
{:ok, {a,b,c,d}}

_ ->
:error
end
end

def cast(_), do: :error

@impl true
def dump({_, _, _, _} = address), do: {:ok, %Postgrex.INET{address: address}}
def dumb(_), do: :error

@impl true
def load(%Postgrex.INET{} = struct), do: {:ok, struct.address}
def load(_), do: :error
end
8 changes: 8 additions & 0 deletions lib/bytepacked/schema.ex
@@ -0,0 +1,8 @@
defmodule Bytepacked.Schema do
defmacro __using__(_) do
quote do
use Ecto.Schema
@timestamps_opts [type: :utc_datetime_usec]
end
end
end
2 changes: 1 addition & 1 deletion lib/bytepacked_web/controllers/user_auth.ex
Expand Up @@ -145,5 +145,5 @@ defmodule BytepackedWeb.UserAuth do

defp maybe_store_return_to(conn), do: conn

defp signed_in_path(_conn), do: "/dashboard"
defp signed_in_path(conn), do: Routes.dashboard_index_path(conn, :index)
end
Expand Up @@ -22,7 +22,7 @@ defmodule BytepackedWeb.UserConfirmationController do
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
|> redirect(to: "/")
|> redirect(to: Routes.user_session_path(conn, :new))
end

# Do not log in the user after confirmation to avoid a
Expand All @@ -32,7 +32,7 @@ defmodule BytepackedWeb.UserConfirmationController do
{:ok, _} ->
conn
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: "/")
|> redirect(to: Routes.user_session_path(conn, :new))

:error ->
# If there is a current user and the account was already confirmed,
Expand All @@ -41,12 +41,12 @@ defmodule BytepackedWeb.UserConfirmationController do
# a warning message.
case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: "/")
redirect(conn, to: Routes.dashboard_index_path(conn, :index))

%{} ->
conn
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: "/")
|> redirect(to: Routes.user_session_path(conn, :new))
end
end
end
Expand Down
Expand Up @@ -11,7 +11,7 @@ defmodule BytepackedWeb.UserRegistrationController do
end

def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
case Accounts.register_user(user_params, conn.assigns.audit_context) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
Expand Down

0 comments on commit 11fc5d7

Please sign in to comment.