Add repo-scoped registry deploy tokens + podman pull in packages view

2e7dd83 · gmorell · 2026-06-25 20:16

8 files +340 -11
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 tokenslong-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