neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
One access rule for the registries; images may live under their repo
95e9713 · gmorell · 2026-10-01 18:46
Message
{commit_body(@commit)}
Files changed
modified
docs/package-registries.md
+29
−0
| 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 | 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 | 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
| 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 | 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 | 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 | 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 | 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