Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ defmodule MyPhoenixAppWeb.Router do
end
```

## Audit Logging

When [audit logging](https://github.com/tompave/fun_with_flags#audit-logging) is configured in `fun_with_flags`, the web dashboard automatically records all flag changes with the user who made them.

The user identifier is extracted from an HTTP request header (configurable, defaults to `x-user-id`). Your host application or reverse proxy should set this header based on the authenticated user.

```elixir
config :fun_with_flags, :audit_logs,
repo: MyApp.Repo,
user_id_header: "X-User-Id"
```

No additional configuration is needed in the UI — it reads the audit log settings from the core `fun_with_flags` configuration.

## Caveats

While the base `fun_with_flags` library is quite relaxed in terms of valid flag names, group names and actor identifers, this web dashboard extension applies some more restrictive rules.
Expand Down
109 changes: 109 additions & 0 deletions lib/fun_with_flags/ui/audit_log_formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
defmodule FunWithFlags.UI.AuditLogFormatter do
@moduledoc false

import FunWithFlags.UI.HTMLEscape, only: [html_escape: 1]

@doc """
Returns a human-friendly HTML description of an audit log record's data map.
Handles both atom-keyed and string-keyed maps.
"""
def describe(%{data: data}) when is_map(data) do
describe_data(normalize_keys(data))
end

def describe(_), do: "Unknown action"

defp normalize_keys(map) when is_map(map) do
Map.new(map, fn
{k, v} when is_atom(k) -> {Atom.to_string(k), normalize_keys(v)}
{k, v} -> {k, normalize_keys(v)}
end)
end

defp normalize_keys(other), do: other

defp describe_data(%{"action" => "enable", "gate" => gate}) do
describe_gate_action("Enabled", gate)
end

defp describe_data(%{"action" => "disable", "gate" => gate}) do
describe_gate_action("Disabled", gate)
end

defp describe_data(%{"action" => "clear_flag"}) do
"Cleared all gates"
end

defp describe_data(%{"action" => "clear_gate", "gate" => gate}) do
describe_gate_action("Cleared", gate)
end

defp describe_data(%{"action" => "export"}) do
"Exported all flags"
end

defp describe_data(%{"action" => "import", "operation_metadata" => meta}) do
count = Map.get(meta, "flag_count", "?")
mode = meta |> Map.get("mode", "") |> humanize_mode()
"Bulk imported #{count} flags (#{mode})"
end

defp describe_data(%{"action" => "import"}) do
"Bulk imported flags"
end

defp describe_data(%{"action" => action}) do
"#{String.capitalize(to_string(action))}"
end

defp describe_data(_), do: "Unknown action"

defp describe_gate_action(verb, %{"type" => "boolean"}) do
"#{verb} boolean gate"
end

defp describe_gate_action(verb, %{"type" => "actor", "target" => target}) do
"#{verb} actor gate for #{html_escape(target)}"
end

defp describe_gate_action(verb, %{"type" => "group", "target" => target}) do
"#{verb} group gate for #{html_escape(target)}"
end

defp describe_gate_action(verb, %{"type" => "percentage_of_time", "target" => target}) do
"#{verb} percentage of time gate (#{format_percentage(target)})"
end

defp describe_gate_action(verb, %{"type" => "percentage_of_actors", "target" => target}) do
"#{verb} percentage of actors gate (#{format_percentage(target)})"
end

defp describe_gate_action(verb, %{"type" => type}) do
"#{verb} #{humanize_gate_type(type)} gate"
end

defp describe_gate_action(verb, _) do
"#{verb} gate"
end

defp format_percentage(val) when is_binary(val) do
case Float.parse(val) do
{f, _} -> "#{round(f * 100)}%"
:error -> val
end
end

defp format_percentage(val) when is_float(val), do: "#{round(val * 100)}%"
defp format_percentage(val) when is_integer(val), do: "#{val}%"
defp format_percentage(val), do: to_string(val)

defp humanize_gate_type(type) do
type
|> to_string()
|> String.replace("_", " ")
end

defp humanize_mode("clear_and_import"), do: "clear and import"
defp humanize_mode("import_and_overwrite"), do: "import and overwrite"
defp humanize_mode(mode), do: to_string(mode)
end
128 changes: 108 additions & 20 deletions lib/fun_with_flags/ui/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ defmodule FunWithFlags.UI.Router do
System.get_env("APP_ENV") != "dev"
end

defp get_audit_user_id(conn) do
header = FunWithFlags.Config.audit_log_user_id_header()
case Plug.Conn.get_req_header(conn, header) do
[user_id | _] -> user_id
_ -> nil
end
end

defp audit_opts(conn) do
case get_audit_user_id(conn) do
nil -> []
user_id -> [audit: [user_id: user_id]]
end
end

@doc false
def call(conn, opts) do
conn = extract_namespace(conn, opts)
Expand Down Expand Up @@ -60,7 +75,7 @@ defmodule FunWithFlags.UI.Router do

case Utils.validate_flag_name(conn, name) do
:ok ->
case Utils.create_flag_with_name(name) do
case Utils.create_flag_with_name(name, audit_opts(conn)) do
{:ok, _} -> redirect_to conn, "/flags/#{name}"
_ -> html_resp(conn, 400, Templates.new(%{conn: conn, error_message: "Something went wrong!"}))
end
Expand All @@ -87,7 +102,8 @@ defmodule FunWithFlags.UI.Router do
get "/flags/:name" do
case Utils.get_flag(name) do
{:ok, flag} ->
body = Templates.details(conn: conn, flag: flag)
audit_assigns = fetch_flag_audit_logs(conn, name, conn.query_params)
body = Templates.details([conn: conn, flag: flag] ++ audit_assigns)
html_resp(conn, 200, body)
{:error, _} ->
body = Templates.not_found(conn: conn, name: name)
Expand All @@ -101,7 +117,7 @@ defmodule FunWithFlags.UI.Router do
delete "/flags/:name" do
name
|> String.to_existing_atom()
|> FunWithFlags.clear()
|> FunWithFlags.clear(audit_opts(conn))

redirect_to conn, "/flags"
end
Expand All @@ -114,9 +130,9 @@ defmodule FunWithFlags.UI.Router do
flag_name = String.to_existing_atom(name)

if enabled do
FunWithFlags.enable(flag_name)
FunWithFlags.enable(flag_name, audit_opts(conn))
else
FunWithFlags.disable(flag_name)
FunWithFlags.disable(flag_name, audit_opts(conn))
end

redirect_to conn, "/flags/#{name}"
Expand All @@ -127,7 +143,7 @@ defmodule FunWithFlags.UI.Router do
#
delete "/flags/:name/boolean" do
flag_name = String.to_existing_atom(name)
FunWithFlags.clear(flag_name, boolean: true)
FunWithFlags.clear(flag_name, [boolean: true] ++ audit_opts(conn))
redirect_to conn, "/flags/#{name}"
end

Expand All @@ -140,9 +156,9 @@ defmodule FunWithFlags.UI.Router do
actor = %SimpleActor{id: actor_id}

if enabled do
FunWithFlags.enable(flag_name, for_actor: actor)
FunWithFlags.enable(flag_name, [for_actor: actor] ++ audit_opts(conn))
else
FunWithFlags.disable(flag_name, for_actor: actor)
FunWithFlags.disable(flag_name, [for_actor: actor] ++ audit_opts(conn))
end

redirect_to conn, "/flags/#{name}#actor_#{actor_id}"
Expand All @@ -155,7 +171,7 @@ defmodule FunWithFlags.UI.Router do
flag_name = String.to_existing_atom(name)
actor = %SimpleActor{id: actor_id}

FunWithFlags.clear(flag_name, for_actor: actor)
FunWithFlags.clear(flag_name, [for_actor: actor] ++ audit_opts(conn))
redirect_to conn, "/flags/#{name}#actor_gates"
end

Expand All @@ -168,9 +184,9 @@ defmodule FunWithFlags.UI.Router do
group_name = to_string(group_name)

if enabled do
FunWithFlags.enable(flag_name, for_group: group_name)
FunWithFlags.enable(flag_name, [for_group: group_name] ++ audit_opts(conn))
else
FunWithFlags.disable(flag_name, for_group: group_name)
FunWithFlags.disable(flag_name, [for_group: group_name] ++ audit_opts(conn))
end

redirect_to conn, "/flags/#{name}#group_#{group_name}"
Expand All @@ -183,7 +199,7 @@ defmodule FunWithFlags.UI.Router do
flag_name = String.to_existing_atom(name)
group_name = to_string(group_name)

FunWithFlags.clear(flag_name, for_group: group_name)
FunWithFlags.clear(flag_name, [for_group: group_name] ++ audit_opts(conn))

redirect_to conn, "/flags/#{name}#group_gates"
end
Expand All @@ -193,7 +209,7 @@ defmodule FunWithFlags.UI.Router do
#
delete "/flags/:name/percentage" do
flag_name = String.to_existing_atom(name)
FunWithFlags.clear(flag_name, for_percentage: true)
FunWithFlags.clear(flag_name, [for_percentage: true] ++ audit_opts(conn))
redirect_to conn, "/flags/#{name}"
end

Expand All @@ -209,9 +225,9 @@ defmodule FunWithFlags.UI.Router do
enabled = Utils.parse_bool(conn.params["enabled"])
actor = %SimpleActor{id: actor_id}
if enabled do
FunWithFlags.enable(flag_name, for_actor: actor)
FunWithFlags.enable(flag_name, [for_actor: actor] ++ audit_opts(conn))
else
FunWithFlags.disable(flag_name, for_actor: actor)
FunWithFlags.disable(flag_name, [for_actor: actor] ++ audit_opts(conn))
end
redirect_to conn, "/flags/#{name}#actor_#{actor_id}"
{:fail, reason} ->
Expand All @@ -232,9 +248,9 @@ defmodule FunWithFlags.UI.Router do
:ok ->
enabled = Utils.parse_bool(conn.params["enabled"])
if enabled do
FunWithFlags.enable(flag_name, for_group: group_name)
FunWithFlags.enable(flag_name, [for_group: group_name] ++ audit_opts(conn))
else
FunWithFlags.disable(flag_name, for_group: group_name)
FunWithFlags.disable(flag_name, [for_group: group_name] ++ audit_opts(conn))
end
redirect_to conn, "/flags/#{name}#group_#{group_name}"
{:fail, reason} ->
Expand All @@ -253,7 +269,7 @@ defmodule FunWithFlags.UI.Router do

case Utils.parse_and_validate_float(conn.params["percent_value"]) do
{:ok, float} ->
FunWithFlags.enable(flag_name, for_percentage_of: {type, float})
FunWithFlags.enable(flag_name, [for_percentage_of: {type, float}] ++ audit_opts(conn))
redirect_to conn, "/flags/#{name}#percentage_gate"
{:fail, reason} ->
{:ok, flag} = Utils.get_flag(name)
Expand All @@ -263,6 +279,37 @@ defmodule FunWithFlags.UI.Router do
end


# Audit logs page
#
get "/audit_logs" do
if audit_log_viewing_available?() do
flag_name = Map.get(conn.query_params, "flag_name")
page = parse_page(Map.get(conn.query_params, "page"))

opts = [page: page, per_page: 25]
opts = if flag_name && flag_name != "", do: Keyword.put(opts, :flag_name, flag_name), else: opts

case FunWithFlags.audit_log_entries(opts) do
{:ok, result} ->
assigns = %{
conn: conn,
audit_records: result.records,
audit_page: result.page,
audit_total_pages: result.total_pages,
audit_total: result.total,
search_flag_name: flag_name
}
html_resp(conn, 200, Templates.audit_logs(assigns))

{:error, _} ->
html_resp(conn, 200, Templates.audit_logs(%{conn: conn, audit_disabled: true}))
end
else
html_resp(conn, 200, Templates.audit_logs(%{conn: conn, audit_disabled: true}))
end
end


# Settings page
#
get "/settings" do
Expand All @@ -281,7 +328,7 @@ defmodule FunWithFlags.UI.Router do
# Export flags
#
post "/settings/export" do
case FunWithFlags.export_flags() do
case FunWithFlags.export_flags(user_id: get_audit_user_id(conn)) do
{:ok, binary} ->
timestamp = Calendar.strftime(DateTime.utc_now(), "%Y-%m-%d_%H-%M-%S")
filename = "flags_export_#{System.get_env("APP_NAME") || "unknown_app"}_#{System.get_env("APP_ENV") || "unknown_env"}_#{timestamp}.etf"
Expand Down Expand Up @@ -376,7 +423,7 @@ defmodule FunWithFlags.UI.Router do
{:ok, binary} ->
mode = parse_import_mode(mode_str)

case FunWithFlags.import_flags(binary, mode) do
case FunWithFlags.import_flags(binary, mode, user_id: get_audit_user_id(conn)) do
{:ok, count} ->
redirect_to conn, "/settings?success=imported_#{count}"

Expand Down Expand Up @@ -419,4 +466,45 @@ defmodule FunWithFlags.UI.Router do

defp parse_success_message("imported_" <> count), do: "Successfully imported #{count} flags"
defp parse_success_message(_), do: nil


defp audit_log_viewing_available? do
Code.ensure_loaded?(FunWithFlags.AuditLog) and
function_exported?(FunWithFlags.AuditLog, :list, 1)
end


defp parse_page(nil), do: 1
defp parse_page(str) when is_binary(str) do
case Integer.parse(str) do
{n, _} when n > 0 -> n
_ -> 1
end
end
defp parse_page(_), do: 1


defp fetch_flag_audit_logs(conn, flag_name, query_params) do
if audit_log_viewing_available?() do
page = parse_page(Map.get(query_params, "audit_page"))

case FunWithFlags.audit_log_entries_for_flag(flag_name, page: page, per_page: 10) do
{:ok, result} ->
[
audit_records: result.records,
audit_page: result.page,
audit_total_pages: result.total_pages,
audit_total: result.total,
page_param: "audit_page",
pagination_base_path: Templates.path(conn, "/flags/#{Templates.url_safe(flag_name)}"),
hide_flag_column: true
]

{:error, _} ->
[audit_disabled: true]
end
else
[audit_disabled: true]
end
end
end
Loading