REST API: scoped PATs + create/import repos under an org

bc58dcd · gmorell · 2026-06-25 22:17

8 files +401 -2
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/accounts.ex
+58 −0
@@ -7,6 +7,64 @@ defmodule GitGud.Accounts do
7 7 alias GitGud.Repo
8 8
9 9 alias GitGud.Accounts.{User, UserToken, UserNotifier}
10 + alias GitGud.Accounts.PersonalAccessToken
11 +
12 + @pat_prefix "ggp_"
13 +
14 + # ── personal access tokens (REST API) ────────────────────────────────
15 +
16 + @doc "A user's API tokens, by name."
17 + def list_personal_access_tokens(%User{id: uid}) do
18 + from(t in PersonalAccessToken, where: t.user_id == ^uid, order_by: [asc: t.name])
19 + |> Repo.all()
20 + end
21 +
22 + @doc """
23 + Mint a token for `user` with `scopes` (subset of
24 + `PersonalAccessToken.scopes/0`). Returns `{:ok, token, plaintext}`;
25 + the `ggp_…` plaintext is only available herewe store its hash.
26 + """
27 + def create_personal_access_token(%User{id: uid}, name, scopes) do
28 + plaintext = @pat_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
29 +
30 + %PersonalAccessToken{user_id: uid}
31 + |> PersonalAccessToken.changeset(%{name: name, token_hash: pat_hash(plaintext), scopes: scopes})
32 + |> Repo.insert()
33 + |> case do
34 + {:ok, token} -> {:ok, token, plaintext}
35 + {:error, _} = err -> err
36 + end
37 + end
38 +
39 + def delete_personal_access_token(%User{id: uid}, id) do
40 + Repo.delete_all(from t in PersonalAccessToken, where: t.user_id == ^uid and t.id == ^id)
41 + :ok
42 + end
43 +
44 + @doc """
45 + Verify a presented API token. On a match, bumps `last_used_at` and
46 + returns `{:ok, %User{}, scopes}`; otherwise `:error`.
47 + """
48 + def verify_personal_access_token(@pat_prefix <> _ = plaintext) do
49 + case Repo.get_by(PersonalAccessToken, token_hash: pat_hash(plaintext)) do
50 + %PersonalAccessToken{} = token ->
51 + token = Repo.preload(token, :user)
52 +
53 + Repo.update_all(
54 + from(t in PersonalAccessToken, where: t.id == ^token.id),
55 + set: [last_used_at: DateTime.utc_now(:second)]
56 + )
57 +
58 + {:ok, token.user, token.scopes}
59 +
60 + nil ->
61 + :error
62 + end
63 + end
64 +
65 + def verify_personal_access_token(_), do: :error
66 +
67 + defp pat_hash(plaintext), do: :crypto.hash(:sha256, plaintext)
10 68
11 69 ## Database getters
12 70
added lib/git_gud/accounts/personal_access_token.ex
+46 −0
@@ -0,0 +1,46 @@
1 +defmodule GitGud.Accounts.PersonalAccessToken do
2 + @moduledoc """
3 + A user-scoped API token (`ggp_…`) for the REST API. Only the SHA-256
4 + `token_hash` is stored; the plaintext is shown once at creation.
5 +
6 + `scopes` is a subset of the extensible vocabulary in `scopes/0`. The
7 + API authorizes each request against the required scope, so new scopes
8 + can be added here over time without changing existing tokens. Today:
9 +
10 + * `repo` — create / manage repositories
11 + """
12 + use Ecto.Schema
13 +
14 + import Ecto.Changeset
15 +
16 + alias GitGud.Accounts.User
17 +
18 + # Extend this list to add scopes; tokens are validated against it.
19 + @scopes ~w(repo)
20 +
21 + schema "personal_access_tokens" do
22 + field :name, :string
23 + field :token_hash, :binary, redact: true
24 + field :scopes, {:array, :string}, default: []
25 + field :last_used_at, :utc_datetime
26 +
27 + belongs_to :user, User
28 +
29 + timestamps(type: :utc_datetime)
30 + end
31 +
32 + @doc false
33 + def changeset(token, attrs) do
34 + token
35 + |> cast(attrs, [:name, :token_hash, :scopes, :user_id])
36 + |> validate_required([:name, :token_hash, :user_id])
37 + |> validate_length(:name, min: 1, max: 100)
38 + |> validate_subset(:scopes, @scopes)
39 + |> validate_length(:scopes, min: 1)
40 + |> unique_constraint(:name, name: :personal_access_tokens_user_id_name_index)
41 + |> unique_constraint(:token_hash)
42 + end
43 +
44 + @doc "The set of scopes a token may carry."
45 + def scopes, do: @scopes
46 +end
modified lib/git_gud/repositories.ex
+28 −2
@@ -503,7 +503,16 @@ defmodule GitGud.Repositories do
503 503 which is stable pre-insert.
504 504 """
505 505 def create_repository(%User{} = owner, attrs) do
506 do_create_repository(owner, attrs, %{owner_id: owner.id, organization_id: nil})
506 + do_create_repository(owner, attrs, clone_fk(%{owner_id: owner.id, organization_id: nil}, attrs))
507 + end
508 +
509 + # Thread an optional remote import URL (`clone_addr`) into the fk so
510 + # init_function/1 mirror-clones it instead of starting empty.
511 + defp clone_fk(fk, attrs) do
512 + case attrs["clone_addr"] || attrs[:clone_addr] do
513 + url when is_binary(url) and url != "" -> Map.put(fk, :clone_from_url, url)
514 + _ -> fk
515 + end
507 516 end
508 517
509 518 @doc """
@@ -521,7 +530,9 @@ defmodule GitGud.Repositories do
521 530 {:error, {:visibility_too_open, org.visibility}}
522 531
523 532 true ->
524 case do_create_repository(org, attrs, %{owner_id: creator.id, organization_id: org.id}) do
533 + fk = clone_fk(%{owner_id: creator.id, organization_id: org.id}, attrs)
534 +
535 + case do_create_repository(org, attrs, fk) do
525 536 {:ok, repo} = ok ->
526 537 _ = GitGud.Federation.Publishing.publish_org_repo_created(org, repo)
527 538 ok
@@ -571,6 +582,10 @@ defmodule GitGud.Repositories do
571 582 end)
572 583 end
573 584
585 + defp init_function(%{clone_from_url: url}) when is_binary(url) do
586 + fn dest -> clone_mirror(url, dest) end
587 + end
588 +
574 589 defp init_function(%{clone_from: source_path}) when is_binary(source_path) do
575 590 fn dest -> clone_bare(source_path, dest) end
576 591 end
@@ -586,6 +601,17 @@ defmodule GitGud.Repositories do
586 601 end
587 602 end
588 603
604 + # Import from a remote URL (e.g. github.com/neiam/foo.git). `--mirror`
605 + # grabs all branches/tags/notes, which is what a full import wants.
606 + defp clone_mirror(url, dest) do
607 + File.mkdir_p!(Path.dirname(dest))
608 +
609 + case System.cmd("git", ["clone", "--mirror", url, dest], stderr_to_stdout: true) do
610 + {_out, 0} -> :ok
611 + {out, _} -> {:error, {:git_clone, out}}
612 + end
613 + end
614 +
589 615 @doc """
590 616 Fork `source_repo` under `dest_namespace` (a `%User{}` or
591 617 `%Organization{}`). The new repo's bare on-disk store is a clone of
modified lib/git_gud_web/components/layouts.ex
+5 −0
@@ -96,6 +96,11 @@ defmodule GitGudWeb.Layouts do
96 96 <.icon name="hero-key-micro" class="size-4" /> SSH keys
97 97 </.link>
98 98 </li>
99 + <li>
100 + <.link navigate={~p"/users/settings/tokens"}>
101 + <.icon name="hero-command-line-micro" class="size-4" /> API tokens
102 + </.link>
103 + </li>
99 104 <li>
100 105 <.link href={~p"/users/log-out"} method="delete">
101 106 <.icon name="hero-arrow-right-on-rectangle-micro" class="size-4" /> Log out
added lib/git_gud_web/controllers/api/v1/org_controller.ex
+95 −0
@@ -0,0 +1,95 @@
1 +defmodule GitGudWeb.Api.V1.OrgController do
2 + @moduledoc """
3 + Org-scoped REST API, authenticated with a Personal Access Token
4 + (`Authorization: token ggp_…` or `Bearer …`) carrying the `repo` scope.
5 +
6 + `POST /api/v1/orgs/:org/repos`
7 + { "name": "foo",
8 + "visibility": "private", # optional, default private
9 + "description": "...", # optional
10 + "clone_addr": "https://github.com/neiam/foo.git" } # optional → import
11 +
12 + With no `clone_addr` the repo is created empty (push your local repo to
13 + it); with one, the forge mirror-clones the remote.
14 + """
15 + use GitGudWeb, :controller
16 +
17 + import Plug.Conn
18 +
19 + alias GitGud.Accounts
20 + alias GitGud.Organizations
21 + alias GitGud.Repositories
22 +
23 + def create_repo(conn, %{"org" => handle} = params) do
24 + with {:ok, user} <- authenticate(conn, "repo"),
25 + %_{} = org <- Organizations.get_organization_by_handle(handle) do
26 + attrs = %{
27 + "name" => params["name"],
28 + "description" => params["description"],
29 + "visibility" => params["visibility"] || "private",
30 + "clone_addr" => params["clone_addr"]
31 + }
32 +
33 + case Repositories.create_repository_for_org(org, user, attrs) do
34 + {:ok, repo} ->
35 + conn |> put_status(201) |> json(repo_json(handle, repo))
36 +
37 + {:error, :forbidden} ->
38 + send_error(conn, 403, "must be an admin of #{handle}")
39 +
40 + {:error, {:visibility_too_open, org_vis}} ->
41 + send_error(conn, 422, "visibility must be no more open than the org (#{org_vis})")
42 +
43 + {:error, %Ecto.Changeset{} = cs} ->
44 + send_error(conn, 422, changeset_message(cs))
45 +
46 + {:error, reason} ->
47 + send_error(conn, 422, inspect(reason))
48 + end
49 + else
50 + :error -> send_error(conn, 401, "invalid token or missing 'repo' scope")
51 + nil -> send_error(conn, 404, "organization not found")
52 + end
53 + end
54 +
55 + defp authenticate(conn, scope) do
56 + with token when is_binary(token) <- bearer(conn),
57 + {:ok, user, scopes} <- Accounts.verify_personal_access_token(token),
58 + true <- scope in scopes do
59 + {:ok, user}
60 + else
61 + _ -> :error
62 + end
63 + end
64 +
65 + defp bearer(conn) do
66 + case get_req_header(conn, "authorization") do
67 + ["token " <> t | _] -> t
68 + ["Bearer " <> t | _] -> t
69 + _ -> nil
70 + end
71 + end
72 +
73 + defp repo_json(handle, repo) do
74 + full = handle <> "/" <> repo.name
75 + base = GitGudWeb.Endpoint.url()
76 +
77 + %{
78 + "id" => repo.id,
79 + "name" => repo.name,
80 + "full_name" => full,
81 + "private" => repo.visibility == "private",
82 + "default_branch" => repo.default_branch,
83 + "html_url" => base <> "/r/" <> full,
84 + "clone_url" => base <> "/" <> full <> ".git"
85 + }
86 + end
87 +
88 + defp changeset_message(cs) do
89 + cs
90 + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
91 + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
92 + end
93 +
94 + defp send_error(conn, status, msg), do: conn |> put_status(status) |> json(%{"message" => msg})
95 +end
added lib/git_gud_web/live/personal_access_token_live/index.ex
+147 −0
@@ -0,0 +1,147 @@
1 +defmodule GitGudWeb.PersonalAccessTokenLive.Index do
2 + @moduledoc """
3 + User settings page for Personal Access Tokens — credentials for the
4 + REST API (`/api/v1`). The plaintext is shown once at creation; only the
5 + hash is stored. Scopes are selectable and validated against an
6 + extensible set, so more can be added later.
7 + """
8 + use GitGudWeb, :live_view
9 +
10 + alias GitGud.Accounts
11 + alias GitGud.Accounts.PersonalAccessToken
12 +
13 + @impl true
14 + def mount(_params, _session, socket) do
15 + user = socket.assigns.current_scope.user
16 +
17 + {:ok,
18 + socket
19 + |> assign(:user, user)
20 + |> assign(:all_scopes, PersonalAccessToken.scopes())
21 + |> assign(:new_token, nil)
22 + |> assign(:api_base, GitGudWeb.Endpoint.url())
23 + |> load()
24 + |> assign(:page_title, "API tokens")}
25 + end
26 +
27 + defp load(socket) do
28 + assign(socket, :tokens, Accounts.list_personal_access_tokens(socket.assigns.user))
29 + end
30 +
31 + @impl true
32 + def handle_event("create_token", params, socket) do
33 + cond do
34 + String.trim(params["name"] || "") == "" ->
35 + {:noreply, put_flash(socket, :error, "Name required.")}
36 +
37 + (params["scopes"] || []) == [] ->
38 + {:noreply, put_flash(socket, :error, "Pick at least one scope.")}
39 +
40 + true ->
41 + name = String.trim(params["name"])
42 +
43 + case Accounts.create_personal_access_token(socket.assigns.user, name, params["scopes"]) do
44 + {:ok, _token, plaintext} ->
45 + {:noreply,
46 + socket
47 + |> load()
48 + |> assign(:new_token, %{name: name, secret: plaintext})
49 + |> put_flash(:info, "Token created — copy it now, it won't be shown again.")}
50 +
51 + {:error, cs} ->
52 + {:noreply, put_flash(socket, :error, error_message(cs))}
53 + end
54 + end
55 + end
56 +
57 + def handle_event("delete_token", %{"id" => id}, socket) do
58 + :ok = Accounts.delete_personal_access_token(socket.assigns.user, String.to_integer(id))
59 + {:noreply, socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")}
60 + end
61 +
62 + defp error_message(%Ecto.Changeset{} = cs) do
63 + cs
64 + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
65 + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
66 + end
67 +
68 + # Built as a string so the JSON braces don't trip HEEx interpolation.
69 + defp curl_example(assigns) do
70 + """
71 + curl -X POST #{assigns.api_base}/api/v1/orgs/neiam/repos \\
72 + -H "Authorization: token #{assigns.new_token.secret}" \\
73 + -H "Content-Type: application/json" \\
74 + -d '{"name":"my-repo"}'
75 + """
76 + end
77 +
78 + @impl true
79 + def render(assigns) do
80 + ~H"""
81 + <Layouts.app flash={@flash} current_scope={@current_scope}>
82 + <div class="max-w-2xl space-y-6">
83 + <div>
84 + <h1 class="text-xl font-semibold">API tokens</h1>
85 + <p class="text-sm opacity-70">
86 + Personal access tokens for the REST API (<code>/api/v1</code>).
87 + Hand one to a script or tool; revoke any time.
88 + </p>
89 + </div>
90 +
91 + <div :if={@new_token} class="rounded border border-success/40 bg-success/10 p-3 space-y-2">
92 + <p class="text-sm font-semibold">
93 + New token{@new_token.name}” — copy it now, it won't be shown again:
94 + </p>
95 + <pre class="text-xs bg-black text-green-300 p-2 rounded overflow-auto select-all">{@new_token.secret}</pre>
96 + <details class="text-xs">
97 + <summary class="cursor-pointer opacity-80">Create a repo under an org</summary>
98 + <pre class="bg-black text-green-300 p-2 rounded overflow-auto select-all mt-1">{curl_example(assigns)}</pre>
99 + <p class="opacity-70 mt-1">
100 + Add <code>clone_addr</code> (e.g. a github.com/neiam URL) to import instead of
101 + creating empty.
102 + </p>
103 + </details>
104 + </div>
105 +
106 + <form phx-submit="create_token" class="flex flex-wrap items-end gap-3">
107 + <label class="form-control">
108 + <span class="text-xs opacity-70">Name</span>
109 + <input name="name" placeholder="repo-importer" class="input input-sm input-bordered" />
110 + </label>
111 + <label :for={s <- @all_scopes} class="label cursor-pointer gap-1 text-sm">
112 + <input type="checkbox" name="scopes[]" value={s} checked class="checkbox checkbox-sm" />
113 + <span class="font-mono">{s}</span>
114 + </label>
115 + <button class="btn btn-sm btn-primary">Create token</button>
116 + </form>
117 +
118 + <table class="table table-sm">
119 + <thead>
120 + <tr><th>Name</th><th>Scopes</th><th>Last used</th><th></th></tr>
121 + </thead>
122 + <tbody>
123 + <tr :for={t <- @tokens}>
124 + <td class="font-mono">{t.name}</td>
125 + <td class="font-mono text-xs">{Enum.join(t.scopes, ", ")}</td>
126 + <td class="text-xs opacity-70">
127 + {t.last_used_at && Calendar.strftime(t.last_used_at, "%Y-%m-%d %H:%M")}
128 + </td>
129 + <td class="text-right">
130 + <button
131 + phx-click="delete_token"
132 + phx-value-id={t.id}
133 + data-confirm={"Revoke token #{t.name}? Anything using it loses access."}
134 + class="btn btn-xs btn-ghost text-error"
135 + >Revoke</button>
136 + </td>
137 + </tr>
138 + <tr :if={@tokens == []}>
139 + <td colspan="4" class="text-sm opacity-50">No tokens yet.</td>
140 + </tr>
141 + </tbody>
142 + </table>
143 + </div>
144 + </Layouts.app>
145 + """
146 + end
147 +end
modified lib/git_gud_web/router.ex
+4 −0
@@ -232,6 +232,9 @@ defmodule GitGudWeb.Router do
232 232
233 233 get "/repos/:owner/:name", RepositoryController, :show
234 234 get "/repos/:owner/:name/commits/:sha", RepositoryController, :commit
235 +
236 + # Write endpoints — Personal Access Token auth (handled in-controller).
237 + post "/orgs/:org/repos", OrgController, :create_repo
235 238 end
236 239
237 240 # Enable LiveDashboard and Swoosh mailbox preview in development
@@ -261,6 +264,7 @@ defmodule GitGudWeb.Router do
261 264 live "/users/settings", UserLive.Settings, :edit
262 265 live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
263 266 live "/users/settings/ssh-keys", SshKeyLive.Index, :index
267 + live "/users/settings/tokens", PersonalAccessTokenLive.Index, :index
264 268 live "/users/settings/invites", UserLive.Invites, :index
265 269 live "/users/settings/two-factor", UserLive.TwoFactorSetup, :index
266 270 end
added priv/repo/migrations/20260625020000_create_personal_access_tokens.exs
+18 −0
@@ -0,0 +1,18 @@
1 +defmodule GitGud.Repo.Migrations.CreatePersonalAccessTokens do
2 + use Ecto.Migration
3 +
4 + def change do
5 + create table(:personal_access_tokens) do
6 + add :user_id, references(:users, 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: []
10 + add :last_used_at, :utc_datetime
11 +
12 + timestamps(type: :utc_datetime)
13 + end
14 +
15 + create unique_index(:personal_access_tokens, [:user_id, :name])
16 + create unique_index(:personal_access_tokens, [:token_hash])
17 + end
18 +end

Parents: f795f88