One access rule for the registries; images may live under their repo

95e9713 · gmorell · 2026-10-01 18:46

41 files +849 -205
Message
{commit_body(@commit)}

Files changed

modified docs/package-registries.md
+29 −0
@@ -28,6 +28,35 @@ cross-mounting, GC, conformance. An apt repository is static files over
28 28 plain HTTP. There is no daemon to embed. The only hard part is OpenPGP
29 29 signing, which OTP cannot do.
30 30
31 +## Who may read and publish
32 +
33 +One rule for every registry, container images included:
34 +`GitGud.Packages.Access`. The OCI token endpoint (`/v2/token`) and the
35 +package registries both ask it.
36 +
37 +* **Accounts** get what `Organizations.can?/3` gives them on the
38 + repository — the same answer the web UI uses. `read` reads; `write`
39 + publishes and pushes; deleting an image takes `admin`. So org admins
40 + and teams with `write` publish, and a plain org member reads a
41 + private repository's packages only if a team grants it.
42 +* **Personal access tokens (`ggp_…`)** are further limited by their
43 + scopes: reads want `packages:read`, `packages:write`, `repo` or
44 + `repo:read`; publishing wants `packages:write` or `repo`; deleting
45 + wants `repo`. A `ci:read` token publishes nothing.
46 +* **Registry tokens (`ggrt_…`)** get their `pull`/`push` scopes on their
47 + own repository — the credential to hand a cluster as an image pull
48 + secret.
49 +* **CI job tokens (`${{ github.token }}`)** read and write their own
50 + repository, and read another repository only where that repository's
51 + job-token grants allow it (Settings → Job token access).
52 +* **Anonymous** callers read public repositories only. *Internal* means
53 + "any signed-in user".
54 +
55 +An image belongs to the repository its first two path segments name:
56 +`owner/repo/server` and `owner/repo/web/assets` are `owner/repo`'s, so a
57 +repository can publish several images, and whatever may push one may
58 +push them all.
59 +
31 60 ## Visibility, and publishing out of a repository that has none
32 61
33 62 Packages follow the repository. A private or internal repository serves
modified lib/git_gud/accounts/personal_access_token.ex
+3 −1
@@ -38,6 +38,7 @@ defmodule GitGud.Accounts.PersonalAccessToken do
38 38 wiki:read
39 39 wiki:write
40 40 packages:read
41 + packages:write
41 42 )
42 43
43 44 # What the mobile client needs to do everything it offers: browse
@@ -77,7 +78,8 @@ defmodule GitGud.Accounts.PersonalAccessToken do
77 78 "pulls:write" => "Comment on, review and merge pull requests",
78 79 "wiki:read" => "Read wiki pages",
79 80 "wiki:write" => "Create and edit wiki pages",
80 − "packages:read" => "Read published packages and how to install them"
81 + "packages:read" => "Read published packages and how to install them",
82 + "packages:write" => "Publish packages and push container images"
81 83 }
82 84
83 85 schema "personal_access_tokens" do
added lib/git_gud/packages/access.ex
+128 −0
@@ -0,0 +1,128 @@
1 +defmodule GitGud.Packages.Access do
2 + @moduledoc """
3 + Who may do what to a repository's packages and container images.
4 +
5 + One decision for both registries. The OCI token endpoint
6 + (`GitGudWeb.RegistryController`) and the package registries
7 + (`GitGud.Packages.Auth`) used to answer this separately, and they
8 + disagreed: the container registry let anonymous callers pull
9 + *internal* images and ignored org admins and teams, while the package
10 + registry ignored what a personal token was scoped to. Both now ask
11 + here, and a user's access goes through `GitGud.Organizations.can?/3` —
12 + the same rule the web UI applies.
13 +
14 + ## Principals
15 +
16 + * `{:user, user, scopes}` — a forge account. `scopes` is `:all` for a
17 + password (or a signed-in browser session) and the token's scopes for
18 + a personal access token (`ggp_…`), which limit what the account's
19 + own permission would otherwise allow.
20 + * `{:token, repository_id, scopes}` — a repo-scoped registry token
21 + (`ggrt_…`), `pull` and/or `push`.
22 + * `{:job, repository_id}` — a running CI job's `${{ github.token }}`.
23 + * `:anonymous`
24 +
25 + ## Actions
26 +
27 + `:read` (pull, download), `:write` (push, publish) and `:delete`
28 + (removing image manifests, which only repository admins may do).
29 +
30 + ## Personal token scopes
31 +
32 + Reading wants `packages:read`, `packages:write`, `repo` or `repo:read`;
33 + writing wants `packages:write` or `repo`; deleting wants `repo`. `repo`
34 + predates the finer scopes and keeps meaning everything.
35 + """
36 +
37 + alias GitGud.Accounts.User
38 + alias GitGud.Organizations
39 + alias GitGud.Repositories
40 + alias GitGud.Repositories.Repository
41 + alias GitGud.Workflows.JobTokenGrants
42 +
43 + @type principal ::
44 + {:user, User.t(), :all | [String.t()]}
45 + | {:token, integer(), [String.t()]}
46 + | {:job, integer()}
47 + | :anonymous
48 +
49 + @type action :: :read | :write | :delete
50 +
51 + @read_scopes ~w(packages:read packages:write repo repo:read)
52 + @write_scopes ~w(packages:write repo)
53 + @delete_scopes ~w(repo)
54 +
55 + @doc "May `principal` perform `action` on `repo`'s packages and images?"
56 + @spec allowed?(principal(), Repository.t(), action()) :: boolean()
57 + def allowed?({:token, repo_id, scopes}, %Repository{id: repo_id}, action) do
58 + case action do
59 + :read -> "pull" in scopes
60 + :write -> "push" in scopes
61 + :delete -> false
62 + end
63 + end
64 +
65 + # A registry token minted for a different repository is worth no more
66 + # than an anonymous request.
67 + def allowed?({:token, _other, _scopes}, %Repository{} = repo, action),
68 + do: allowed?(:anonymous, repo, action)
69 +
70 + # A job token carries the forge's own judgement that the job belongs to
71 + # its repository, so it reads and writes that repository's packages with
72 + # no stored credential. Elsewhere it reads only what the other
73 + # repository has granted it (or what anyone may read).
74 + def allowed?({:job, repo_id}, %Repository{id: repo_id}, action),
75 + do: action in [:read, :write]
76 +
77 + def allowed?({:job, source_id}, %Repository{} = repo, :read),
78 + do: JobTokenGrants.allows?(repo, source_id, :read) or allowed?(:anonymous, repo, :read)
79 +
80 + def allowed?({:job, _other}, %Repository{}, _action), do: false
81 +
82 + def allowed?({:user, %User{} = user, scopes}, %Repository{} = repo, action) do
83 + scope_permits?(scopes, action) and can?(user, repo, permission(action))
84 + end
85 +
86 + def allowed?(:anonymous, %Repository{} = repo, :read), do: can?(nil, repo, :read)
87 +
88 + def allowed?(:anonymous, %Repository{}, _action), do: false
89 +
90 + # `Organizations.can?/3` answers nil for "no permission at all".
91 + defp can?(user, repo, permission), do: Organizations.can?(user, repo, permission) == true
92 +
93 + defp permission(:read), do: :read
94 + defp permission(:write), do: :write
95 + defp permission(:delete), do: :admin
96 +
97 + defp scope_permits?(:all, _action), do: true
98 + defp scope_permits?(scopes, :read), do: Enum.any?(scopes, &(&1 in @read_scopes))
99 + defp scope_permits?(scopes, :write), do: Enum.any?(scopes, &(&1 in @write_scopes))
100 + defp scope_permits?(scopes, :delete), do: Enum.any?(scopes, &(&1 in @delete_scopes))
101 +
102 + @doc """
103 + The repository a registry image name belongs to.
104 +
105 + An image is named for its repository, `owner/name`, or for something
106 + under it — `owner/name/server`, `owner/name/web/assets` — so a
107 + repository can publish several images, as on GitLab. Whatever applies
108 + to the repository applies to every image beneath it.
109 + """
110 + @spec repository_for_image(String.t()) :: {:ok, Repository.t()} | :error
111 + def repository_for_image(image) when is_binary(image) do
112 + case String.split(image, "/") do
113 + [owner, name | rest] when owner != "" and name != "" ->
114 + if Enum.all?(rest, &(&1 != "")) do
115 + try do
116 + {:ok, Repositories.get_repository_by_path!(owner, name)}
117 + rescue
118 + Ecto.NoResultsError -> :error
119 + end
120 + else
121 + :error
122 + end
123 +
124 + _ ->
125 + :error
126 + end
127 + end
128 +end
modified lib/git_gud/packages/auth.ex
+19 −51
@@ -23,31 +23,35 @@ defmodule GitGud.Packages.Auth do
23 23 A workflow's `${{ github.token }}` is a `GitGud.Workflows.RunnerJwt`
24 24 minted for one job, and it authenticates here the same way it already
25 25 does at the OCI token endpoint: it may read and write its *own*
26 − repository's packages, and nothing anywhere else. That is what lets a
26 + repository's packages, and elsewhere read only what another repository
27 + has granted it (`GitGud.Workflows.JobTokenGrants`). That is what lets a
27 28 publish step be pasted into a workflow with no secret to create
28 29 first — and it is the same deal the container registry already
29 30 offers, so the two don't need different instructions.
31 +
32 + ## Who may do what
33 +
34 + Decided by `GitGud.Packages.Access`, which the container registry's
35 + token endpoint asks too.
30 36 """
31 37
32 38 alias GitGud.Accounts
33 39 alias GitGud.Accounts.User
34 40 alias GitGud.Packages
41 + alias GitGud.Packages.Access
35 42 alias GitGud.Repositories.Repository
36 43 alias GitGud.Workflows.RunnerJwt
37 44
38 45 @typedoc """
39 46 Who is making the request.
40 47
41 − * `{:user, %User{}}` — a forge account, via password or `ggp_…`
48 + * `{:user, %User{}, scopes}` — a forge account: `:all` via password,
49 + the token's scopes via `ggp_…`
42 50 * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
43 51 * `{:job, repository_id}` — a running CI job's `${{ github.token }}`
44 52 * `:anonymous`
45 53 """
46 − @type principal ::
47 − {:user, User.t()}
48 − | {:token, integer(), [String.t()]}
49 − | {:job, integer()}
50 − | :anonymous
54 + @type principal :: Access.principal()
51 55
52 56 @doc """
53 57 Identify the caller from the request's `Authorization` header.
@@ -114,13 +118,15 @@ defmodule GitGud.Packages.Auth do
114 118
115 119 _ ->
116 120 case Accounts.verify_personal_access_token(secret) do
117 − {:ok, %User{} = user, _scopes} ->
118 − {:user, user}
121 + # The token's scopes travel with it: a `ci:read` token is not a
122 + # way to publish packages.
123 + {:ok, %User{} = user, scopes} ->
124 + {:user, user, scopes}
119 125
120 126 :error ->
121 127 with true <- is_binary(username),
122 128 %User{} = user <- Accounts.get_user_by_email_and_password(username, secret) do
123 − {:user, user}
129 + {:user, user, :all}
124 130 else
125 131 _ -> nil
126 132 end
@@ -162,49 +168,11 @@ defmodule GitGud.Packages.Auth do
162 168
163 169 @doc """
164 170 May `principal` perform `action` (`:read` or `:write`) on `repo`?
165 −
166 − Reads follow repository visibility; writes are the owner's, or a
167 − `ggrt_…` minted for that repository with the matching scope.
171 + See `GitGud.Packages.Access`.
168 172 """
169 173 @spec authorize(principal(), Repository.t(), :read | :write) :: boolean()
170 − def authorize({:token, repo_id, scopes}, %Repository{id: repo_id}, action) do
171 − scope_for(action) in scopes
172 − end
173 −
174 − # A token minted for a different repository is worth no more than
175 − # an anonymous request.
176 − def authorize({:token, _other, _}, %Repository{} = repo, action),
177 − do: authorize(:anonymous, repo, action)
178 −
179 − # A job token carries the forge's own judgement that this job belongs
180 − # to this repository, so it reads and writes that repository's
181 − # packages with no stored credential — and, exactly as at the OCI
182 − # token endpoint, gets nothing at all anywhere else.
183 − def authorize({:job, repo_id}, %Repository{id: repo_id}, _action), do: true
184 −
185 − def authorize({:job, _other}, %Repository{} = repo, action),
186 − do: authorize(:anonymous, repo, action)
187 −
188 − def authorize({:user, %User{} = user}, %Repository{} = repo, action) do
189 − repo = GitGud.Repo.preload(repo, [:owner, :organization])
190 −
191 − cond do
192 − user.id == repo.owner_id -> true
193 − action == :read -> repo.visibility in ["public", "internal"]
194 − true -> org_writer?(repo, user)
195 − end
196 − end
197 −
198 − def authorize(:anonymous, %Repository{visibility: vis}, :read), do: vis == "public"
199 − def authorize(:anonymous, %Repository{}, :write), do: false
200 −
201 − defp scope_for(:read), do: "pull"
202 − defp scope_for(:write), do: "push"
203 −
204 − defp org_writer?(%Repository{organization: %_{} = org}, %User{} = user),
205 − do: GitGud.Organizations.member?(org, user)
206 −
207 − defp org_writer?(_repo, _user), do: false
174 + def authorize(principal, %Repository{} = repo, action) when action in [:read, :write],
175 + do: Access.allowed?(principal, repo, action)
208 176
209 177 @doc """
210 178 The `WWW-Authenticate` challenge to send with a 401.
modified lib/git_gud/packages/formats/cargo.ex
+1 −1
@@ -469,6 +469,6 @@ defmodule GitGud.Packages.Formats.Cargo do
469 469
470 470 defp to_integer(_, default), do: default
471 471
472 − defp uploader(%{principal: {:user, user}}), do: user
472 + defp uploader(%{principal: {:user, user, _scopes}}), do: user
473 473 defp uploader(_ctx), do: nil
474 474 end
modified lib/git_gud/packages/formats/deb.ex
+1 −1

Click to load diff…

modified lib/git_gud/packages/formats/fdroid.ex
+1 −1

Click to load diff…

modified lib/git_gud/packages/formats/hex.ex
+1 −1

Click to load diff…

modified lib/git_gud/packages/formats/pypi.ex
+1 −1

Click to load diff…

modified lib/git_gud/packages/formats/rpm.ex
+1 −1

Click to load diff…

modified lib/git_gud/packages/token.ex
+3 −34

Click to load diff…

added lib/git_gud/workflows/job_token_grant.ex
+76 −0

Click to load diff…

added lib/git_gud/workflows/job_token_grants.ex
+119 −0

Click to load diff…

modified lib/git_gud_web/controllers/package_icon_controller.ex
+1 −1

Click to load diff…

modified lib/git_gud_web/controllers/registry_controller.ex
+47 −105

Click to load diff…

modified lib/git_gud_web/live/org_live/packages.ex
+1 −1

Click to load diff…

added priv/repo/migrations/20261001150000_create_job_token_grants.exs
+36 −0

Click to load diff…

added test/git_gud/packages/access_test.exs
+121 −0

Click to load diff…

modified test/git_gud/packages_substrate_test.exs
+1 −1

Click to load diff…

added test/git_gud_web/controllers/registry_controller_test.exs
+254 −0

Click to load diff…

modified test/git_gud_web/live/org_packages_catalog_test.exs
+5 −5

Click to load diff…

Parents: 7784d39