Forge-managed zot auth: accept per-job runner token at /v2/token

12c8ec4 · gmorell · 2026-06-25 16:26

1 files +83 -25
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