towerops/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex
Graham McIntire 3632580d1b fix: address security and reliability findings from bugs.md review
- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs
- Webhook auth: handle nil/blank secret with controlled error, not 500 crash
- Sudo redirect: reuse validated return_path? from login to prevent open redirect
- Map live: remove redundant inline script (ensureLeaflet hook handles loading)
- Bang calls: convert crash-prone exact matches to case in QR live and API controllers
2026-05-11 18:54:12 -05:00

348 lines
11 KiB
Elixir

defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do
@moduledoc """
API controller for managing escalation policies.
All endpoints require API token authentication and operations are scoped
to the organization associated with the token.
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.OnCall
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
alias Towerops.Repo
alias ToweropsWeb.ScopedResource
# --- Policy CRUD ---
@doc "GET /api/v1/escalation_policies"
def index(conn, _params) do
organization_id = conn.assigns.current_organization_id
policies =
organization_id
|> OnCall.list_escalation_policies()
|> Enum.map(&format_policy/1)
json(conn, %{escalation_policies: policies})
end
@doc "POST /api/v1/escalation_policies"
def create(conn, %{"escalation_policy" => policy_params}) do
organization_id = conn.assigns.current_organization_id
attrs = Map.put(policy_params, "organization_id", organization_id)
case OnCall.create_escalation_policy(attrs) do
{:ok, policy} ->
conn
|> put_status(:created)
|> json(format_policy(policy))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'escalation_policy' parameter"})
end
@doc "GET /api/v1/escalation_policies/:id"
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch_preload(EscalationPolicy, id, organization_id, rules: :targets) do
{:ok, policy} ->
json(conn, format_policy_detail(policy))
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Escalation policy not found"})
end
end
@doc "PATCH /api/v1/escalation_policies/:id"
def update(conn, %{"id" => id, "escalation_policy" => policy_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(EscalationPolicy, id, organization_id) do
{:ok, policy} ->
case OnCall.update_escalation_policy(policy, policy_params) do
{:ok, updated} ->
json(conn, format_policy(updated))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Escalation policy not found"})
end
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'escalation_policy' parameter"})
end
@doc "DELETE /api/v1/escalation_policies/:id"
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(EscalationPolicy, id, organization_id) do
{:ok, policy} ->
case OnCall.delete_escalation_policy(policy) do
{:ok, _} ->
json(conn, %{success: true})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Escalation policy not found"})
end
end
# --- Rules ---
@doc "POST /api/v1/escalation_policies/:id/rules"
def create_rule(conn, %{"id" => policy_id, "rule" => rule_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(EscalationPolicy, policy_id, organization_id) do
{:ok, _policy} ->
attrs = Map.put(rule_params, "escalation_policy_id", policy_id)
case OnCall.create_escalation_rule(attrs) do
{:ok, rule} ->
conn
|> put_status(:created)
|> json(format_rule(rule))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Escalation policy not found"})
end
end
@doc "PATCH /api/v1/escalation_policies/:id/rules/:rule_id"
def update_rule(conn, %{"id" => policy_id, "rule_id" => rule_id, "rule" => rule_params}) do
organization_id = conn.assigns.current_organization_id
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
{:ok, rule} <- fetch_rule(rule_id, policy_id) do
case OnCall.update_escalation_rule(rule, rule_params) do
{:ok, updated} ->
json(conn, format_rule(updated))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
else
{:error, :forbidden} ->
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
{:error, :rule_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
end
end
@doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id"
def delete_rule(conn, %{"id" => policy_id, "rule_id" => rule_id}) do
organization_id = conn.assigns.current_organization_id
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
{:ok, rule} <- fetch_rule(rule_id, policy_id) do
{:ok, _} = OnCall.delete_escalation_rule(rule)
json(conn, %{success: true})
else
{:error, :forbidden} ->
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
{:error, :rule_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
end
end
# --- Targets ---
@doc "POST /api/v1/escalation_policies/:id/rules/:rule_id/targets"
def create_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target" => target_params}) do
organization_id = conn.assigns.current_organization_id
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
{:ok, _rule} <- fetch_rule(rule_id, policy_id) do
attrs = Map.put(target_params, "escalation_rule_id", rule_id)
case OnCall.create_escalation_target(attrs) do
{:ok, target} ->
conn
|> put_status(:created)
|> json(format_target(target))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
else
{:error, :forbidden} ->
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
{:error, :rule_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
end
end
@doc "DELETE /api/v1/escalation_policies/:id/rules/:rule_id/targets/:target_id"
def delete_target(conn, %{"id" => policy_id, "rule_id" => rule_id, "target_id" => target_id}) do
organization_id = conn.assigns.current_organization_id
with {:ok, _policy} <- ScopedResource.fetch(EscalationPolicy, policy_id, organization_id),
{:ok, _rule} <- fetch_rule(rule_id, policy_id),
{:ok, target} <- fetch_target(target_id, rule_id) do
case OnCall.delete_escalation_target(target) do
{:ok, _} ->
json(conn, %{success: true})
{:error, reason} ->
require Logger
Logger.error("Failed to delete escalation target #{target_id}: #{inspect(reason)}")
conn |> put_status(:internal_server_error) |> json(%{error: "Failed to delete escalation target"})
end
else
{:error, :forbidden} ->
conn |> put_status(:forbidden) |> json(%{error: "Access denied to this escalation policy"})
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"})
{:error, :rule_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Rule not found"})
{:error, :target_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Target not found"})
end
end
# --- Private helpers ---
defp fetch_rule(rule_id, policy_id) do
case Repo.get(EscalationRule, rule_id) do
%EscalationRule{escalation_policy_id: ^policy_id} = rule -> {:ok, rule}
_ -> {:error, :rule_not_found}
end
end
defp fetch_target(target_id, rule_id) do
case Repo.get(EscalationTarget, target_id) do
%EscalationTarget{escalation_rule_id: ^rule_id} = target -> {:ok, target}
_ -> {:error, :target_not_found}
end
end
defp format_policy(policy) do
%{
id: policy.id,
name: policy.name,
description: policy.description,
repeat_count: policy.repeat_count,
inserted_at: policy.inserted_at
}
end
defp format_policy_detail(policy) do
%{
id: policy.id,
name: policy.name,
description: policy.description,
repeat_count: policy.repeat_count,
inserted_at: policy.inserted_at,
rules: Enum.map(policy.rules, &format_rule_detail/1)
}
end
defp format_rule(rule) do
%{
id: rule.id,
position: rule.position,
timeout_minutes: rule.timeout_minutes,
escalation_policy_id: rule.escalation_policy_id,
inserted_at: rule.inserted_at
}
end
defp format_rule_detail(rule) do
rule
|> format_rule()
|> Map.put(:targets, Enum.map(rule.targets, &format_target/1))
end
defp format_target(target) do
%{
id: target.id,
target_type: target.target_type,
user_id: target.user_id,
schedule_id: target.schedule_id,
escalation_rule_id: target.escalation_rule_id,
inserted_at: target.inserted_at
}
end
end