11.2 KiB · text History b868907
defmodule GitGudWeb.RegistryControllerTest do
  @moduledoc """
  The OCI token endpoint: what each kind of credential is granted, on a
  repository's own image and on images beneath it (`owner/repo/server`).
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  alias GitGud.Accounts
  alias GitGud.Organizations
  alias GitGud.Packages
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage
  alias GitGud.Workflows.JobTokenGrants
  alias GitGud.Workflows.RunnerJwt
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun

  @moduletag :git_required

  setup do
    if System.find_executable("git") == nil do
      {:skip, "git not on PATH"}
    else
      previous = Application.get_env(:git_gud, Packages.Token)
      {private_pem, _public_pem} = Packages.Token.generate_keypair(2048)
      Application.put_env(:git_gud, Packages.Token, private_key_pem: private_pem)

      on_exit(fn ->
        if previous,
          do: Application.put_env(:git_gud, Packages.Token, previous),
          else: Application.delete_env(:git_gud, Packages.Token)
      end)

      :ok
    end
  end

  # The actions granted on `image` for a token request with `auth`.
  defp granted(conn, auth, image, actions \\ "pull,push") do
    conn =
      if auth,
        do: put_req_header(conn, "authorization", "Basic " <> Base.encode64(auth)),
        else: conn

    body = conn |> get("/v2/token", scope: "repository:#{image}:#{actions}") |> json_response(200)
    {:ok, claims} = Joken.peek_claims(body["token"])

    case Enum.find(claims["access"], &(&1["name"] == image)) do
      %{"actions" => granted} -> granted
      nil -> []
    end
  end

  defp path(repo) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization], force: true)
    Storage.repo_handle(repo) <> "/" <> repo.name
  end

  defp pat(user, scopes) do
    {:ok, _token, plaintext} = Accounts.create_personal_access_token(user, "t", scopes)
    "x:" <> plaintext
  end

  defp job_token(repo, perms \\ GitGud.Workflows.Permissions.default()) do
    run =
      %WorkflowRun{repository_id: repo.id}
      |> WorkflowRun.create_changeset(%{
        number: System.unique_integer([:positive]),
        workflow_path: ".forgejo/workflows/ci.yml",
        trigger: "push",
        head_sha: :crypto.strong_rand_bytes(20)
      })
      |> GitGud.Repo.insert!()

    job =
      %WorkflowJob{workflow_run_id: run.id}
      |> WorkflowJob.create_changeset(%{job_id: "build", definition: %{}})
      # Claimed: a job token is only good while its job runs.
      |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
      |> GitGud.Repo.insert!()

    "gitgud-actions:" <> RunnerJwt.mint(job, run, perms)
  end

  defp org_repo(visibility \\ "private") do
    owner = user_fixture()
    handle = "org#{System.unique_integer([:positive])}"
    {:ok, org} = Organizations.create_organization(owner, %{"handle" => handle, "name" => handle})

    {:ok, repo} =
      Repositories.create_repository_for_org(org, owner, %{
        "name" => unique_repo_name(),
        "visibility" => visibility
      })

    %{owner: owner, org: org, repo: repo}
  end

  describe "images under a repository" do
    test "the owner may push the repository's image and any beneath it", %{conn: conn} do
      {owner, repo} = repository_fixture()
      owner = set_password(owner)
      auth = owner.email <> ":" <> valid_user_password()

      assert granted(conn, auth, path(repo)) == ["pull", "push"]
      assert granted(conn, auth, path(repo) <> "/login") == ["pull", "push"]
      assert granted(conn, auth, path(repo) <> "/web/assets") == ["pull", "push"]
    end

    test "scopes it doesn't understand are dropped, not signed as asked", %{conn: conn} do
      {_owner, repo} = repository_fixture(%{visibility: "public"})

      for scope <- [
            "registry:catalog:*",
            "repository(plugin):#{path(repo)}:pull",
            "registry:catalog:* repository:#{path(repo)}:pull"
          ] do
        body = conn |> get("/v2/token", scope: scope) |> json_response(200)
        {:ok, claims} = Joken.peek_claims(body["token"])

        assert Enum.all?(claims["access"], &(&1["type"] == "repository")), scope
        refute Enum.any?(claims["access"], &(&1["name"] == "catalog")), scope
      end

      # The repository half of a space-separated request is still granted.
      body =
        conn
        |> get("/v2/token", scope: "registry:catalog:* repository:#{path(repo)}:pull")
        |> json_response(200)

      {:ok, claims} = Joken.peek_claims(body["token"])
      assert [%{"name" => name, "actions" => ["pull"]}] = claims["access"]
      assert name == path(repo)
    end

    test "names that aren't a repository grant nothing", %{conn: conn} do
      {owner, repo} = repository_fixture()
      auth = pat(owner, ["repo"])

      assert granted(conn, auth, "nobody-here/" <> repo.name <> "/login") == []
      assert granted(conn, auth, path(repo) <> "//login") == []
      assert granted(conn, auth, repo.name) == []
    end
  end

  describe "job tokens" do
    test "push and pull their own repository's images, sub-paths included", %{conn: conn} do
      {_owner, repo} = repository_fixture()
      auth = job_token(repo)

      assert granted(conn, auth, path(repo)) == ["pull", "push"]
      assert granted(conn, auth, path(repo) <> "/tower-demo") == ["pull", "push"]
    end

    test "get nothing from another private repository until it grants read", %{conn: conn} do
      {_, source} = repository_fixture()
      {_, target} = repository_fixture()
      auth = job_token(source)

      assert granted(conn, auth, path(target) <> "/tower-demo") == []

      {:ok, _} = JobTokenGrants.create(target, path(source), %{read: true})

      # Read, never write: a grant doesn't let another repository publish here.
      assert granted(conn, auth, path(target) <> "/tower-demo") == ["pull"]
    end

    test "an organization-wide grant covers every repository in it", %{conn: conn} do
      %{org: org, repo: source} = org_repo()
      {_, target} = repository_fixture()
      {:ok, _} = JobTokenGrants.create(target, org.handle, %{read: true})

      assert granted(conn, job_token(source), path(target)) == ["pull"]
    end

    test "a dispatch-only grant doesn't open reads", %{conn: conn} do
      {_, source} = repository_fixture()
      {_, target} = repository_fixture()
      {:ok, _} = JobTokenGrants.create(target, path(source), %{read: false, dispatch: true})

      assert granted(conn, job_token(source), path(target)) == []
    end

    test "follow the workflow's packages: permission", %{conn: conn} do
      {_, repo} = repository_fixture()
      read = job_token(repo, %{"contents" => :read, "packages" => :read, "actions" => :none})
      none = job_token(repo, %{"contents" => :read, "packages" => :none, "actions" => :none})

      assert granted(conn, read, path(repo)) == ["pull"]
      assert granted(conn, none, path(repo)) == []
    end

    test "a grant is read only with packages: read", %{conn: conn} do
      {_, source} = repository_fixture()
      {_, target} = repository_fixture()
      {:ok, _} = JobTokenGrants.create(target, path(source), %{read: true})
      auth = job_token(source, %{"contents" => :read, "packages" => :none, "actions" => :none})

      assert granted(conn, auth, path(target)) == []
    end
  end

  describe "registry tokens" do
    test "grant their scopes on their repository and the images beneath it", %{conn: conn} do
      {_owner, repo} = repository_fixture()
      {:ok, _, plaintext} = Packages.create_registry_token(repo, "argo", ["pull"])
      auth = "deploy:" <> plaintext

      assert granted(conn, auth, path(repo)) == ["pull"]
      assert granted(conn, auth, path(repo) <> "/login") == ["pull"]

      {_, other} = repository_fixture()
      assert granted(conn, auth, path(other) <> "/login") == []
    end
  end

  describe "visibility (anonymous)" do
    test "public images are pullable", %{conn: conn} do
      {_, repo} = repository_fixture(%{visibility: "public"})
      assert granted(conn, nil, path(repo) <> "/web") == ["pull"]
    end

    test "internal images are not: internal means signed-in users", %{conn: conn} do
      {_, repo} = repository_fixture(%{visibility: "internal"})
      assert granted(conn, nil, path(repo)) == []

      reader = user_fixture()
      assert granted(conn, pat(reader, ["packages:read"]), path(repo)) == ["pull"]
    end

    test "private images are not", %{conn: conn} do
      {_, repo} = repository_fixture()
      assert granted(conn, nil, path(repo)) == []
    end
  end

  describe "organizations" do
    test "an org admin who isn't the repository's owner may push", %{conn: conn} do
      %{org: org, repo: repo} = org_repo()
      admin = user_fixture()
      {:ok, _} = Organizations.add_member(org, admin, "admin")

      assert granted(conn, pat(admin, ["repo"]), path(repo) <> "/login") == ["pull", "push"]
    end

    test "a team with write may push; read may only pull", %{conn: conn} do
      %{org: org, repo: repo} = org_repo()
      writer = user_fixture()
      reader = user_fixture()

      for {user, permission} <- [{writer, "write"}, {reader, "read"}] do
        {:ok, _} = Organizations.add_member(org, user)

        {:ok, team} =
          Organizations.create_team(org, %{"name" => "t#{System.unique_integer([:positive])}"})

        {:ok, _} = Organizations.add_team_member(team, user)
        {:ok, _} = Organizations.grant_team_repo(team, repo, permission)
      end

      assert granted(conn, pat(writer, ["repo"]), path(repo)) == ["pull", "push"]
      assert granted(conn, pat(reader, ["repo"]), path(repo)) == ["pull"]
    end

    test "a plain member gets nothing from a private repository", %{conn: conn} do
      %{org: org, repo: repo} = org_repo()
      member = user_fixture()
      {:ok, _} = Organizations.add_member(org, member)

      assert granted(conn, pat(member, ["repo"]), path(repo)) == []
    end

    test "deleting takes an admin", %{conn: conn} do
      %{owner: owner, org: org, repo: repo} = org_repo()
      writer = user_fixture()
      {:ok, _} = Organizations.add_member(org, writer)

      {:ok, team} =
        Organizations.create_team(org, %{"name" => "w#{System.unique_integer([:positive])}"})

      {:ok, _} = Organizations.add_team_member(team, writer)
      {:ok, _} = Organizations.grant_team_repo(team, repo, "write")

      assert granted(conn, pat(owner, ["repo"]), path(repo), "delete") == ["delete"]
      assert granted(conn, pat(writer, ["repo"]), path(repo), "delete") == []
    end
  end

  describe "personal access tokens keep to their scopes" do
    setup do
      {owner, repo} = repository_fixture()
      %{owner: owner, repo: repo}
    end

    test "packages:write pushes", %{conn: conn, owner: owner, repo: repo} do
      assert granted(conn, pat(owner, ["packages:write"]), path(repo)) == ["pull", "push"]
    end

    test "packages:read only pulls", %{conn: conn, owner: owner, repo: repo} do
      assert granted(conn, pat(owner, ["packages:read"]), path(repo)) == ["pull"]
    end

    test "a token without a package or repo scope gets nothing", %{
      conn: conn,
      owner: owner,
      repo: repo
    } do
      assert granted(conn, pat(owner, ["ci:read", "ci:write"]), path(repo)) == []
    end
  end
end