neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
API tokens: last_used_at is written at most once a minute
c1a34d8 · Gabriel Morell · 2026-10-20 20:54
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/accounts.ex
+25
−8
@@ -48,20 +48,21 @@ defmodule GitGud.Accounts do
| 48 | 48 | :ok |
| 49 | 49 | end |
| 50 | 50 | |
| 51 | + # `last_used_at` is to the minute: a client polling with one token | |
| 52 | + # (the plasmoid, `gg ci watch`, the app) would otherwise queue every | |
| 53 | + # request on that row's lock, and one slow write stalls them all. | |
| 54 | + @pat_touch_every 60 | |
| 55 | + | |
| 51 | 56 | @doc """ |
| 52 | − Verify a presented API token. On a match, bumps `last_used_at` and | |
| 53 | − returns `{:ok, %User{}, scopes}`; otherwise `:error`. | |
| 57 | + Verify a presented API token. On a match, bumps `last_used_at` (at | |
| 58 | + most once a minute) and returns `{:ok, %User{}, scopes}`; otherwise | |
| 59 | + `:error`. | |
| 54 | 60 | """ |
| 55 | 61 | def verify_personal_access_token(@pat_prefix <> _ = plaintext) do |
| 56 | 62 | case Repo.get_by(PersonalAccessToken, token_hash: pat_hash(plaintext)) do |
| 57 | 63 | %PersonalAccessToken{} = token -> |
| 58 | 64 | token = Repo.preload(token, :user) |
| 59 | − | |
| 60 | − Repo.update_all( | |
| 61 | − from(t in PersonalAccessToken, where: t.id == ^token.id), | |
| 62 | − set: [last_used_at: DateTime.utc_now(:second)] | |
| 63 | − ) | |
| 64 | − | |
| 65 | + touch_personal_access_token(token) | |
| 65 | 66 | {:ok, token.user, token.scopes} |
| 66 | 67 | |
| 67 | 68 | nil -> |
@@ -71,6 +72,22 @@ defmodule GitGud.Accounts do
| 71 | 72 | |
| 72 | 73 | def verify_personal_access_token(_), do: :error |
| 73 | 74 | |
| 75 | + # The `where` makes racing requests skip the row instead of waiting on | |
| 76 | + # its lock: only one of them matches, the rest see a fresh value. | |
| 77 | + defp touch_personal_access_token(%PersonalAccessToken{id: id, last_used_at: last}) do | |
| 78 | + now = DateTime.utc_now(:second) | |
| 79 | + since = DateTime.add(now, -@pat_touch_every, :second) | |
| 80 | + | |
| 81 | + if is_nil(last) or DateTime.compare(last, since) == :lt do | |
| 82 | + from(t in PersonalAccessToken, | |
| 83 | + where: t.id == ^id and (is_nil(t.last_used_at) or t.last_used_at < ^since) | |
| 84 | + ) | |
| 85 | + |> Repo.update_all(set: [last_used_at: now]) | |
| 86 | + end | |
| 87 | + | |
| 88 | + :ok | |
| 89 | + end | |
| 90 | + | |
| 74 | 91 | defp pat_hash(plaintext), do: :crypto.hash(:sha256, plaintext) |
| 75 | 92 | |
| 76 | 93 | ## Database getters |
added
test/git_gud/accounts/personal_access_token_touch_test.exs
+43
−0
@@ -0,0 +1,43 @@
| 1 | +defmodule GitGud.Accounts.PersonalAccessTokenTouchTest do | |
| 2 | + @moduledoc """ | |
| 3 | + `last_used_at` is written at most once a minute, so a client polling | |
| 4 | + with one token doesn't queue every request on that row's lock. | |
| 5 | + """ | |
| 6 | + use GitGud.DataCase, async: true | |
| 7 | + | |
| 8 | + import GitGud.AccountsFixtures | |
| 9 | + | |
| 10 | + alias GitGud.Accounts | |
| 11 | + alias GitGud.Accounts.PersonalAccessToken | |
| 12 | + | |
| 13 | + setup do | |
| 14 | + user = user_fixture() | |
| 15 | + {:ok, token, plaintext} = Accounts.create_personal_access_token(user, "poller", ["ci:read"]) | |
| 16 | + {:ok, plaintext: plaintext, token: token} | |
| 17 | + end | |
| 18 | + | |
| 19 | + defp last_used(token), do: Repo.get!(PersonalAccessToken, token.id).last_used_at | |
| 20 | + | |
| 21 | + defp set_last_used(token, at), | |
| 22 | + do: token |> Ecto.Changeset.change(last_used_at: at) |> Repo.update!() | |
| 23 | + | |
| 24 | + test "the first use is recorded", %{plaintext: plaintext, token: token} do | |
| 25 | + assert {:ok, _user, ["ci:read"]} = Accounts.verify_personal_access_token(plaintext) | |
| 26 | + assert DateTime.diff(DateTime.utc_now(), last_used(token)) < 5 | |
| 27 | + end | |
| 28 | + | |
| 29 | + test "a use within the minute writes nothing", %{plaintext: plaintext, token: token} do | |
| 30 | + recent = DateTime.add(DateTime.utc_now(:second), -10, :second) | |
| 31 | + set_last_used(token, recent) | |
| 32 | + | |
| 33 | + assert {:ok, _user, _scopes} = Accounts.verify_personal_access_token(plaintext) | |
| 34 | + assert last_used(token) == recent | |
| 35 | + end | |
| 36 | + | |
| 37 | + test "a use after the minute moves it on", %{plaintext: plaintext, token: token} do | |
| 38 | + set_last_used(token, DateTime.add(DateTime.utc_now(:second), -120, :second)) | |
| 39 | + | |
| 40 | + assert {:ok, _user, _scopes} = Accounts.verify_personal_access_token(plaintext) | |
| 41 | + assert DateTime.diff(DateTime.utc_now(), last_used(token)) < 5 | |
| 42 | + end | |
| 43 | +end |
Parents: 845c94b