towerops/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex
Graham McIntire b252cb81b9 fix: 5 more high-severity bugs (H6, H8, H17, H18)
- H6: batch interface stats query in get_site_capacity_summary, replacing
  per-interface SELECT with a single grouped query
- H8: explicit expires_at check in MobileAuth and GraphQLAuth plugs as
  defense in depth — the query already filters expired rows, but a future
  refactor dropping the filter can't quietly re-enable revoked sessions
- H17: Reports LiveView (toggle/delete/run_now) now uses
  Reports.get_organization_report/2 to scope by organization_id, fixing
  IDOR where a user could manipulate other tenants' reports by ID
- H18: ToweropsWeb.Api.ParamFilter strips identity fields (id,
  organization_id, user_id, created_by_id, inserted_at, updated_at) from
  user-supplied API params; applied to devices, sites, checks, and
  escalation_policies create/update paths to prevent mass-assignment of
  tenant ownership fields if a changeset cast list ever drifts
2026-05-12 11:04:31 -05:00

353 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.Api.ParamFilter
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 =
policy_params
|> ParamFilter.strip_sensitive()
|> Map.put("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, ParamFilter.strip_sensitive(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