Add repo-scoped registry deploy tokens + podman pull in packages view
2e7dd83 · gmorell · 2026-06-25 20:16
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/packages.ex
+56
−0
@@ -15,9 +15,65 @@ defmodule GitGud.Packages do
| 15 | 15 | |
| 16 | 16 | alias GitGud.Packages.Package |
| 17 | 17 | alias GitGud.Packages.PackageVersion |
| 18 | + alias GitGud.Packages.RegistryToken | |
| 18 | 19 | alias GitGud.Repo |
| 19 | 20 | alias GitGud.Repositories.Repository |
| 20 | 21 | |
| 22 | + @registry_token_prefix "ggrt_" | |
| 23 | + | |
| 24 | + # ── registry deploy tokens ─────────────────────────────────────────── | |
| 25 | + | |
| 26 | + @doc "Repo-scoped registry tokens, by name." | |
| 27 | + def list_registry_tokens(%Repository{id: rid}) do | |
| 28 | + from(t in RegistryToken, where: t.repository_id == ^rid, order_by: [asc: t.name]) | |
| 29 | + |> Repo.all() | |
| 30 | + end | |
| 31 | + | |
| 32 | + @doc """ | |
| 33 | + Create a registry token for a repo with the given `scopes` | |
| 34 | + (subset of `pull`/`push`). Returns `{:ok, token, plaintext}`; the | |
| 35 | + plaintext (`ggrt_…`) is only available here — we store its hash. | |
| 36 | + """ | |
| 37 | + def create_registry_token(%Repository{id: rid}, name, scopes) do | |
| 38 | + plaintext = @registry_token_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)) | |
| 39 | + | |
| 40 | + %RegistryToken{repository_id: rid} | |
| 41 | + |> RegistryToken.changeset(%{name: name, token_hash: token_hash(plaintext), scopes: scopes}) | |
| 42 | + |> Repo.insert() | |
| 43 | + |> case do | |
| 44 | + {:ok, token} -> {:ok, token, plaintext} | |
| 45 | + {:error, _} = err -> err | |
| 46 | + end | |
| 47 | + end | |
| 48 | + | |
| 49 | + def delete_registry_token(%Repository{id: rid}, id) do | |
| 50 | + Repo.delete_all(from t in RegistryToken, where: t.repository_id == ^rid and t.id == ^id) | |
| 51 | + :ok | |
| 52 | + end | |
| 53 | + | |
| 54 | + @doc """ | |
| 55 | + Verify a presented registry token. On a match, bumps `last_used_at` | |
| 56 | + and returns `{:ok, repository_id, scopes}`; otherwise `:error`. | |
| 57 | + """ | |
| 58 | + def verify_registry_token(@registry_token_prefix <> _ = plaintext) do | |
| 59 | + case Repo.get_by(RegistryToken, token_hash: token_hash(plaintext)) do | |
| 60 | + %RegistryToken{} = token -> | |
| 61 | + Repo.update_all( | |
| 62 | + from(t in RegistryToken, where: t.id == ^token.id), | |
| 63 | + set: [last_used_at: DateTime.utc_now(:second)] | |
| 64 | + ) | |
| 65 | + | |
| 66 | + {:ok, token.repository_id, token.scopes} | |
| 67 | + | |
| 68 | + nil -> | |
| 69 | + :error | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + def verify_registry_token(_), do: :error | |
| 74 | + | |
| 75 | + defp token_hash(plaintext), do: :crypto.hash(:sha256, plaintext) | |
| 76 | + | |
| 21 | 77 | def list_packages(%Repository{id: rid}) do |
| 22 | 78 | Package |
| 23 | 79 | |> where([p], p.repository_id == ^rid) |
added
lib/git_gud/packages/registry_token.ex
+45
−0
@@ -0,0 +1,45 @@
| 1 | +defmodule GitGud.Packages.RegistryToken do | |
| 2 | + @moduledoc """ | |
| 3 | + A long-lived, repo-scoped token for authenticating to the bundled OCI | |
| 4 | + registry (zot) outside of CI — e.g. an Argo/keel pull secret, or an | |
| 5 | + external pipeline pushing images. | |
| 6 | + | |
| 7 | + Only the SHA-256 `token_hash` is stored; the plaintext (`ggrt_…`) is | |
| 8 | + shown once at creation. `scopes` is a subset of `pull` / `push`; the | |
| 9 | + registry token endpoint grants the intersection of these and the | |
| 10 | + requested actions on the token's own repository. | |
| 11 | + """ | |
| 12 | + use Ecto.Schema | |
| 13 | + | |
| 14 | + import Ecto.Changeset | |
| 15 | + | |
| 16 | + alias GitGud.Repositories.Repository | |
| 17 | + | |
| 18 | + @scopes ~w(pull push) | |
| 19 | + | |
| 20 | + schema "registry_tokens" do | |
| 21 | + field :name, :string | |
| 22 | + field :token_hash, :binary, redact: true | |
| 23 | + field :scopes, {:array, :string}, default: ["pull"] | |
| 24 | + field :last_used_at, :utc_datetime | |
| 25 | + | |
| 26 | + belongs_to :repository, Repository | |
| 27 | + | |
| 28 | + timestamps(type: :utc_datetime) | |
| 29 | + end | |
| 30 | + | |
| 31 | + @doc false | |
| 32 | + def changeset(token, attrs) do | |
| 33 | + token | |
| 34 | + |> cast(attrs, [:name, :token_hash, :scopes, :repository_id]) | |
| 35 | + |> validate_required([:name, :token_hash, :repository_id]) | |
| 36 | + |> validate_length(:name, min: 1, max: 100) | |
| 37 | + |> validate_subset(:scopes, @scopes) | |
| 38 | + |> validate_length(:scopes, min: 1) | |
| 39 | + |> unique_constraint(:name, name: :registry_tokens_repository_id_name_index) | |
| 40 | + |> unique_constraint(:token_hash) | |
| 41 | + end | |
| 42 | + | |
| 43 | + @doc "Allowed scope values." | |
| 44 | + def scopes, do: @scopes | |
| 45 | +end |
modified
lib/git_gud_web/components/core_components.ex
+5
−0
@@ -560,6 +560,11 @@ defmodule GitGudWeb.CoreComponents do
| 560 | 560 | label="Deploy keys" |
| 561 | 561 | active={@current == :deploy_keys} |
| 562 | 562 | /> |
| 563 | + <.repo_settings_nav_link | |
| 564 | + navigate={~p"/r/#{@handle}/#{@repo.name}/settings/registry-tokens"} | |
| 565 | + label="Registry tokens" | |
| 566 | + active={@current == :registry_tokens} | |
| 567 | + /> | |
| 563 | 568 | <.repo_settings_nav_link |
| 564 | 569 | navigate={~p"/r/#{@handle}/#{@repo.name}/settings/interaction"} |
| 565 | 570 | label="Interaction policy" |
modified
lib/git_gud_web/controllers/registry_controller.ex
+31
−3
@@ -17,6 +17,7 @@ defmodule GitGudWeb.RegistryController do
| 17 | 17 | use GitGudWeb, :controller |
| 18 | 18 | |
| 19 | 19 | alias GitGud.Accounts |
| 20 | + alias GitGud.Packages | |
| 20 | 21 | alias GitGud.Packages.Token |
| 21 | 22 | alias GitGud.Repositories |
| 22 | 23 | alias GitGud.Repositories.Storage |
@@ -59,9 +60,18 @@ defmodule GitGudWeb.RegistryController do
| 59 | 60 | end |
| 60 | 61 | |
| 61 | 62 | _ -> |
| 62 | − case Accounts.get_user_by_email_and_password(username, secret) do | |
| 63 | − %_{} = user -> {:user, user} | |
| 64 | − _ -> :anonymous | |
| 63 | + case Packages.verify_registry_token(secret) do | |
| 64 | + {:ok, repo_id, scopes} -> | |
| 65 | + case job_repo_path(repo_id) do | |
| 66 | + nil -> :anonymous | |
| 67 | + path -> {:deploy, path, scopes} | |
| 68 | + end | |
| 69 | + | |
| 70 | + :error -> | |
| 71 | + case Accounts.get_user_by_email_and_password(username, secret) do | |
| 72 | + %_{} = user -> {:user, user} | |
| 73 | + _ -> :anonymous | |
| 74 | + end | |
| 65 | 75 | end |
| 66 | 76 | end |
| 67 | 77 |
@@ -88,6 +98,24 @@ defmodule GitGudWeb.RegistryController do
| 88 | 98 | {nil, Enum.map(scopes, &user_scope(nil, &1))} |
| 89 | 99 | end |
| 90 | 100 | |
| 101 | + # A repo-scoped deploy token grants its own `pull`/`push` scopes on its | |
| 102 | + # own repo; other repos fall back to anonymous (public pull only). | |
| 103 | + defp authorize({:deploy, repo_path, token_scopes}, scopes) do | |
| 104 | + access = | |
| 105 | + Enum.map(scopes, fn scope -> | |
| 106 | + case String.split(scope, ":", parts: 3) do | |
| 107 | + ["repository", ^repo_path, actions] -> | |
| 108 | + granted = Enum.filter(String.split(actions, ","), &(&1 in token_scopes)) | |
| 109 | + "repository:" <> repo_path <> ":" <> Enum.join(granted, ",") | |
| 110 | + | |
| 111 | + _ -> | |
| 112 | + user_scope(nil, scope) | |
| 113 | + end | |
| 114 | + end) | |
| 115 | + | |
| 116 | + {"deploy-token:" <> repo_path, access} | |
| 117 | + end | |
| 118 | + | |
| 91 | 119 | # A job token grants push+pull on its own repo; other repos fall back to |
| 92 | 120 | # anonymous (public pull only). |
| 93 | 121 | defp authorize({:job, job_path}, scopes) do |
added
lib/git_gud_web/live/registry_token_live/index.ex
+160
−0
@@ -0,0 +1,160 @@
| 1 | +defmodule GitGudWeb.RegistryTokenLive.Index do | |
| 2 | + @moduledoc """ | |
| 3 | + Repo settings page for registry deploy tokens — long-lived, | |
| 4 | + repo-scoped credentials for pulling/pushing the repo's images outside | |
| 5 | + of CI (Argo/keel pull secrets, external pipelines). The plaintext is | |
| 6 | + shown once at creation; only the hash is stored. | |
| 7 | + """ | |
| 8 | + use GitGudWeb, :live_view | |
| 9 | + | |
| 10 | + alias GitGud.Packages | |
| 11 | + alias GitGud.Repositories | |
| 12 | + alias GitGud.Repositories.Storage | |
| 13 | + | |
| 14 | + @impl true | |
| 15 | + def mount(%{"owner" => owner, "name" => name}, _session, socket) do | |
| 16 | + repo = | |
| 17 | + Repositories.get_repository_by_path!(owner, name) | |
| 18 | + |> GitGud.Repo.preload([:owner, :organization]) | |
| 19 | + | |
| 20 | + {:ok, | |
| 21 | + socket | |
| 22 | + |> assign(:repo, repo) | |
| 23 | + |> assign(:handle, Storage.repo_handle(repo)) | |
| 24 | + |> GitGudWeb.RepoLive.Header.assign_chrome(repo) | |
| 25 | + |> assign(:registry_host, Application.get_env(:git_gud, :registry_host)) | |
| 26 | + |> assign(:new_token, nil) | |
| 27 | + |> assign(:all_scopes, Packages.RegistryToken.scopes()) | |
| 28 | + |> load() | |
| 29 | + |> assign(:page_title, "Registry tokens — #{owner}/#{name}")} | |
| 30 | + end | |
| 31 | + | |
| 32 | + defp load(socket) do | |
| 33 | + assign(socket, :tokens, Packages.list_registry_tokens(socket.assigns.repo)) | |
| 34 | + end | |
| 35 | + | |
| 36 | + @impl true | |
| 37 | + def handle_event("create_token", params, socket) do | |
| 38 | + cond do | |
| 39 | + not socket.assigns.can_admin? -> | |
| 40 | + {:noreply, put_flash(socket, :error, "Not allowed.")} | |
| 41 | + | |
| 42 | + String.trim(params["name"] || "") == "" -> | |
| 43 | + {:noreply, put_flash(socket, :error, "Name required.")} | |
| 44 | + | |
| 45 | + (params["scopes"] || []) == [] -> | |
| 46 | + {:noreply, put_flash(socket, :error, "Pick at least one scope.")} | |
| 47 | + | |
| 48 | + true -> | |
| 49 | + name = String.trim(params["name"]) | |
| 50 | + scopes = params["scopes"] | |
| 51 | + | |
| 52 | + case Packages.create_registry_token(socket.assigns.repo, name, scopes) do | |
| 53 | + {:ok, _token, plaintext} -> | |
| 54 | + {:noreply, | |
| 55 | + socket | |
| 56 | + |> load() | |
| 57 | + |> assign(:new_token, %{name: name, secret: plaintext}) | |
| 58 | + |> put_flash(:info, "Token created — copy it now, it won't be shown again.")} | |
| 59 | + | |
| 60 | + {:error, cs} -> | |
| 61 | + {:noreply, put_flash(socket, :error, error_message(cs))} | |
| 62 | + end | |
| 63 | + end | |
| 64 | + end | |
| 65 | + | |
| 66 | + def handle_event("delete_token", %{"id" => id}, socket) do | |
| 67 | + if socket.assigns.can_admin? do | |
| 68 | + :ok = Packages.delete_registry_token(socket.assigns.repo, String.to_integer(id)) | |
| 69 | + {:noreply, socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")} | |
| 70 | + else | |
| 71 | + {:noreply, put_flash(socket, :error, "Not allowed.")} | |
| 72 | + end | |
| 73 | + end | |
| 74 | + | |
| 75 | + defp error_message(%Ecto.Changeset{} = cs) do | |
| 76 | + cs | |
| 77 | + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end) | |
| 78 | + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end) | |
| 79 | + end | |
| 80 | + | |
| 81 | + defp image_ref(assigns), do: "#{assigns.registry_host}/#{assigns.handle}/#{assigns.repo.name}" | |
| 82 | + | |
| 83 | + @impl true | |
| 84 | + def render(assigns) do | |
| 85 | + ~H""" | |
| 86 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 87 | + <div class="space-y-8"> | |
| 88 | + <GitGudWeb.RepoLive.Header.header | |
| 89 | + repo={@repo} | |
| 90 | + handle={@handle} | |
| 91 | + current_scope={@current_scope} | |
| 92 | + has_packages?={@has_packages?} | |
| 93 | + can_admin?={@can_admin?} | |
| 94 | + latest_run={@latest_run} | |
| 95 | + /> | |
| 96 | + <.repo_settings_header handle={@handle} repo={@repo} current={:registry_tokens} /> | |
| 97 | + | |
| 98 | + <section class="max-w-2xl space-y-4"> | |
| 99 | + <div> | |
| 100 | + <h2 class="font-semibold">Registry deploy tokens</h2> | |
| 101 | + <p class="text-sm opacity-70"> | |
| 102 | + Long-lived credentials to <code>pull</code>/<code>push</code> | |
| 103 | + this repo's container images outside CI. CI itself doesn't need one — | |
| 104 | + it uses the per-job token automatically. | |
| 105 | + </p> | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + <div :if={@new_token} class="rounded border border-success/40 bg-success/10 p-3 space-y-2"> | |
| 109 | + <p class="text-sm font-semibold">New token “{@new_token.name}” — copy it now:</p> | |
| 110 | + <pre class="text-xs bg-black text-green-300 p-2 rounded overflow-auto select-all">{@new_token.secret}</pre> | |
| 111 | + <p :if={@registry_host} class="text-xs opacity-80"> | |
| 112 | + <span class="font-mono select-all">podman login {@registry_host} -u {@new_token.name} -p {@new_token.secret}</span> | |
| 113 | + </p> | |
| 114 | + </div> | |
| 115 | + | |
| 116 | + <form :if={@can_admin?} phx-submit="create_token" class="flex flex-wrap items-end gap-3"> | |
| 117 | + <label class="form-control"> | |
| 118 | + <span class="text-xs opacity-70">Name</span> | |
| 119 | + <input name="name" placeholder="argo-pull" class="input input-sm input-bordered" /> | |
| 120 | + </label> | |
| 121 | + <label :for={s <- @all_scopes} class="label cursor-pointer gap-1 text-sm"> | |
| 122 | + <input type="checkbox" name="scopes[]" value={s} checked={s == "pull"} class="checkbox checkbox-sm" /> | |
| 123 | + <span class="font-mono">{s}</span> | |
| 124 | + </label> | |
| 125 | + <button class="btn btn-sm btn-primary">Create token</button> | |
| 126 | + </form> | |
| 127 | + | |
| 128 | + <table class="table table-sm"> | |
| 129 | + <thead> | |
| 130 | + <tr><th>Name</th><th>Scopes</th><th>Last used</th><th></th></tr> | |
| 131 | + </thead> | |
| 132 | + <tbody> | |
| 133 | + <tr :for={t <- @tokens}> | |
| 134 | + <td class="font-mono">{t.name}</td> | |
| 135 | + <td class="font-mono text-xs">{Enum.join(t.scopes, ", ")}</td> | |
| 136 | + <td class="text-xs opacity-70">{t.last_used_at && Calendar.strftime(t.last_used_at, "%Y-%m-%d %H:%M")}</td> | |
| 137 | + <td class="text-right"> | |
| 138 | + <button | |
| 139 | + :if={@can_admin?} | |
| 140 | + phx-click="delete_token" | |
| 141 | + phx-value-id={t.id} | |
| 142 | + data-confirm={"Revoke token #{t.name}? Anything using it will lose access."} | |
| 143 | + class="btn btn-xs btn-ghost text-error" | |
| 144 | + >Revoke</button> | |
| 145 | + </td> | |
| 146 | + </tr> | |
| 147 | + <tr :if={@tokens == []}><td colspan="4" class="text-sm opacity-50">No tokens yet.</td></tr> | |
| 148 | + </tbody> | |
| 149 | + </table> | |
| 150 | + | |
| 151 | + <div :if={@registry_host} class="text-xs opacity-70 space-y-1"> | |
| 152 | + <p>Pull this repo's image with:</p> | |
| 153 | + <pre class="bg-base-200 p-2 rounded select-all">podman pull {image_ref(assigns)}:thetag</pre> | |
| 154 | + </div> | |
| 155 | + </section> | |
| 156 | + </div> | |
| 157 | + </Layouts.app> | |
| 158 | + """ | |
| 159 | + end | |
| 160 | +end |
modified
lib/git_gud_web/live/repo_live/packages.ex
+24
−8
@@ -28,9 +28,22 @@ defmodule GitGudWeb.RepoLive.Packages do
| 28 | 28 | |> assign(:packages, packages) |
| 29 | 29 | |> assign(:expanded_id, nil) |
| 30 | 30 | |> assign(:versions_by_id, %{}) |
| 31 | + |> assign(:registry_host, Application.get_env(:git_gud, :registry_host)) | |
| 31 | 32 | |> assign(:page_title, "Packages — #{owner}/#{name}")} |
| 32 | 33 | end |
| 33 | 34 | |
| 35 | + # Reconstruct the zot repository path the webhook deflated: a package | |
| 36 | + # named after the repo was pushed as `<owner>/<repo>`; a sub-image as | |
| 37 | + # `<owner>/<repo>/<image>`. (See PackagesWebhookController.) | |
| 38 | + defp pull_ref(handle, repo, pkg, version, host) do | |
| 39 | + base = | |
| 40 | + if pkg.name == repo.name, | |
| 41 | + do: "#{handle}/#{repo.name}", | |
| 42 | + else: "#{handle}/#{repo.name}/#{pkg.name}" | |
| 43 | + | |
| 44 | + "#{host}/#{base}:#{version}" | |
| 45 | + end | |
| 46 | + | |
| 34 | 47 | @impl true |
| 35 | 48 | def handle_event("toggle", %{"id" => id}, socket) do |
| 36 | 49 | id = String.to_integer(id) |
@@ -111,14 +124,17 @@ defmodule GitGudWeb.RepoLive.Packages do
| 111 | 124 | :if={@expanded_id == pkg.id} |
| 112 | 125 | class="mt-2 ml-4 border-l border-base-300 pl-3 space-y-1 text-xs" |
| 113 | 126 | > |
| 114 | − <li | |
| 115 | − :for={v <- Map.get(@versions_by_id, pkg.id, [])} | |
| 116 | − class="flex items-baseline justify-between gap-2" | |
| 117 | − > | |
| 118 | − <span class="font-mono">{v.version}</span> | |
| 119 | − <span class="opacity-60 truncate">{short_digest(v.digest)}</span> | |
| 120 | − <span class="opacity-50">{format_size(v.size)}</span> | |
| 121 | − <span class="opacity-50">{format_dt(v.pushed_at)}</span> | |
| 127 | + <li :for={v <- Map.get(@versions_by_id, pkg.id, [])} class="space-y-1"> | |
| 128 | + <div class="flex items-baseline justify-between gap-2"> | |
| 129 | + <span class="font-mono">{v.version}</span> | |
| 130 | + <span class="opacity-60 truncate">{short_digest(v.digest)}</span> | |
| 131 | + <span class="opacity-50">{format_size(v.size)}</span> | |
| 132 | + <span class="opacity-50">{format_dt(v.pushed_at)}</span> | |
| 133 | + </div> | |
| 134 | + <code | |
| 135 | + :if={@registry_host} | |
| 136 | + class="block bg-base-200 rounded px-2 py-1 select-all text-[11px]" | |
| 137 | + >podman pull {pull_ref(@handle, @repo, pkg, v.version, @registry_host)}</code> | |
| 122 | 138 | </li> |
| 123 | 139 | <li :if={Map.get(@versions_by_id, pkg.id, []) == []} class="opacity-60"> |
| 124 | 140 | No versions. |
modified
lib/git_gud_web/router.ex
+1
−0
@@ -165,6 +165,7 @@ defmodule GitGudWeb.Router do
| 165 | 165 | live "/r/:owner/:name/settings/branches", BranchProtectionLive.Index, :index |
| 166 | 166 | live "/r/:owner/:name/settings/interaction", InteractionPolicyLive.Edit, :edit |
| 167 | 167 | live "/r/:owner/:name/settings/secrets", SecretsLive.Repo, :index |
| 168 | + live "/r/:owner/:name/settings/registry-tokens", RegistryTokenLive.Index, :index | |
| 168 | 169 | live "/r/:owner/:name/settings/runners", RepoLive.SettingsRunners, :index |
| 169 | 170 | live "/r/:owner/:name/settings", RepoLive.Settings, :edit |
| 170 | 171 | live "/orgs/:handle/settings/profile", OrgLive.SettingsProfile, :index |
added
priv/repo/migrations/20260625010000_create_registry_tokens.exs
+18
−0
@@ -0,0 +1,18 @@
| 1 | +defmodule GitGud.Repo.Migrations.CreateRegistryTokens do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + def change do | |
| 5 | + create table(:registry_tokens) do | |
| 6 | + add :repository_id, references(:repositories, on_delete: :delete_all), null: false | |
| 7 | + add :name, :string, null: false | |
| 8 | + add :token_hash, :binary, null: false | |
| 9 | + add :scopes, {:array, :string}, null: false, default: ["pull"] | |
| 10 | + add :last_used_at, :utc_datetime | |
| 11 | + | |
| 12 | + timestamps(type: :utc_datetime) | |
| 13 | + end | |
| 14 | + | |
| 15 | + create unique_index(:registry_tokens, [:repository_id, :name]) | |
| 16 | + create unique_index(:registry_tokens, [:token_hash]) | |
| 17 | + end | |
| 18 | +end |
Parents: e5029ee