7.6 KiB · text History b868907
defmodule GitGudWeb.JobTokenLive.Index do
  @moduledoc """
  Repo settings page for the job-token allowlist: which other
  repositories' CI jobs may read this repository's images and packages,
  or dispatch its workflows. See `GitGud.Workflows.JobTokenGrant`.
  """
  use GitGudWeb, :live_view

  alias GitGud.Repositories
  alias GitGud.Repositories.Storage
  alias GitGud.Workflows.JobTokenGrants

  @impl true
  def mount(%{"owner" => owner, "name" => name}, _session, socket) do
    repo =
      Repositories.get_repository_by_path!(owner, name)
      |> GitGud.Repo.preload([:owner, :organization])

    # Repo admins only, like registry tokens: the list says which other
    # repositories can read this one's packages or start its workflows.
    if GitGudWeb.RepoAdmin.admin?(socket.assigns.current_scope.user, repo) do
      {:ok,
       socket
       |> assign(:repo, repo)
       |> assign(:handle, Storage.repo_handle(repo))
       |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
       |> load()
       |> assign(:page_title, "Job token access — #{owner}/#{name}")}
    else
      {:ok, GitGudWeb.RepoAdmin.deny(socket, repo)}
    end
  end

  # Which repository or organization the grant lets in, as the page
  # shows it.
  defp grant_label(grant) do
    grant
    |> GitGud.Repo.preload(source_repository: [:owner, :organization], source_organization: [])
    |> JobTokenGrants.source_label()
  end

  defp load(socket), do: assign(socket, :grants, JobTokenGrants.list(socket.assigns.repo))

  @impl true
  def handle_event("create_grant", params, socket) do
    source = String.trim(params["source"] || "")

    attrs = %{
      read: params["read"] == "true",
      dispatch: params["dispatch"] == "true"
    }

    cond do
      # Asked again, not read from mount: the socket can outlive the role.
      not GitGudWeb.RepoAdmin.admin_now?(socket) ->
        {:noreply, put_flash(socket, :error, "Not allowed.")}

      source == "" ->
        {:noreply,
         put_flash(socket, :error, "Name a repository (owner/name) or an organization.")}

      true ->
        repo = socket.assigns.repo

        audited =
          GitGud.Audit.transact(
            socket.assigns.current_scope,
            "job_token_grant.created",
            fn -> JobTokenGrants.create(repo, source, attrs) end,
            fn grant ->
              %{
                repository: repo,
                target_id: grant.id,
                target_label: grant_label(grant),
                metadata: %{read: grant.read, dispatch: grant.dispatch}
              }
            end
          )

        case audited do
          {:ok, _grant} ->
            {:noreply, socket |> load() |> put_flash(:info, "Granted #{source}.")}

          {:error, :unknown_source} ->
            {:noreply,
             put_flash(socket, :error, "No repository or organization called #{source}.")}

          {:error, cs} ->
            {:noreply, put_flash(socket, :error, error_message(cs))}
        end
    end
  end

  def handle_event("delete_grant", %{"id" => id}, socket) do
    if GitGudWeb.RepoAdmin.admin_now?(socket) do
      repo = socket.assigns.repo

      if id = GitGudWeb.RepoAdmin.parse_id(id) do
        GitGud.Audit.transact(
          socket.assigns.current_scope,
          "job_token_grant.deleted",
          fn -> JobTokenGrants.delete(repo, id) end,
          fn grant ->
            %{repository: repo, target_id: grant.id, target_label: grant_label(grant)}
          end
        )
      end

      {:noreply, socket |> load() |> put_flash(:info, "Grant removed.")}
    else
      {:noreply, put_flash(socket, :error, "Not allowed.")}
    end
  end

  defp error_message(%Ecto.Changeset{} = cs) do
    cs
    |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
    |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
  end

  defp permissions(grant) do
    [grant.read && "read images & packages", grant.dispatch && "dispatch workflows"]
    |> Enum.filter(& &1)
    |> Enum.join(", ")
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="space-y-8">
        <GitGudWeb.RepoLive.Header.header
          repo={@repo}
          handle={@handle}
          current_scope={@current_scope}
          has_packages?={@has_packages?}
          can_admin?={@can_admin?}
          latest_run={@latest_run}
          open_pulls={@open_pulls}
          security_alerts={assigns[:security_alerts]}
        />
        <.repo_settings_header handle={@handle} repo={@repo} current={:job_tokens} />

        <section class="max-w-2xl space-y-4">
          <div>
            <h2 class="font-semibold">Job token access</h2>
            <p class="text-sm opacity-70">
              A CI job's token (<code>{"${{ github.token }}"}</code>) reaches only its own repository.
              Let another repository's jobs — or every repository in an organization — read this one's
              container images and packages, or dispatch its workflows.
            </p>
          </div>

          <form :if={@can_admin?} phx-submit="create_grant" class="flex flex-wrap items-end gap-3">
            <label class="form-control">
              <span class="text-xs opacity-70">Repository or organization</span>
              <input
                name="source"
                placeholder="owner/repo or org"
                class="input input-sm input-bordered font-mono"
              />
            </label>
            <label class="label cursor-pointer gap-1 text-sm">
              <input type="checkbox" name="read" value="true" checked class="checkbox checkbox-sm" />
              <span>read</span>
            </label>
            <label class="label cursor-pointer gap-1 text-sm">
              <input type="checkbox" name="dispatch" value="true" class="checkbox checkbox-sm" />
              <span>dispatch</span>
            </label>
            <button class="btn btn-sm btn-primary">Grant</button>
          </form>

          <table class="table table-sm">
            <thead>
              <tr>
                <th>Jobs from</th>
                <th>May</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              <tr :for={g <- @grants}>
                <td class="font-mono">{JobTokenGrants.source_label(g)}</td>
                <td class="text-xs">{permissions(g)}</td>
                <td class="text-right">
                  <button
                    :if={@can_admin?}
                    phx-click="delete_grant"
                    phx-value-id={g.id}
                    data-confirm={"Remove #{JobTokenGrants.source_label(g)}'s access?"}
                    class="btn btn-xs btn-ghost text-error"
                  >
                    Remove
                  </button>
                </td>
              </tr>
              <tr :if={@grants == []}>
                <td colspan="3" class="text-sm opacity-50">
                  No other repository's jobs have access.
                </td>
              </tr>
            </tbody>
          </table>

          <div class="text-xs opacity-70 space-y-1">
            <p>Dispatching from a granted repository's workflow:</p>
            <pre class="bg-base-200 p-2 rounded overflow-auto select-all">curl -fsS -X POST -H "Authorization: token $TOKEN" \
      -H "Content-Type: application/json" -d '&#123;"ref":"{@repo.default_branch || "main"}"&#125;' \
      "$CI_FORGE_URL/api/v1/repos/{@handle}/{@repo.name}/actions/workflows/&lt;file&gt;.yml/dispatches"</pre>
            <p>
              with <code>TOKEN: {"${{ github.token }}"}</code>; the workflow needs <code>on: workflow_dispatch</code>.
            </p>
          </div>
        </section>
      </div>
    </Layouts.app>
    """
  end
end