How to Monitor BodyGuard with Vigilmon
BodyGuard is the clean authorization library for Elixir and Phoenix. It delegates authorization decisions to policy modules, keeping business logic separate from access control. When a policy returns {:error, :unauthorized} for the wrong reason — a missing context, a misconfigured role, or a dependent service going down — users get locked out silently while your logs fill with noise.
External monitoring catches what your policy layer can't self-report. This tutorial covers:
- A health endpoint that validates BodyGuard policy evaluation
- HTTP uptime monitoring with Vigilmon
- Heartbeat monitoring for background authorization jobs
- Slack alerts and a status page
Step 1: Add BodyGuard to your project
# mix.exs
defp deps do
[
{:bodyguard, "~> 2.4"},
{:req, "~> 0.4"} # for heartbeat pings
]
end
Define a simple policy module:
# lib/my_app/policies/post_policy.ex
defmodule MyApp.Policies.PostPolicy do
@behaviour BodyGuard.Policy
def authorize(:create, user, _params) do
if user.role in [:editor, :admin], do: :ok, else: {:error, :unauthorized}
end
def authorize(:update, user, post) do
if post.author_id == user.id or user.role == :admin, do: :ok, else: {:error, :unauthorized}
end
def authorize(:delete, %{role: :admin}, _post), do: :ok
def authorize(:delete, _user, _post), do: {:error, :unauthorized}
def authorize(_action, _user, _params), do: {:error, :unauthorized}
end
Use it in your controllers:
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
def create(conn, params) do
with :ok <- BodyGuard.permit(MyApp.Policies.PostPolicy, :create, conn.assigns.current_user, params) do
# proceed with creation
end
end
end
Step 2: Build a health endpoint for BodyGuard
Since BodyGuard is pure Elixir with no external I/O, the health check validates that policy modules load correctly and that any external dependencies they consult (database, cache) are reachable:
# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
import Plug.Conn
require Logger
def init(opts), do: opts
def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
checks = run_checks()
status = if checks.status == "ok", do: 200, else: 503
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(checks))
|> halt()
end
def call(conn, _opts), do: conn
defp run_checks do
authz = authorization_check()
%{
status: if(authz.status == "ok", do: "ok", else: "degraded"),
authorization: authz
}
end
defp authorization_check do
start = System.monotonic_time(:millisecond)
# Probe each registered policy with a known test principal
probe_user = %{id: 0, role: :admin}
probe_resource = %{author_id: 0}
policies = Application.get_env(:my_app, :health_check_policies, [])
results =
Enum.map(policies, fn {policy_module, action} ->
case BodyGuard.permit(policy_module, action, probe_user, probe_resource) do
:ok ->
%{policy: inspect(policy_module), action: action, status: "ok"}
{:error, :unauthorized} ->
# Expected for non-admin actions with admin user is unusual — log a warning
Logger.warning("[BodyGuard] unexpected unauthorized for probe: #{inspect(policy_module)}.#{action}")
%{policy: inspect(policy_module), action: action, status: "unexpected_deny"}
{:error, reason} ->
%{policy: inspect(policy_module), action: action, status: "error", reason: inspect(reason)}
end
end)
elapsed = System.monotonic_time(:millisecond) - start
has_error = Enum.any?(results, &(&1.status == "error"))
%{
status: if(has_error, do: "error", else: "ok"),
evaluation_ms: elapsed,
policies: results
}
end
end
Configure which policies to probe:
# config/config.exs
config :my_app, :health_check_policies, [
{MyApp.Policies.PostPolicy, :create},
{MyApp.Policies.UserPolicy, :read}
]
Register the plug before the router:
# lib/my_app_web/endpoint.ex
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router
Test it:
mix phx.server
curl http://localhost:4000/health | jq .
# {
# "status": "ok",
# "authorization": {
# "status": "ok",
# "evaluation_ms": 1,
# "policies": [
# {"policy": "MyApp.Policies.PostPolicy", "action": "create", "status": "ok"}
# ]
# }
# }
Step 3: Track authorization failure rates
Aggregate authorization denials in your telemetry pipeline so you can alert on spikes:
# lib/my_app/telemetry.ex
defmodule MyApp.Telemetry do
require Logger
def setup do
:telemetry.attach(
"bodyguard-deny-logger",
[:bodyguard, :permit, :stop],
&handle_permit/4,
nil
)
end
def handle_permit(_event, _measurements, %{result: {:error, :unauthorized}, policy: policy, action: action}, _config) do
Logger.warning("[BodyGuard] denied #{inspect(policy)}.#{action}")
:counters.add(deny_counter(), 1, 1)
end
def handle_permit(_event, _measurements, _meta, _config), do: :ok
def deny_count, do: :counters.get(deny_counter(), 1)
defp deny_counter do
case :persistent_term.get({__MODULE__, :deny_counter}, nil) do
nil ->
ref = :counters.new(1, [:write_concurrency])
:persistent_term.put({__MODULE__, :deny_counter}, ref)
ref
ref ->
ref
end
end
end
Expose the denial count in your health endpoint:
defp run_checks do
authz = authorization_check()
deny_count = MyApp.Telemetry.deny_count()
%{
status: if(authz.status == "ok", do: "ok", else: "degraded"),
authorization: authz,
recent_denials: deny_count
}
end
Step 4: Monitor the health endpoint with Vigilmon
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute
- Enable keyword check: body must contain
"status":"ok" - Save
If a policy module fails to load after a hot code upgrade, or a database-backed role lookup times out inside a policy, the health endpoint returns "status":"degraded" while still serving HTTP 200. The keyword check catches that.
Step 5: Heartbeat monitoring for permission sync jobs
If you sync roles or permissions from an external system (LDAP, a permissions service, a config store), use a heartbeat to confirm the sync ran successfully:
# lib/my_app/workers/permission_sync_worker.ex
defmodule MyApp.Workers.PermissionSyncWorker do
use GenServer
require Logger
@interval :timer.minutes(15)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:sync, state) do
sync_permissions()
schedule()
{:noreply, state}
end
defp sync_permissions do
# Sync roles/permissions from your external source
case MyApp.Permissions.sync() do
{:ok, count} ->
Logger.info("[PermissionSyncWorker] synced #{count} permission records")
ping_vigilmon()
{:error, reason} ->
Logger.error("[PermissionSyncWorker] sync failed: #{inspect(reason)}")
end
end
defp ping_vigilmon do
url = Application.get_env(:my_app, :vigilmon)[:heartbeat_url]
if url do
case Req.get(url, receive_timeout: 5_000) do
{:ok, %{status: 200}} -> :ok
err -> Logger.warning("[PermissionSyncWorker] heartbeat ping failed: #{inspect(err)}")
end
end
end
defp schedule, do: Process.send_after(self(), :sync, @interval)
end
In Vigilmon, create a Heartbeat Monitor:
- Click New Monitor → Heartbeat
- Set expected interval: 30 minutes
- Copy the ping URL
- Set it as
VIGILMON_HEARTBEAT_URLin your environment
If the sync fails or the worker crashes, Vigilmon alerts you before stale permissions cause security incidents.
Step 6: Slack alerts and status page
Slack alerts:
- In Vigilmon, go to Notifications → New Channel → Slack
- Paste your Slack incoming webhook URL
- Enable the channel on your BodyGuard health monitor and heartbeat monitor
When policy evaluation breaks:
🔴 DOWN: yourdomain.com/health
Keyword "status":"ok" not found
Detected: US-East
2 minutes ago
When the permission sync misses:
🔴 HEARTBEAT MISSED: Permission Sync
Expected ping within 30 minutes — none received
Last seen: 47 minutes ago
Status page:
- Go to Status Pages → New Status Page
- Add your monitors
- Share the URL with your team
What you've built
| What | How |
|------|-----|
| Health endpoint | Plug with BodyGuard policy probe |
| Keyword check | Vigilmon keyword match on "status":"ok" |
| Uptime monitoring | Vigilmon HTTP monitor → /health |
| Policy liveness | Probe admin user against registered policies |
| Denial tracking | Telemetry handler counting authorization denials |
| Permission sync monitoring | Heartbeat GenServer + Vigilmon heartbeat monitor |
| Slack alerts | Vigilmon Slack notification channel |
| Status page | Vigilmon public status page |
BodyGuard keeps access control clean and policy-driven. Vigilmon tells you when the authorization layer can't be trusted.
Next steps
- Alert when denial counts spike — a sudden burst of
{:error, :unauthorized}responses can indicate a broken role assignment or a misconfigured policy after a deploy - Add per-policy response time tracking to detect policies with slow database lookups
- Monitor role sync latency to ensure authorization decisions use fresh permission data
Get started free at vigilmon.online.