Forge-managed zot auth: accept per-job runner token at /v2/token
12c8ec4 · gmorell · 2026-06-25 16:26
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud_web/controllers/registry_controller.ex
+83
−25
@@ -18,28 +18,12 @@ defmodule GitGudWeb.RegistryController do
| 18 | 18 | |
| 19 | 19 | alias GitGud.Accounts |
| 20 | 20 | alias GitGud.Packages.Token |
| 21 | + alias GitGud.Repositories | |
| 22 | + alias GitGud.Repositories.Storage | |
| 23 | + alias GitGud.Workflows.RunnerJwt | |
| 21 | 24 | |
| 22 | 25 | def token(conn, params) do |
| 23 | − {user, sub} = | |
| 24 | − case basic_auth_user(conn) do | |
| 25 | − {:ok, user} -> {user, user.email} | |
| 26 | − :anonymous -> {nil, nil} | |
| 27 | − end | |
| 28 | − | |
| 29 | − scopes = List.wrap(params["scope"]) | |
| 30 | − | |
| 31 | − access = | |
| 32 | − scopes | |
| 33 | − |> Enum.map(fn scope -> | |
| 34 | − case String.split(scope, ":", parts: 3) do | |
| 35 | − ["repository", path, actions] -> | |
| 36 | − granted = Token.authorize(user, path, String.split(actions, ",")) | |
| 37 | − "repository:" <> path <> ":" <> Enum.join(granted, ",") | |
| 38 | − | |
| 39 | − _ -> | |
| 40 | − scope | |
| 41 | − end | |
| 42 | − end) | |
| 26 | + {sub, access} = authorize(authenticate(conn), List.wrap(params["scope"])) | |
| 43 | 27 | |
| 44 | 28 | case Token.issue(sub, access) do |
| 45 | 29 | {:ok, jwt, claims} -> |
@@ -57,14 +41,88 @@ defmodule GitGudWeb.RegistryController do
| 57 | 41 | end |
| 58 | 42 | end |
| 59 | 43 | |
| 60 | − defp basic_auth_user(conn) do | |
| 44 | + # Authenticated principal for the token request: | |
| 45 | + # {:user, %User{}} — forge email + password (humans / manual creds) | |
| 46 | + # {:job, "owner/name"} — a CI job's runner token (forge-managed auth): | |
| 47 | + # the workflow logs in with `password: ${{ github.token }}`, so the | |
| 48 | + # Basic-auth password is the per-job RunnerJwt; it may push/pull | |
| 49 | + # only its own repository. | |
| 50 | + # :anonymous | |
| 51 | + defp authenticate(conn) do | |
| 52 | + case basic_creds(conn) do | |
| 53 | + {:ok, username, secret} -> | |
| 54 | + case RunnerJwt.verify(secret) do | |
| 55 | + {:ok, %{"pid" => pid}} -> | |
| 56 | + case job_repo_path(pid) do | |
| 57 | + nil -> :anonymous | |
| 58 | + path -> {:job, path} | |
| 59 | + end | |
| 60 | + | |
| 61 | + _ -> | |
| 62 | + case Accounts.get_user_by_email_and_password(username, secret) do | |
| 63 | + %_{} = user -> {:user, user} | |
| 64 | + _ -> :anonymous | |
| 65 | + end | |
| 66 | + end | |
| 67 | + | |
| 68 | + :error -> | |
| 69 | + :anonymous | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + defp basic_creds(conn) do | |
| 61 | 74 | with ["Basic " <> b64] <- Plug.Conn.get_req_header(conn, "authorization"), |
| 62 | 75 | {:ok, decoded} <- Base.decode64(b64), |
| 63 | − [email, password] <- String.split(decoded, ":", parts: 2), | |
| 64 | − %_{} = user <- Accounts.get_user_by_email_and_password(email, password) do | |
| 65 | − {:ok, user} | |
| 76 | + [username, secret] <- String.split(decoded, ":", parts: 2) do | |
| 77 | + {:ok, username, secret} | |
| 66 | 78 | else |
| 67 | − _ -> :anonymous | |
| 79 | + _ -> :error | |
| 68 | 80 | end |
| 69 | 81 | end |
| 82 | + | |
| 83 | + defp authorize({:user, user}, scopes) do | |
| 84 | + {user.email, Enum.map(scopes, &user_scope(user, &1))} | |
| 85 | + end | |
| 86 | + | |
| 87 | + defp authorize(:anonymous, scopes) do | |
| 88 | + {nil, Enum.map(scopes, &user_scope(nil, &1))} | |
| 89 | + end | |
| 90 | + | |
| 91 | + # A job token grants push+pull on its own repo; other repos fall back to | |
| 92 | + # anonymous (public pull only). | |
| 93 | + defp authorize({:job, job_path}, scopes) do | |
| 94 | + access = | |
| 95 | + Enum.map(scopes, fn scope -> | |
| 96 | + case String.split(scope, ":", parts: 3) do | |
| 97 | + ["repository", ^job_path, actions] -> | |
| 98 | + granted = Enum.filter(String.split(actions, ","), &(&1 in ["pull", "push"])) | |
| 99 | + "repository:" <> job_path <> ":" <> Enum.join(granted, ",") | |
| 100 | + | |
| 101 | + _ -> | |
| 102 | + user_scope(nil, scope) | |
| 103 | + end | |
| 104 | + end) | |
| 105 | + | |
| 106 | + {"runner-task:" <> job_path, access} | |
| 107 | + end | |
| 108 | + | |
| 109 | + defp user_scope(user, scope) do | |
| 110 | + case String.split(scope, ":", parts: 3) do | |
| 111 | + ["repository", path, actions] -> | |
| 112 | + granted = Token.authorize(user, path, String.split(actions, ",")) | |
| 113 | + "repository:" <> path <> ":" <> Enum.join(granted, ",") | |
| 114 | + | |
| 115 | + _ -> | |
| 116 | + scope | |
| 117 | + end | |
| 118 | + end | |
| 119 | + | |
| 120 | + defp job_repo_path(pid) when is_integer(pid) do | |
| 121 | + repo = Repositories.get_repository!(pid) |> GitGud.Repo.preload([:owner, :organization]) | |
| 122 | + Storage.repo_handle(repo) <> "/" <> repo.name | |
| 123 | + rescue | |
| 124 | + _ -> nil | |
| 125 | + end | |
| 126 | + | |
| 127 | + defp job_repo_path(_), do: nil | |
| 70 | 128 | end |
Parents: 5fe9c0c