neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Keep the OpenBao token alive
bc4a319 · Gabriel Morell · 2026-09-12 16:45
Message
{commit_body(@commit)}
Files changed
modified
config/runtime.exs
+5
−0
@@ -190,6 +190,11 @@ if config_env() == :prod do
| 190 | 190 | config :git_gud, GitGud.Packages.Signing.OpenBao, |
| 191 | 191 | address: bao_addr, |
| 192 | 192 | token: System.get_env("OPENBAO_TOKEN"), |
| 193 | + # Set OPENBAO_ROLE to use the Kubernetes auth method instead: the | |
| 194 | + # app logs in with its ServiceAccount JWT and renews a short-lived | |
| 195 | + # token itself, so no static token has to be minted or rotated. | |
| 196 | + role: System.get_env("OPENBAO_ROLE"), | |
| 197 | + auth_mount: System.get_env("OPENBAO_AUTH_MOUNT", "kubernetes"), | |
| 193 | 198 | mount: System.get_env("OPENBAO_TRANSIT_MOUNT", "transit"), |
| 194 | 199 | key_type: System.get_env("OPENBAO_KEY_TYPE", "rsa-4096"), |
| 195 | 200 | key_prefix: System.get_env("OPENBAO_KEY_PREFIX", "gitgud") |
modified
lib/git_gud/application.ex
+3
−0
@@ -33,6 +33,9 @@ defmodule GitGud.Application do
| 33 | 33 | GitGud.Cache, |
| 34 | 34 | {Oban, Application.fetch_env!(:git_gud, Oban)}, |
| 35 | 35 | GitGud.Federation.RateLimiter, |
| 36 | + # Holds (and renews) the OpenBao token the F-Droid signer | |
| 37 | + # uses. Idle unless OpenBao is configured. | |
| 38 | + GitGud.Packages.Signing.OpenBao.Session, | |
| 36 | 39 | # Ad-hoc background work kicked off by a request or a live |
| 37 | 40 | # view — see GitGudWeb.RepoLive.EntryTimes. |
| 38 | 41 | {Task.Supervisor, name: GitGud.TaskSupervisor}, |
modified
lib/git_gud/packages/signing/open_bao.ex
+8
−6
@@ -29,11 +29,11 @@ defmodule GitGud.Packages.Signing.OpenBao do
| 29 | 29 | key_type: "rsa-4096", |
| 30 | 30 | key_prefix: "gitgud" |
| 31 | 31 | |
| 32 | − Auth is a static token read from a Kubernetes Secret, which is how | |
| 33 | − every other credential in this deployment already arrives. The | |
| 34 | − Kubernetes auth method — the app logging in with its service-account | |
| 35 | − JWT for a short-lived token — is the hardening step after this, and | |
| 36 | − changes nothing above `request/4`. | |
| 32 | + Auth comes from `GitGud.Packages.Signing.OpenBao.Session`, which | |
| 33 | + either logs in with the pod's ServiceAccount JWT (`:role` set, a | |
| 34 | + short-lived token it renews itself) or carries the static token from | |
| 35 | + a Kubernetes Secret, renewing that before its period lapses. Either | |
| 36 | + way this module just asks for the current token. | |
| 37 | 37 | |
| 38 | 38 | ## Key naming |
| 39 | 39 |
@@ -213,7 +213,9 @@ defmodule GitGud.Packages.Signing.OpenBao do
| 213 | 213 | |
| 214 | 214 | defp mount, do: config(:mount, @default_mount) |
| 215 | 215 | defp address, do: config(:address, nil) |> trim_slash() |
| 216 | − defp token, do: config(:token, nil) | |
| 216 | + # Not `config(:token, …)`: the session owns acquisition and renewal, | |
| 217 | + # and with Kubernetes auth there is no configured token to read. | |
| 218 | + defp token, do: GitGud.Packages.Signing.OpenBao.Session.token() | |
| 217 | 219 | |
| 218 | 220 | defp trim_slash(nil), do: nil |
| 219 | 221 | defp trim_slash(url), do: String.trim_trailing(url, "/") |
added
lib/git_gud/packages/signing/open_bao/session.ex
+302
−0
@@ -0,0 +1,302 @@
| 1 | +defmodule GitGud.Packages.Signing.OpenBao.Session do | |
| 2 | + @moduledoc """ | |
| 3 | + Holds the OpenBao token the signer uses, and keeps it alive. | |
| 4 | + | |
| 5 | + Two ways in, and the difference is whether a human has to remember | |
| 6 | + something: | |
| 7 | + | |
| 8 | + * **Kubernetes auth** (`:role` set) — the app logs in with its own | |
| 9 | + ServiceAccount JWT and gets a short-lived token back. Nothing is | |
| 10 | + stored anywhere, the credential is the pod's identity, and a | |
| 11 | + leaked token expires on its own. This is the one to use. | |
| 12 | + | |
| 13 | + * **A static token** (`:token` set) — what the first deploy did, | |
| 14 | + because it needs no auth method configured. A `-period=768h` | |
| 15 | + token is renewable rather than eternal, so this process renews it | |
| 16 | + before it lapses. Without that, F-Droid signing breaks 32 days | |
| 17 | + after a deploy, at a moment unrelated to anything anyone changed. | |
| 18 | + | |
| 19 | + Renewal happens at two thirds of the lease, so a failure still has a | |
| 20 | + third of the lease to retry in rather than being the last word. Under | |
| 21 | + Kubernetes auth a failed renewal falls back to logging in again; a | |
| 22 | + static token has no such move, so it keeps the token it has and | |
| 23 | + retries — that token is valid until it isn't. | |
| 24 | + | |
| 25 | + Started unconditionally but idle unless OpenBao is configured: no | |
| 26 | + address, no work, and no state beyond an empty struct. | |
| 27 | + """ | |
| 28 | + | |
| 29 | + use GenServer | |
| 30 | + | |
| 31 | + require Logger | |
| 32 | + | |
| 33 | + # Renew at two thirds of the lease. Earlier wastes calls; later leaves | |
| 34 | + # no room to retry. | |
| 35 | + @renew_fraction 2 / 3 | |
| 36 | + | |
| 37 | + # Kubernetes projects the pod's ServiceAccount token here. | |
| 38 | + @jwt_path "/var/run/secrets/kubernetes.io/serviceaccount/token" | |
| 39 | + | |
| 40 | + defstruct token: nil, expires_at: nil, timer: nil | |
| 41 | + | |
| 42 | + @doc false | |
| 43 | + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) | |
| 44 | + | |
| 45 | + @doc """ | |
| 46 | + The token to present to OpenBao, or `nil` when it isn't configured. | |
| 47 | + | |
| 48 | + Logs in on first ask rather than at boot: an instance that never | |
| 49 | + serves an F-Droid repository should never talk to OpenBao at all, and | |
| 50 | + a boot that waits on a sealed OpenBao is a boot that fails for the | |
| 51 | + wrong reason. | |
| 52 | + """ | |
| 53 | + @spec token() :: String.t() | nil | |
| 54 | + def token do | |
| 55 | + case GenServer.whereis(__MODULE__) do | |
| 56 | + nil -> static_token() | |
| 57 | + _pid -> GenServer.call(__MODULE__, :token, 20_000) | |
| 58 | + end | |
| 59 | + end | |
| 60 | + | |
| 61 | + @doc "Drop the cached token, so the next ask acquires a new one." | |
| 62 | + @spec invalidate() :: :ok | |
| 63 | + def invalidate do | |
| 64 | + case GenServer.whereis(__MODULE__) do | |
| 65 | + nil -> :ok | |
| 66 | + _pid -> GenServer.cast(__MODULE__, :invalidate) | |
| 67 | + end | |
| 68 | + end | |
| 69 | + | |
| 70 | + @impl true | |
| 71 | + def init(_opts), do: {:ok, %__MODULE__{}} | |
| 72 | + | |
| 73 | + @impl true | |
| 74 | + def handle_call(:token, _from, state) do | |
| 75 | + case ensure_token(state) do | |
| 76 | + {:ok, state} -> {:reply, state.token, state} | |
| 77 | + {:error, reason, state} -> {:reply, nil, log_failure(reason, state)} | |
| 78 | + end | |
| 79 | + end | |
| 80 | + | |
| 81 | + @impl true | |
| 82 | + def handle_cast(:invalidate, state), do: {:noreply, clear(state)} | |
| 83 | + | |
| 84 | + @impl true | |
| 85 | + def handle_info(:renew, state) do | |
| 86 | + case renew(state) do | |
| 87 | + {:ok, state} -> | |
| 88 | + {:noreply, state} | |
| 89 | + | |
| 90 | + {:error, reason, state} -> | |
| 91 | + Logger.warning("openbao: renewal failed (#{inspect(reason)})") | |
| 92 | + | |
| 93 | + if role() do | |
| 94 | + {_result, state} = login_or_log(clear(state)) | |
| 95 | + {:noreply, state} | |
| 96 | + else | |
| 97 | + {:noreply, schedule(state, retry_after(state))} | |
| 98 | + end | |
| 99 | + end | |
| 100 | + end | |
| 101 | + | |
| 102 | + def handle_info(_msg, state), do: {:noreply, state} | |
| 103 | + | |
| 104 | + # ── acquiring ──────────────────────────────────────────────────────── | |
| 105 | + | |
| 106 | + defp ensure_token(%__MODULE__{token: token} = state) when is_binary(token) do | |
| 107 | + if expired?(state), do: acquire(clear(state)), else: {:ok, state} | |
| 108 | + end | |
| 109 | + | |
| 110 | + defp ensure_token(state), do: acquire(state) | |
| 111 | + | |
| 112 | + defp acquire(state) do | |
| 113 | + cond do | |
| 114 | + is_nil(address()) -> {:error, :not_configured, state} | |
| 115 | + role() -> login(state) | |
| 116 | + static_token() -> introspect(state, static_token()) | |
| 117 | + true -> {:error, :not_configured, state} | |
| 118 | + end | |
| 119 | + end | |
| 120 | + | |
| 121 | + defp login(state) do | |
| 122 | + with {:ok, jwt} <- read_jwt(), | |
| 123 | + {:ok, %{"auth" => auth}} <- | |
| 124 | + post("/v1/auth/#{auth_mount()}/login", %{role: role(), jwt: jwt}, nil) do | |
| 125 | + {:ok, store(state, auth["client_token"], auth["lease_duration"])} | |
| 126 | + else | |
| 127 | + {:error, reason} -> {:error, reason, state} | |
| 128 | + {:ok, _unexpected} -> {:error, :unexpected_response, state} | |
| 129 | + end | |
| 130 | + end | |
| 131 | + | |
| 132 | + # A static token's lease has to be asked for: the Secret holds the | |
| 133 | + # token, not its expiry. | |
| 134 | + defp introspect(state, token) do | |
| 135 | + case post("/v1/auth/token/lookup-self", nil, token) do | |
| 136 | + {:ok, %{"data" => data}} -> | |
| 137 | + {:ok, store(state, token, data["ttl"])} | |
| 138 | + | |
| 139 | + {:error, reason} -> | |
| 140 | + # Can't read its own lease — use it anyway rather than refusing | |
| 141 | + # to sign. `lookup-self` is granted by the default policy, but a | |
| 142 | + # policy can be edited. | |
| 143 | + Logger.warning("openbao: token lookup failed (#{inspect(reason)}); using it unrenewed") | |
| 144 | + {:ok, %{state | token: token, expires_at: nil}} | |
| 145 | + | |
| 146 | + {:ok, _} -> | |
| 147 | + {:ok, %{state | token: token, expires_at: nil}} | |
| 148 | + end | |
| 149 | + end | |
| 150 | + | |
| 151 | + defp renew(%__MODULE__{token: nil} = state), do: {:error, :no_token, state} | |
| 152 | + | |
| 153 | + defp renew(%__MODULE__{token: token} = state) do | |
| 154 | + case post("/v1/auth/token/renew-self", %{}, token) do | |
| 155 | + {:ok, %{"auth" => auth}} -> {:ok, store(state, token, auth["lease_duration"])} | |
| 156 | + {:ok, _} -> {:error, :unexpected_response, state} | |
| 157 | + {:error, reason} -> {:error, reason, state} | |
| 158 | + end | |
| 159 | + end | |
| 160 | + | |
| 161 | + defp login_or_log(state) do | |
| 162 | + case login(state) do | |
| 163 | + {:ok, state} -> {:ok, state} | |
| 164 | + {:error, reason, state} -> {:error, log_failure(reason, state)} | |
| 165 | + end | |
| 166 | + end | |
| 167 | + | |
| 168 | + # ── lease bookkeeping ──────────────────────────────────────────────── | |
| 169 | + | |
| 170 | + defp store(state, token, lease) when is_binary(token) do | |
| 171 | + state = cancel_timer(state) | |
| 172 | + lease = normalize_lease(lease) | |
| 173 | + | |
| 174 | + state = %{ | |
| 175 | + state | |
| 176 | + | token: token, | |
| 177 | + expires_at: lease && DateTime.add(DateTime.utc_now(), lease, :second) | |
| 178 | + } | |
| 179 | + | |
| 180 | + if lease, do: schedule(state, round(lease * @renew_fraction)), else: state | |
| 181 | + end | |
| 182 | + | |
| 183 | + defp store(state, _token, _lease), do: state | |
| 184 | + | |
| 185 | + # A zero or missing lease means "does not expire" (a root token, or | |
| 186 | + # one issued with no TTL) — nothing to renew. | |
| 187 | + defp normalize_lease(lease) when is_integer(lease) and lease > 0, do: lease | |
| 188 | + defp normalize_lease(_), do: nil | |
| 189 | + | |
| 190 | + defp schedule(state, seconds) do | |
| 191 | + seconds = max(seconds, 1) | |
| 192 | + %{state | timer: Process.send_after(self(), :renew, seconds * 1000)} | |
| 193 | + end | |
| 194 | + | |
| 195 | + defp retry_after(%__MODULE__{expires_at: nil}), do: 300 | |
| 196 | + | |
| 197 | + defp retry_after(%__MODULE__{expires_at: expires_at}) do | |
| 198 | + expires_at | |
| 199 | + |> DateTime.diff(DateTime.utc_now()) | |
| 200 | + |> div(3) | |
| 201 | + |> max(30) | |
| 202 | + |> min(3600) | |
| 203 | + end | |
| 204 | + | |
| 205 | + defp expired?(%__MODULE__{expires_at: nil}), do: false | |
| 206 | + | |
| 207 | + defp expired?(%__MODULE__{expires_at: expires_at}), | |
| 208 | + do: DateTime.compare(DateTime.utc_now(), expires_at) != :lt | |
| 209 | + | |
| 210 | + defp clear(state), do: %{cancel_timer(state) | token: nil, expires_at: nil} | |
| 211 | + | |
| 212 | + defp cancel_timer(%__MODULE__{timer: nil} = state), do: state | |
| 213 | + | |
| 214 | + defp cancel_timer(%__MODULE__{timer: timer} = state) do | |
| 215 | + Process.cancel_timer(timer) | |
| 216 | + %{state | timer: nil} | |
| 217 | + end | |
| 218 | + | |
| 219 | + defp log_failure(:not_configured, state), do: state | |
| 220 | + | |
| 221 | + defp log_failure(reason, state) do | |
| 222 | + Logger.warning("openbao: could not obtain a token (#{inspect(reason)})") | |
| 223 | + state | |
| 224 | + end | |
| 225 | + | |
| 226 | + # ── http ───────────────────────────────────────────────────────────── | |
| 227 | + | |
| 228 | + defp post(path, body, token) do | |
| 229 | + req = | |
| 230 | + Req.new( | |
| 231 | + method: :post, | |
| 232 | + url: address() <> path, | |
| 233 | + json: body, | |
| 234 | + headers: token_headers(token), | |
| 235 | + receive_timeout: 15_000, | |
| 236 | + retry: :safe_transient | |
| 237 | + ) | |
| 238 | + |> Req.merge(Application.get_env(:git_gud, :openbao_req_options, [])) | |
| 239 | + | |
| 240 | + case Req.request(req) do | |
| 241 | + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> | |
| 242 | + {:ok, body || %{}} | |
| 243 | + | |
| 244 | + {:ok, %Req.Response{status: 403, body: body}} -> | |
| 245 | + {:error, {:denied, errors(body)}} | |
| 246 | + | |
| 247 | + {:ok, %Req.Response{status: 503}} -> | |
| 248 | + {:error, :sealed} | |
| 249 | + | |
| 250 | + {:ok, %Req.Response{status: status, body: body}} -> | |
| 251 | + {:error, {:http, status, errors(body)}} | |
| 252 | + | |
| 253 | + {:error, reason} -> | |
| 254 | + {:error, {:unreachable, reason}} | |
| 255 | + end | |
| 256 | + end | |
| 257 | + | |
| 258 | + defp token_headers(nil), do: [] | |
| 259 | + defp token_headers(token), do: [{"x-vault-token", token}] | |
| 260 | + | |
| 261 | + defp errors(%{"errors" => [_ | _] = errors}), do: Enum.join(errors, "; ") | |
| 262 | + defp errors(body) when is_binary(body), do: String.slice(body, 0, 200) | |
| 263 | + defp errors(body), do: body |> inspect() |> String.slice(0, 200) | |
| 264 | + | |
| 265 | + defp read_jwt do | |
| 266 | + case File.read(jwt_path()) do | |
| 267 | + {:ok, jwt} -> {:ok, String.trim(jwt)} | |
| 268 | + {:error, reason} -> {:error, {:no_service_account_token, reason}} | |
| 269 | + end | |
| 270 | + end | |
| 271 | + | |
| 272 | + # ── config ─────────────────────────────────────────────────────────── | |
| 273 | + | |
| 274 | + defp config(key, default) do | |
| 275 | + Application.get_env(:git_gud, GitGud.Packages.Signing.OpenBao, []) | |
| 276 | + |> Keyword.get(key, default) | |
| 277 | + end | |
| 278 | + | |
| 279 | + defp address do | |
| 280 | + case config(:address, nil) do | |
| 281 | + address when is_binary(address) and address != "" -> String.trim_trailing(address, "/") | |
| 282 | + _ -> nil | |
| 283 | + end | |
| 284 | + end | |
| 285 | + | |
| 286 | + defp role do | |
| 287 | + case config(:role, nil) do | |
| 288 | + role when is_binary(role) and role != "" -> role | |
| 289 | + _ -> nil | |
| 290 | + end | |
| 291 | + end | |
| 292 | + | |
| 293 | + defp static_token do | |
| 294 | + case config(:token, nil) do | |
| 295 | + token when is_binary(token) and token != "" -> token | |
| 296 | + _ -> nil | |
| 297 | + end | |
| 298 | + end | |
| 299 | + | |
| 300 | + defp auth_mount, do: config(:auth_mount, "kubernetes") | |
| 301 | + defp jwt_path, do: config(:jwt_path, @jwt_path) | |
| 302 | +end |
added
test/git_gud/packages/signing_open_bao_session_test.exs
+226
−0
@@ -0,0 +1,226 @@
| 1 | +defmodule GitGud.Packages.Signing.OpenBao.SessionTest do | |
| 2 | + @moduledoc """ | |
| 3 | + How the signer gets a token, and how it keeps one. | |
| 4 | + | |
| 5 | + The interesting cases are the ones an operator only meets 32 days | |
| 6 | + after a deploy: a lease that has to be renewed, a renewal that fails, | |
| 7 | + and a login that can't happen because nothing is configured. | |
| 8 | + """ | |
| 9 | + use ExUnit.Case, async: false | |
| 10 | + | |
| 11 | + alias GitGud.Packages.Signing.OpenBao | |
| 12 | + alias GitGud.Packages.Signing.OpenBao.Session | |
| 13 | + | |
| 14 | + @address "http://openbao.test:8200" | |
| 15 | + | |
| 16 | + setup do | |
| 17 | + on_exit(fn -> | |
| 18 | + Application.delete_env(:git_gud, OpenBao) | |
| 19 | + Application.delete_env(:git_gud, :openbao_req_options) | |
| 20 | + Session.invalidate() | |
| 21 | + end) | |
| 22 | + | |
| 23 | + # The session is a singleton the application starts, so each test | |
| 24 | + # has to begin from "no token cached". | |
| 25 | + Session.invalidate() | |
| 26 | + :ok | |
| 27 | + end | |
| 28 | + | |
| 29 | + describe "with nothing configured" do | |
| 30 | + test "hands back no token and makes no request" do | |
| 31 | + stub(fn _ -> raise "should not have called OpenBao" end) | |
| 32 | + | |
| 33 | + assert Session.token() == nil | |
| 34 | + end | |
| 35 | + | |
| 36 | + test "an address with no credentials is still no token" do | |
| 37 | + configure(address: @address) | |
| 38 | + stub(fn _ -> raise "should not have called OpenBao" end) | |
| 39 | + | |
| 40 | + assert Session.token() == nil | |
| 41 | + end | |
| 42 | + end | |
| 43 | + | |
| 44 | + describe "with a static token" do | |
| 45 | + test "asks for its lease, then reuses the token without asking again" do | |
| 46 | + configure(address: @address, token: "bao-static") | |
| 47 | + | |
| 48 | + stub(fn | |
| 49 | + {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 2_764_800}}) | |
| 50 | + {_conn, path, _} -> raise "unexpected #{path}" | |
| 51 | + end) | |
| 52 | + | |
| 53 | + assert Session.token() == "bao-static" | |
| 54 | + assert_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _} | |
| 55 | + | |
| 56 | + # Cached: a second ask is free. | |
| 57 | + assert Session.token() == "bao-static" | |
| 58 | + refute_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _} | |
| 59 | + end | |
| 60 | + | |
| 61 | + test "renews before the lease runs out, keeping the same token" do | |
| 62 | + configure(address: @address, token: "bao-static") | |
| 63 | + | |
| 64 | + stub(fn | |
| 65 | + {conn, "/v1/auth/token/lookup-self", _} -> | |
| 66 | + json(conn, %{"data" => %{"ttl" => 60}}) | |
| 67 | + | |
| 68 | + {conn, "/v1/auth/token/renew-self", _} -> | |
| 69 | + json(conn, %{"auth" => %{"lease_duration" => 60}}) | |
| 70 | + | |
| 71 | + {_conn, path, _} -> | |
| 72 | + raise "unexpected #{path}" | |
| 73 | + end) | |
| 74 | + | |
| 75 | + assert Session.token() == "bao-static" | |
| 76 | + | |
| 77 | + # Don't wait 40 seconds for the timer; ask for the renewal the | |
| 78 | + # timer would have asked for. | |
| 79 | + send(Process.whereis(Session), :renew) | |
| 80 | + | |
| 81 | + assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000 | |
| 82 | + assert Session.token() == "bao-static" | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "a failed renewal keeps the token it has rather than signing with nothing" do | |
| 86 | + configure(address: @address, token: "bao-static") | |
| 87 | + | |
| 88 | + stub(fn | |
| 89 | + {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 600}}) | |
| 90 | + {conn, "/v1/auth/token/renew-self", _} -> error(conn, 403, "permission denied") | |
| 91 | + {_conn, path, _} -> raise "unexpected #{path}" | |
| 92 | + end) | |
| 93 | + | |
| 94 | + assert Session.token() == "bao-static" | |
| 95 | + | |
| 96 | + send(Process.whereis(Session), :renew) | |
| 97 | + assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000 | |
| 98 | + | |
| 99 | + # Still usable: the token is valid until its own expiry, and | |
| 100 | + # refusing to sign early would be a worse failure than trying. | |
| 101 | + assert Session.token() == "bao-static" | |
| 102 | + end | |
| 103 | + | |
| 104 | + test "a token whose lease can't be read is used anyway" do | |
| 105 | + configure(address: @address, token: "bao-static") | |
| 106 | + | |
| 107 | + stub(fn | |
| 108 | + {conn, "/v1/auth/token/lookup-self", _} -> error(conn, 403, "permission denied") | |
| 109 | + {_conn, path, _} -> raise "unexpected #{path}" | |
| 110 | + end) | |
| 111 | + | |
| 112 | + assert Session.token() == "bao-static" | |
| 113 | + end | |
| 114 | + end | |
| 115 | + | |
| 116 | + describe "with Kubernetes auth" do | |
| 117 | + setup do | |
| 118 | + path = Path.join(System.tmp_dir!(), "sa-token-#{System.unique_integer([:positive])}") | |
| 119 | + File.write!(path, "the-projected-jwt\n") | |
| 120 | + on_exit(fn -> File.rm(path) end) | |
| 121 | + %{jwt_path: path} | |
| 122 | + end | |
| 123 | + | |
| 124 | + test "logs in with the pod's ServiceAccount JWT", ctx do | |
| 125 | + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path) | |
| 126 | + | |
| 127 | + stub(fn | |
| 128 | + {conn, "/v1/auth/kubernetes/login", body} -> | |
| 129 | + # The role and the JWT are the whole credential; a trailing | |
| 130 | + # newline in the projected file would be rejected by OpenBao. | |
| 131 | + assert body["role"] == "gitgud-fdroid" | |
| 132 | + assert body["jwt"] == "the-projected-jwt" | |
| 133 | + | |
| 134 | + json(conn, %{"auth" => %{"client_token" => "bao-short-lived", "lease_duration" => 3600}}) | |
| 135 | + | |
| 136 | + {_conn, path, _} -> | |
| 137 | + raise "unexpected #{path}" | |
| 138 | + end) | |
| 139 | + | |
| 140 | + assert Session.token() == "bao-short-lived" | |
| 141 | + # No static token was needed anywhere. | |
| 142 | + assert_received {:openbao_request, "POST", "/v1/auth/kubernetes/login", _} | |
| 143 | + end | |
| 144 | + | |
| 145 | + test "a rejected login is reported as no token, not a stale one", ctx do | |
| 146 | + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path) | |
| 147 | + | |
| 148 | + stub(fn | |
| 149 | + {conn, "/v1/auth/kubernetes/login", _} -> error(conn, 403, "role not found") | |
| 150 | + {_conn, path, _} -> raise "unexpected #{path}" | |
| 151 | + end) | |
| 152 | + | |
| 153 | + assert Session.token() == nil | |
| 154 | + end | |
| 155 | + | |
| 156 | + test "a missing ServiceAccount token is a clear failure, not a crash", %{jwt_path: path} do | |
| 157 | + File.rm!(path) | |
| 158 | + configure(address: @address, role: "gitgud-fdroid", jwt_path: path) | |
| 159 | + stub(fn _ -> raise "should not have reached the network" end) | |
| 160 | + | |
| 161 | + assert Session.token() == nil | |
| 162 | + end | |
| 163 | + | |
| 164 | + test "a lease that runs out is replaced rather than handed out stale", ctx do | |
| 165 | + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path) | |
| 166 | + | |
| 167 | + stub(fn | |
| 168 | + {conn, "/v1/auth/kubernetes/login", _} -> | |
| 169 | + # Zero lease reads as "no expiry" — so use a 1-second one and | |
| 170 | + # let it pass. | |
| 171 | + json(conn, %{ | |
| 172 | + "auth" => %{ | |
| 173 | + "client_token" => "bao-#{System.unique_integer([:positive])}", | |
| 174 | + "lease_duration" => 1 | |
| 175 | + } | |
| 176 | + }) | |
| 177 | + | |
| 178 | + # The renewal timer fires inside this window too; refusing it | |
| 179 | + # is what forces the login path to be the one that recovers. | |
| 180 | + {conn, "/v1/auth/token/renew-self", _} -> | |
| 181 | + error(conn, 403, "not renewable") | |
| 182 | + | |
| 183 | + {_conn, path, _} -> | |
| 184 | + raise "unexpected #{path}" | |
| 185 | + end) | |
| 186 | + | |
| 187 | + first = Session.token() | |
| 188 | + assert is_binary(first) | |
| 189 | + | |
| 190 | + Process.sleep(1_100) | |
| 191 | + second = Session.token() | |
| 192 | + | |
| 193 | + assert is_binary(second) | |
| 194 | + assert second != first | |
| 195 | + end | |
| 196 | + end | |
| 197 | + | |
| 198 | + # ── helpers ────────────────────────────────────────────────────────── | |
| 199 | + | |
| 200 | + defp configure(opts), do: Application.put_env(:git_gud, OpenBao, opts) | |
| 201 | + | |
| 202 | + defp stub(handler) when is_function(handler, 1) do | |
| 203 | + test = self() | |
| 204 | + | |
| 205 | + Application.put_env(:git_gud, :openbao_req_options, | |
| 206 | + plug: fn conn -> | |
| 207 | + {:ok, raw, conn} = Plug.Conn.read_body(conn) | |
| 208 | + body = if raw == "", do: %{}, else: Jason.decode!(raw) | |
| 209 | + send(test, {:openbao_request, conn.method, conn.request_path, body}) | |
| 210 | + handler.({conn, conn.request_path, body}) | |
| 211 | + end | |
| 212 | + ) | |
| 213 | + end | |
| 214 | + | |
| 215 | + defp json(conn, payload) do | |
| 216 | + conn | |
| 217 | + |> Plug.Conn.put_resp_content_type("application/json") | |
| 218 | + |> Plug.Conn.resp(200, Jason.encode!(payload)) | |
| 219 | + end | |
| 220 | + | |
| 221 | + defp error(conn, status, message) do | |
| 222 | + conn | |
| 223 | + |> Plug.Conn.put_resp_content_type("application/json") | |
| 224 | + |> Plug.Conn.resp(status, Jason.encode!(%{"errors" => [message]})) | |
| 225 | + end | |
| 226 | +end |
modified
test/git_gud/packages/signing_open_bao_test.exs
+25
−6
@@ -13,6 +13,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 13 | 13 | alias GitGud.Accounts.User |
| 14 | 14 | alias GitGud.Packages.Signing |
| 15 | 15 | alias GitGud.Packages.Signing.OpenBao |
| 16 | + alias GitGud.Packages.Signing.OpenBao.Session | |
| 16 | 17 | |
| 17 | 18 | @address "http://openbao.test:8200" |
| 18 | 19 |
@@ -29,8 +30,14 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 29 | 30 | Application.delete_env(:git_gud, OpenBao) |
| 30 | 31 | Application.delete_env(:git_gud, :openbao_req_options) |
| 31 | 32 | Application.delete_env(:git_gud, Signing) |
| 33 | + Session.invalidate() | |
| 32 | 34 | end) |
| 33 | 35 | |
| 36 | + # The session holding the token is a singleton the application | |
| 37 | + # starts, so each test begins from "no token cached" — otherwise | |
| 38 | + # these pass or fail depending on what ran before them. | |
| 39 | + Session.invalidate() | |
| 40 | + | |
| 34 | 41 | %{user: %User{handle: "neiam"}} |
| 35 | 42 | end |
| 36 | 43 |
@@ -44,7 +51,15 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 44 | 51 | {:ok, raw, conn} = Plug.Conn.read_body(conn) |
| 45 | 52 | body = if raw == "", do: %{}, else: Jason.decode!(raw) |
| 46 | 53 | send(test, {:openbao_request, conn.method, conn.request_path, body}) |
| 47 | − handler.({conn, conn.request_path, body}) | |
| 54 | + | |
| 55 | + # Intercepted here rather than in every handler: the session | |
| 56 | + # asks a static token for its lease before the first signature, | |
| 57 | + # and that is not what any of these tests are about. | |
| 58 | + if conn.request_path == "/v1/auth/token/lookup-self" do | |
| 59 | + json(conn, 200, lookup_self_response()) | |
| 60 | + else | |
| 61 | + handler.({conn, conn.request_path, body}) | |
| 62 | + end | |
| 48 | 63 | end |
| 49 | 64 | ) |
| 50 | 65 | end |
@@ -58,14 +73,18 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 58 | 73 | defp signature_response(bytes), |
| 59 | 74 | do: %{"data" => %{"signature" => "vault:v1:" <> Base.encode64(bytes)}} |
| 60 | 75 | |
| 76 | + # The session asks a static token for its own lease before first use. | |
| 77 | + # Answering it here keeps these tests about signing. | |
| 78 | + defp lookup_self_response, do: %{"data" => %{"ttl" => 3600, "renewable" => true}} | |
| 79 | + | |
| 61 | 80 | describe "sign/4" do |
| 62 | 81 | test "asks for PKCS#1 v1.5 over SHA-256 by default", %{user: user} do |
| 63 | 82 | stub(fn {conn, _path, _body} -> json(conn, 200, signature_response("sig-bytes")) end) |
| 64 | 83 | |
| 65 | 84 | assert {:ok, "sig-bytes"} = OpenBao.sign(user, "fdroid", "data to sign", []) |
| 66 | 85 | |
| 67 | − assert_receive {:openbao_request, "POST", path, body} | |
| 68 | − assert path == "/v1/transit/sign/gitgud-fdroid-#{user.handle}" | |
| 86 | + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> key, body} | |
| 87 | + assert key == "gitgud-fdroid-#{user.handle}" | |
| 69 | 88 | assert body["signature_algorithm"] == "pkcs1v15" |
| 70 | 89 | assert body["hash_algorithm"] == "sha2-256" |
| 71 | 90 | assert body["input"] == Base.encode64("data to sign") |
@@ -80,7 +99,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 80 | 99 | |
| 81 | 100 | assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", []) |
| 82 | 101 | |
| 83 | − assert_receive {:openbao_request, "POST", _, body} | |
| 102 | + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body} | |
| 84 | 103 | assert body["prehashed"] == false |
| 85 | 104 | refute body["hash_algorithm"] == "none" |
| 86 | 105 | end |
@@ -90,7 +109,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 90 | 109 | |
| 91 | 110 | assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", digest: :sha1) |
| 92 | 111 | |
| 93 | − assert_receive {:openbao_request, "POST", _, body} | |
| 112 | + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body} | |
| 94 | 113 | assert body["hash_algorithm"] == "sha1" |
| 95 | 114 | end |
| 96 | 115 |
@@ -130,7 +149,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
| 130 | 149 | |
| 131 | 150 | assert {:ok, %{public_pem: ^pem}} = OpenBao.ensure_key(user, "fdroid") |
| 132 | 151 | |
| 133 | − assert_receive {:openbao_request, "POST", _, create} | |
| 152 | + assert_receive {:openbao_request, "POST", "/v1/transit/keys/" <> _, create} | |
| 134 | 153 | assert create["type"] == "rsa-4096" |
| 135 | 154 | # Without this the guarantee this backend claims is just a comment. |
| 136 | 155 | assert create["exportable"] == false |
Parents: ea9cadf