lib/openagents/deployments/policy.ex

main at 58e6347eeb72 · 10 KB

defmodule OpenAgents.Deployments.Policy do
  @moduledoc """
  Evaluates one environment's protection policy against one exact request.

  Evaluation is pure: it takes the environment, the request, the check results
  already published for those exact bytes, the approvals already recorded, and
  the current time, and returns the state the run may hold plus a durable
  explanation of every rule it considered.

  The explanation is the product, not a by-product. A blocked deployment that
  cannot say which rule blocked it forces an operator to guess, and a guess is
  usually "grant more authority". Explanations carry rule names, outcomes, and
  bounded details only — never a secret value, and never a provider credential.
  """

  alias OpenAgents.Deployments.Approval
  alias OpenAgents.Deployments.CheckResult
  alias OpenAgents.Deployments.Environment
  alias OpenAgents.Deployments.Protection
  alias OpenAgents.Deployments.Request

  @type outcome :: :satisfied | :pending | :blocked
  @type explanation :: [%{optional(String.t()) => term()}]
  @type decision ::
          {:admit, String.t(), explanation()}
          | {:deny, String.t(), explanation()}

  @doc """
  Evaluate the policy, returning the admitted state or a denial.

  `:admit` carries `"queued"` when nothing further is required, or
  `"waiting_for_approval"` when the policy needs decisions the run does not have
  yet. `:deny` carries a bounded reason such as `"frozen"` or `"checks_failed"`.
  """
  @spec evaluate(
          Environment.t(),
          Request.t(),
          [CheckResult.t()],
          [Approval.t()],
          DateTime.t()
        ) :: decision()
  def evaluate(
        %Environment{} = environment,
        %Request{} = request,
        check_results,
        approvals,
        %DateTime{} = now
      ) do
    protection = environment.protection || %Protection{}

    [
      source_rule(protection, request),
      freeze_rule(protection),
      window_rule(protection, now),
      artifact_age_rule(protection, request, now),
      checks_rule(protection, request, check_results, now),
      approvals_rule(protection, request, approvals)
    ]
    |> decide()
  end

  @doc """
  The state a run starts in, before policy has been evaluated for the first time.

  A run that has required checks starts in `checking`, so a reader can tell an
  unevaluated run from one that is genuinely waiting on nothing.
  """
  @spec initial_state(Environment.t()) :: String.t()
  def initial_state(%Environment{} = environment) do
    protection = environment.protection || %Protection{}

    if protection.required_checks == [], do: "requested", else: "checking"
  end

  defp decide(rules) do
    explanation = Enum.map(rules, fn {_outcome, entry} -> entry end)

    blocked = Enum.find(rules, fn {outcome, _entry} -> outcome == :blocked end)
    pending = Enum.find(rules, fn {outcome, _entry} -> outcome == :pending end)

    cond do
      blocked ->
        {_outcome, entry} = blocked
        {:deny, Map.fetch!(entry, "reason"), explanation}

      pending ->
        {_outcome, entry} = pending
        {:admit, pending_state(Map.fetch!(entry, "rule")), explanation}

      true ->
        {:admit, "queued", explanation}
    end
  end

  defp pending_state("required_approvals"), do: "waiting_for_approval"
  defp pending_state(_rule), do: "checking"

  defp source_rule(%Protection{} = protection, %Request{} = request) do
    cond do
      workflow_rejected?(protection, request) ->
        blocked("allowed_workflows", "source_workflow_not_allowed", %{
          "source_workflow" => request.source_workflow
        })

      ref_admitted?(protection, request.source_ref) ->
        satisfied("allowed_sources", %{"source_ref" => request.source_ref})

      true ->
        blocked("allowed_sources", "source_ref_not_allowed", %{"source_ref" => request.source_ref})
    end
  end

  defp workflow_rejected?(%Protection{allowed_workflows: []}, _request), do: false

  defp workflow_rejected?(%Protection{allowed_workflows: allowed}, %Request{} = request),
    do: request.source_workflow not in allowed

  defp ref_admitted?(%Protection{allowed_branches: [], allowed_tags: []}, _ref), do: true

  defp ref_admitted?(%Protection{} = protection, "refs/heads/" <> branch),
    do: pattern_match?(protection.allowed_branches, branch)

  defp ref_admitted?(%Protection{} = protection, "refs/tags/" <> tag),
    do: pattern_match?(protection.allowed_tags, tag)

  defp ref_admitted?(%Protection{}, _ref), do: false

  # A trailing `*` is the only wildcard: `release/*` admits a family of branches
  # without admitting `release-hotfix-escape`.
  defp pattern_match?(patterns, value) do
    Enum.any?(patterns, fn pattern ->
      case String.split(pattern, "*", parts: 2) do
        [^value] -> true
        [prefix, ""] -> String.starts_with?(value, prefix)
        _other -> false
      end
    end)
  end

  defp freeze_rule(%Protection{frozen: true} = protection) do
    blocked("freeze", "frozen", %{"freeze_reason" => protection.freeze_reason})
  end

  defp freeze_rule(%Protection{}), do: satisfied("freeze", %{})

  defp window_rule(%Protection{} = protection, now) do
    cond do
      Protection.unrestricted_window?(protection) ->
        satisfied("deployment_window", %{"window" => "unrestricted"})

      Protection.within_window?(protection, now) ->
        satisfied("deployment_window", %{"window" => "open"})

      true ->
        blocked("deployment_window", "outside_window", %{"window" => "closed"})
    end
  end

  defp artifact_age_rule(%Protection{maximum_artifact_age_seconds: nil}, _request, _now),
    do: satisfied("artifact_age", %{"limit" => "none"})

  defp artifact_age_rule(%Protection{} = protection, %Request{artifact_created_at: nil}, _now) do
    blocked("artifact_age", "artifact_age_unknown", %{
      "maximum_age_seconds" => protection.maximum_artifact_age_seconds
    })
  end

  defp artifact_age_rule(%Protection{} = protection, %Request{} = request, now) do
    age = DateTime.diff(now, request.artifact_created_at)

    if age <= protection.maximum_artifact_age_seconds do
      satisfied("artifact_age", %{"age_seconds" => age})
    else
      blocked("artifact_age", "artifact_too_old", %{"age_seconds" => age})
    end
  end

  defp checks_rule(%Protection{required_checks: []}, _request, _results, _now),
    do: satisfied("required_checks", %{"required" => []})

  defp checks_rule(%Protection{} = protection, %Request{} = request, check_results, now) do
    matching = Enum.filter(check_results, &matches_bytes?(&1, request))
    by_name = Map.new(matching, &{&1.name, &1})

    # A required check with no evidence at all is the common case, so each rule
    # is evaluated over `{name, result_or_nil}` rather than over results found.
    required = Enum.map(protection.required_checks, &{&1, by_name[&1]})

    failed = for {name, result} <- required, failed?(result), do: name
    expired = for {name, result} <- required, expired?(result, protection, now), do: name
    missing = for {name, result} <- required, awaiting?(result), do: name

    cond do
      failed != [] ->
        blocked("required_checks", "checks_failed", %{"failed" => Enum.sort(failed)})

      expired != [] ->
        blocked("required_checks", "checks_expired", %{"expired" => Enum.sort(expired)})

      missing != [] ->
        pending("required_checks", %{"awaiting" => Enum.sort(missing)})

      true ->
        satisfied("required_checks", %{"required" => Enum.sort(protection.required_checks)})
    end
  end

  # A check counts only for the exact commit and artifact it examined. This is
  # the whole defense against replaying a green result onto different bytes.
  defp matches_bytes?(%CheckResult{} = result, %Request{} = request) do
    result.commit_sha == request.commit_sha and result.artifact_digest == request.artifact_digest
  end

  defp failed?(%CheckResult{status: "failed"}), do: true
  defp failed?(_result), do: false

  defp awaiting?(%CheckResult{status: "pending"}), do: true
  defp awaiting?(%CheckResult{}), do: false
  defp awaiting?(nil), do: true

  defp expired?(%CheckResult{status: "succeeded"} = result, %Protection{} = protection, now) do
    hard_expiry =
      not is_nil(result.valid_until) and DateTime.compare(result.valid_until, now) != :gt

    policy_expiry =
      not is_nil(protection.check_validity_seconds) and
        DateTime.diff(now, result.updated_at) > protection.check_validity_seconds

    hard_expiry or policy_expiry
  end

  defp expired?(_result, _protection, _now), do: false

  defp approvals_rule(%Protection{required_approvals: required}, _request, _approvals)
       when required <= 0 do
    satisfied("required_approvals", %{"required" => 0})
  end

  defp approvals_rule(%Protection{} = protection, %Request{} = request, approvals) do
    for_bytes = Enum.filter(approvals, &(&1.request_digest == request.request_digest))
    rejected = Enum.filter(for_bytes, &(&1.decision == "rejected"))

    approved =
      for_bytes
      |> Enum.filter(&(&1.decision == "approved"))
      |> Enum.reject(&self_approval?(protection, request, &1))
      |> Enum.uniq_by(& &1.approver_user_id)

    cond do
      rejected != [] ->
        blocked("required_approvals", "rejected", %{"rejections" => length(rejected)})

      length(approved) >= protection.required_approvals ->
        satisfied("required_approvals", %{
          "required" => protection.required_approvals,
          "approved" => length(approved)
        })

      true ->
        pending("required_approvals", %{
          "required" => protection.required_approvals,
          "approved" => length(approved)
        })
    end
  end

  # Separation of duties is enforced twice: the approval endpoint refuses the
  # requester, and evaluation refuses to count such an approval even if one is
  # already on record from before the policy tightened.
  defp self_approval?(%Protection{separation_of_duties: false}, _request, _approval), do: false

  defp self_approval?(%Protection{}, %Request{requested_by_user_id: nil}, _approval), do: false

  defp self_approval?(%Protection{}, %Request{} = request, %Approval{} = approval),
    do: approval.approver_user_id == request.requested_by_user_id

  defp satisfied(rule, detail), do: {:satisfied, entry(rule, "satisfied", detail)}
  defp pending(rule, detail), do: {:pending, entry(rule, "pending", detail)}

  defp blocked(rule, reason, detail),
    do: {:blocked, Map.put(entry(rule, "blocked", detail), "reason", reason)}

  defp entry(rule, outcome, detail) do
    %{"rule" => rule, "outcome" => outcome, "detail" => detail}
  end
end