Runners: a registration token registers one runner and expires

8417f8f · Gabriel Morell · 2026-10-20 18:18

27 files +237 -51
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/organizations/organization.ex
+1 −0
@@ -16,6 +16,7 @@ defmodule GitGud.Organizations.Organization do
16 16 field :visibility, :string, default: "public"
17 17 # See `GitGud.Repositories.Repository.runner_reg_token_hash`.
18 18 field :runner_reg_token_hash, :binary
19 + field :runner_reg_token_expires_at, :utc_datetime
19 20
20 21 field :display_name, :string
21 22 field :bio, :string
modified lib/git_gud/repositories/repository.ex
+3 −1
@@ -36,8 +36,10 @@ defmodule GitGud.Repositories.Repository do
36 36 field :block_self_approval, :boolean, default: true
37 37 # SHA-256 of the current runner registration token, if any.
38 38 # Regenerating the token replaces the hash, invalidating the old
39 − # token immediately.
39 + # token immediately; registering a runner with it clears it, and it
40 + # stops working at `runner_reg_token_expires_at` either way.
40 41 field :runner_reg_token_hash, :binary
42 + field :runner_reg_token_expires_at, :utc_datetime
41 43 # Set when the repo was imported from a remote git URL. Kept so the
42 44 # repo can be re-fetched from upstream on demand; never cast from
43 45 # user input.
modified lib/git_gud/workflows/runners.ex
+74 −31
@@ -8,7 +8,8 @@ defmodule GitGud.Workflows.Runners do
8 8 settings UI; the resulting runner is scoped to that repo or org.
9 9 Stored as a SHA-256 hash on `repositories.runner_reg_token_hash`
10 10 or `organizations.runner_reg_token_hash`. Regenerating shifts the
11 − hash, invalidating the old token in one step.
11 + hash, invalidating the old token in one step. A token registers
12 + one runner and expires a day after it's minted.
12 13
13 14 2. **Global env token** (`GITGUD_RUNNER_REG_TOKEN`) — legacy path
14 15 for operator-managed pool runners. Resulting runner has neither
@@ -45,22 +46,34 @@ defmodule GitGud.Workflows.Runners do
45 46 defp global_token_valid?(_), do: false
46 47
47 48 @doc """
48 − Resolve a registration token to its scope. Returns:
49 + Redeem a registration token: resolve it to its scope and, for a
50 + repository's or organization's token, use it up. Returns:
49 51
50 52 * `{:ok, :global}` — legacy global token from env / config
51 53 * `{:ok, %Repository{}}` — repo-scoped token
52 54 * `{:ok, %Organization{}}` — org-scoped token
53 − * `:error` — unknown / invalid token
55 + * `:error` — unknown, expired or already used
56 +
57 + A scoped token is good for one runner, within
58 + `registration_token_ttl_seconds/0` of being minted. Redeeming it is a
59 + single `UPDATE … WHERE hash = … AND expires_at > now()` that clears
60 + the hash, so two registrations racing on one token can't both win.
61 + Run it inside the transaction that creates the runner (as
62 + `register_with_token/2` does) so a registration that fails afterwards
63 + doesn't burn the token.
64 +
65 + The global token is the operator's, for a pool of runners: it neither
66 + expires nor wears out.
54 67 """
55 68 def consume_registration_token(token) when is_binary(token) and token != "" do
56 69 cond do
57 70 global_token_valid?(token) ->
58 71 {:ok, :global}
59 72
60 − repo = lookup_repo_token(token) ->
73 + repo = redeem(Repository, token) ->
61 74 {:ok, repo}
62 75
63 − org = lookup_org_token(token) ->
76 + org = redeem(Organization, token) ->
64 77 {:ok, org}
65 78
66 79 true ->
@@ -70,45 +83,73 @@ defmodule GitGud.Workflows.Runners do
70 83
71 84 def consume_registration_token(_), do: :error
72 85
73 − defp lookup_repo_token(token) do
74 − hash = token_hash(token)
75 − Repo.get_by(Repository, runner_reg_token_hash: hash)
86 + @doc """
87 + Redeem `token` and register a runner with `attrs` in its scope, all or
88 + nothing. `{:ok, runner, bearer}`, `:error` for a bad token, or
89 + `{:error, changeset}`.
90 + """
91 + def register_with_token(token, attrs) do
92 + Repo.transact(fn ->
93 + with {:ok, scope} <- consume_registration_token(token),
94 + {:ok, runner, bearer} <- register(attrs, scope) do
95 + {:ok, {runner, bearer}}
96 + else
97 + :error -> {:error, :bad_token}
98 + {:error, _} = err -> err
99 + end
100 + end)
101 + |> case do
102 + {:ok, {runner, bearer}} -> {:ok, runner, bearer}
103 + {:error, :bad_token} -> :error
104 + {:error, _} = err -> err
105 + end
76 106 end
77 107
78 − defp lookup_org_token(token) do
108 + defp redeem(schema, token) do
79 109 hash = token_hash(token)
80 − Repo.get_by(Organization, runner_reg_token_hash: hash)
110 + now = DateTime.utc_now(:second)
111 +
112 + from(s in schema,
113 + where: s.runner_reg_token_hash == ^hash and s.runner_reg_token_expires_at > ^now,
114 + select: s
115 + )
116 + |> Repo.update_all(set: [runner_reg_token_hash: nil, runner_reg_token_expires_at: nil])
117 + |> case do
118 + {1, [row]} -> row
119 + _ -> nil
120 + end
81 121 end
82 122
83 123 defp token_hash(token), do: :crypto.hash(:sha256, token)
84 124
125 + @doc """
126 + How long a minted registration token stays good, in seconds:
127 +
128 + config :git_gud, GitGud.Workflows.Runners, registration_token_ttl_seconds: 86_400
129 + """
130 + def registration_token_ttl_seconds do
131 + Application.get_env(:git_gud, __MODULE__, [])
132 + |> Keyword.get(:registration_token_ttl_seconds, 86_400)
133 + end
134 +
85 135 @doc """
86 136 Generate (or regenerate) a registration token for a scope. Returns
87 137 `{:ok, bare_token}` — the bare token is **shown once**, only the
88 − hash is persisted.
138 + hash is persisted. It registers one runner, within
139 + `registration_token_ttl_seconds/0`.
89 140
90 141 Regenerating is destructive — any previously-distributed token for
91 142 that scope stops working immediately.
92 143 """
93 − def mint_registration_token(%Repository{} = repo) do
94 − token = mint_token()
95 − hash = token_hash(token)
96 −
97 − repo
98 − |> Ecto.Changeset.change(runner_reg_token_hash: hash)
99 − |> Repo.update()
100 − |> case do
101 − {:ok, _} -> {:ok, token}
102 − err -> err
103 − end
104 − end
105 −
106 − def mint_registration_token(%Organization{} = org) do
144 + def mint_registration_token(%scope{} = record) when scope in [Repository, Organization] do
107 145 token = mint_token()
108 − hash = token_hash(token)
146 + expires_at = DateTime.add(DateTime.utc_now(:second), registration_token_ttl_seconds())
109 147
110 − org
111 − |> Ecto.Changeset.change(runner_reg_token_hash: hash)
148 + record
149 + |> Ecto.Changeset.change(
150 + runner_reg_token_hash: token_hash(token),
151 + runner_reg_token_expires_at: expires_at
152 + )
112 153 |> Repo.update()
113 154 |> case do
114 155 {:ok, _} -> {:ok, token}
@@ -118,9 +159,11 @@ defmodule GitGud.Workflows.Runners do
118 159
119 160 defp mint_token, do: :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false)
120 161
121 − @doc "True if `scope` currently has a registration token minted."
122 − def has_token?(%Repository{runner_reg_token_hash: h}), do: not is_nil(h)
123 − def has_token?(%Organization{runner_reg_token_hash: h}), do: not is_nil(h)
162 + @doc "True if `scope` has a registration token that is still good."
163 + def has_token?(%{runner_reg_token_hash: h, runner_reg_token_expires_at: %DateTime{} = exp})
164 + when is_binary(h),
165 + do: DateTime.compare(exp, DateTime.utc_now()) == :gt
166 +
124 167 def has_token?(_), do: false
125 168
126 169 # ── runner CRUD ──────────────────────────────────────────────────────
modified lib/git_gud_web/controllers/runner_controller.ex
+17 −17
@@ -15,8 +15,9 @@ defmodule GitGudWeb.RunnerController do
15 15 etc.).
16 16
17 17 Auth: all methods except Ping require `Authorization: Bearer <token>`.
18 − Register additionally requires the one-shot registration token in the
19 − body, mirroring `forgejo-runner register --token`.
18 + Register instead takes a registration token in the body, mirroring
19 + `forgejo-runner register --token`: a repository's or organization's
20 + is good for one runner and expires; the operator's global one is not.
20 21 """
21 22
22 23 use GitGudWeb, :controller
@@ -70,24 +71,23 @@ defmodule GitGudWeb.RunnerController do
70 71 error(conn, :invalid_argument, "runner name is required")
71 72
72 73 true ->
73 − case Runners.consume_registration_token(req.token) do
74 + attrs = %{
75 + name: req.name,
76 + version: req.version,
77 + labels: %{"labels" => merged_labels(req)}
78 + }
79 +
80 + # A repository's or organization's token registers one runner
81 + # and expires; see `Runners.consume_registration_token/1`.
82 + case Runners.register_with_token(req.token, attrs) do
74 83 :error ->
75 − error(conn, :unauthenticated, "invalid registration token")
84 + error(conn, :unauthenticated, "invalid, expired or already used registration token")
76 85
77 − {:ok, scope} ->
78 − attrs = %{
79 − name: req.name,
80 − version: req.version,
81 − labels: %{"labels" => merged_labels(req)}
82 − }
86 + {:ok, runner, token} ->
87 + respond(conn, %RegisterResponse{runner: render_runner_proto(runner, token)})
83 88
84 − case Runners.register(attrs, scope) do
85 − {:ok, runner, token} ->
86 − respond(conn, %RegisterResponse{runner: render_runner_proto(runner, token)})
87 −
88 − {:error, cs} ->
89 − error(conn, :invalid_argument, format_errors(cs))
90 − end
89 + {:error, cs} ->
90 + error(conn, :invalid_argument, format_errors(cs))
91 91 end
92 92 end
93 93 end
modified lib/git_gud_web/live/org_live/settings_runners.ex
+13 −1
@@ -203,6 +203,9 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
203 203 class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
204 204 phx-no-curly-interpolation
205 205 ><%= @new_token %></pre>
206 + <p class="text-xs opacity-70" id="reg-token-terms">
207 + Registers one runner, within {token_ttl_label()}.
208 + </p>
206 209 <p class="text-xs opacity-70">Pick a deployment style:</p>
207 210 <.runner_register_commands
208 211 token={@new_token}
@@ -212,7 +215,7 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
212 215 </div>
213 216 <% Runners.has_token?(@org) -> %>
214 217 <p class="text-sm opacity-70">
215 − Regenerate to invalidate the previously-distributed token.
218 + An unused token is outstanding until {format_expiry(@org)}. Regenerate to invalidate it.
216 219 </p>
217 220 <% true -> %>
218 221 <p class="text-sm opacity-70">
@@ -291,4 +294,13 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
291 294
292 295 defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
293 296 defp format_dt(_), do: ""
297 +
298 + defp format_expiry(scope), do: format_dt(scope.runner_reg_token_expires_at) <> " UTC"
299 +
300 + defp token_ttl_label do
301 + case Runners.registration_token_ttl_seconds() do
302 + s when rem(s, 3600) == 0 -> "#{div(s, 3600)} hours"
303 + s -> "#{div(s, 60)} minutes"
304 + end
305 + end
294 306 end
modified lib/git_gud_web/live/repo_live/settings_runners.ex
+13 −1
@@ -232,6 +232,9 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
232 232 class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
233 233 phx-no-curly-interpolation
234 234 ><%= @new_token %></pre>
235 + <p class="text-xs opacity-70" id="reg-token-terms">
236 + Registers one runner, within {token_ttl_label()}.
237 + </p>
235 238 <p class="text-xs opacity-70">
236 239 Pick a deployment style:
237 240 </p>
@@ -243,7 +246,7 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
243 246 </div>
244 247 <% Runners.has_token?(@repo) -> %>
245 248 <p class="text-sm opacity-70">
246 − A token has been minted previously. Regenerate to invalidate the old token.
249 + An unused token is outstanding until {format_expiry(@repo)}. Regenerate to invalidate it.
247 250 </p>
248 251 <% true -> %>
249 252 <p class="text-sm opacity-70">
@@ -327,4 +330,13 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
327 330
328 331 defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
329 332 defp format_dt(_), do: ""
333 +
334 + defp format_expiry(scope), do: format_dt(scope.runner_reg_token_expires_at) <> " UTC"
335 +
336 + defp token_ttl_label do
337 + case Runners.registration_token_ttl_seconds() do
338 + s when rem(s, 3600) == 0 -> "#{div(s, 3600)} hours"
339 + s -> "#{div(s, 60)} minutes"
340 + end
341 + end
330 342 end
added priv/repo/migrations/20261020181557_add_runner_reg_token_expiry.exs
+34 −0
@@ -0,0 +1,34 @@
1 +defmodule GitGud.Repo.Migrations.AddRunnerRegTokenExpiry do
2 + use Ecto.Migration
3 +
4 + # A repository's or organization's runner registration token used to be
5 + # good forever and for any number of runners. Now it expires
6 + # (`GitGud.Workflows.Runners`, 24h by default) and registers one runner.
7 + # A token minted before this gets a day from the migration rather than
8 + # stopping on the spot.
9 + def up do
10 + for table <- [:repositories, :organizations] do
11 + alter table(table) do
12 + add :runner_reg_token_expires_at, :utc_datetime
13 + end
14 + end
15 +
16 + flush()
17 +
18 + for table <- ~w(repositories organizations) do
19 + execute """
20 + UPDATE #{table}
21 + SET runner_reg_token_expires_at = date_trunc('second', now() AT TIME ZONE 'utc') + interval '1 day'
22 + WHERE runner_reg_token_hash IS NOT NULL
23 + """
24 + end
25 + end
26 +
27 + def down do
28 + for table <- [:repositories, :organizations] do
29 + alter table(table) do
30 + remove :runner_reg_token_expires_at
31 + end
32 + end
33 + end
34 +end
modified test/git_gud/workflows/scoped_runners_test.exs
+63 −0
@@ -34,6 +34,69 @@ defmodule GitGud.Workflows.ScopedRunnersTest do
34 34 test "an unknown token is rejected" do
35 35 assert :error = Runners.consume_registration_token("definitely-not-a-token")
36 36 end
37 +
38 + test "a token registers one runner", %{repo: repo} do
39 + {:ok, token} = Runners.mint_registration_token(repo)
40 + assert Runners.has_token?(GitGud.Repo.reload!(repo))
41 +
42 + assert {:ok, _runner, _} = Runners.register_with_token(token, %{name: "first"})
43 + assert :error = Runners.register_with_token(token, %{name: "second"})
44 + refute Runners.has_token?(GitGud.Repo.reload!(repo))
45 + end
46 +
47 + test "an organization's token registers one runner too" do
48 + user = GitGud.AccountsFixtures.user_fixture()
49 +
50 + {:ok, org} =
51 + GitGud.Organizations.create_organization(user, %{
52 + "handle" => "org#{System.unique_integer([:positive])}"
53 + })
54 +
55 + {:ok, token} = Runners.mint_registration_token(org)
56 +
57 + assert {:ok, %GitGud.Organizations.Organization{}} =
58 + Runners.consume_registration_token(token)
59 +
60 + assert :error = Runners.consume_registration_token(token)
61 + end
62 +
63 + test "a token expires", %{repo: repo} do
64 + {:ok, token} = Runners.mint_registration_token(repo)
65 +
66 + repo
67 + |> GitGud.Repo.reload!()
68 + |> Ecto.Changeset.change(
69 + runner_reg_token_expires_at: DateTime.add(DateTime.utc_now(:second), -1)
70 + )
71 + |> GitGud.Repo.update!()
72 +
73 + refute Runners.has_token?(GitGud.Repo.reload!(repo))
74 + assert :error = Runners.register_with_token(token, %{name: "late"})
75 + end
76 +
77 + test "expires after the configured time", %{repo: repo} do
78 + Application.put_env(:git_gud, Runners, registration_token_ttl_seconds: 600)
79 + on_exit(fn -> Application.delete_env(:git_gud, Runners) end)
80 +
81 + {:ok, _token} = Runners.mint_registration_token(repo)
82 + exp = GitGud.Repo.reload!(repo).runner_reg_token_expires_at
83 + assert_in_delta DateTime.diff(exp, DateTime.utc_now()), 600, 5
84 + end
85 +
86 + test "a registration that fails doesn't use the token up", %{repo: repo} do
87 + {:ok, token} = Runners.mint_registration_token(repo)
88 +
89 + assert {:error, %Ecto.Changeset{}} = Runners.register_with_token(token, %{name: ""})
90 + assert {:ok, _runner, _} = Runners.register_with_token(token, %{name: "retry"})
91 + end
92 +
93 + test "the global token stays good for a pool" do
94 + Application.put_env(:git_gud, Runners, registration_token: "pool-token")
95 + on_exit(fn -> Application.delete_env(:git_gud, Runners) end)
96 +
97 + assert {:ok, _, _} = Runners.register_with_token("pool-token", %{name: "a"})
98 + assert {:ok, _, _} = Runners.register_with_token("pool-token", %{name: "b"})
99 + end
37 100 end
38 101
39 102 describe "register/2 with scope" do
modified test/git_gud_web/controllers/runner_controller_test.exs
+19 −0
@@ -84,6 +84,25 @@ defmodule GitGudWeb.RunnerControllerTest do
84 84
85 85 assert resp.status == 401
86 86 end
87 +
88 + test "a repository's token registers one runner", %{conn: conn} do
89 + {_user, repo} = GitGud.ForgeFixtures.repository_fixture()
90 + {:ok, reg} = GitGud.Workflows.Runners.mint_registration_token(repo)
91 +
92 + register = fn name ->
93 + body =
94 + %RegisterRequest{name: name, token: reg}
95 + |> RegisterRequest.encode()
96 + |> IO.iodata_to_binary()
97 +
98 + conn
99 + |> put_req_header("content-type", "application/protobuf")
100 + |> post("/api/actions/_apis/runner.v1.RunnerService/Register", body)
101 + end
102 +
103 + assert register.("one").status == 200
104 + assert register.("two").status == 401
105 + end
87 106 end
88 107
89 108 describe "built-in CI vars" do

Parents: 0d2522e