defmodule GitGud.Packages.Auth do
@moduledoc """
Authentication for the non-OCI package registries.
`RegistryToken` is already format-agnostic — a `ggrt_…` string
scoped to one repository with `pull`/`push`. What differs per
ecosystem is only how the publish tool *presents* it:
twine Authorization: Basic base64("__token__:ggrt_…")
pip Authorization: Basic base64("<user>:<secret>") (or in the URL)
mix Authorization: ggrt_… (bare, no scheme)
cargo Authorization: ggrt_… (bare, no scheme)
apt Authorization: Basic … (from /etc/apt/auth.conf)
So credential extraction tries, in order: HTTP Basic (either half may
carry the secret, since `__token__` occupies the username), a bare
`Authorization` value, and `Bearer`. Each candidate is then checked
against the registry tokens, the per-job CI tokens, the user API
tokens (`ggp_…`) and finally an email + password pair.
## CI publishes without stored credentials
A workflow's `${{ github.token }}` is a `GitGud.Workflows.RunnerJwt`
minted for one job, and it authenticates here the same way it already
does at the OCI token endpoint: it may read and write its *own*
repository's packages, and elsewhere read only what another repository
has granted it (`GitGud.Workflows.JobTokenGrants`) — as far as the
workflow's `permissions: packages:` allows, and only while the job runs.
That is what lets a publish step be pasted into a workflow with no
secret to create first — and it is the same deal the container registry already
offers, so the two don't need different instructions.
## Who may do what
Decided by `GitGud.Packages.Access`, which the container registry's
token endpoint asks too.
"""
alias GitGud.Accounts
alias GitGud.Accounts.PasswordThrottle
alias GitGud.Accounts.User
alias GitGud.Packages
alias GitGud.Packages.Access
alias GitGud.Repositories.Repository
alias GitGud.Workflows.RunnerJwt
@typedoc """
Who is making the request.
* `{:user, %User{}, scopes}` — a forge account: `:all` via password,
the token's scopes via `ggp_…`
* `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
* `{:job, repository_id, perm}` — a running CI job's
`${{ github.token }}`, with its `permissions:` claim
* `:anonymous`
"""
@type principal :: Access.principal()
@doc """
Identify the caller from the request's `Authorization` header.
Always succeeds — an unauthenticated or unrecognised request is
`:anonymous`, and authorization is a separate decision.
"""
@spec authenticate(Plug.Conn.t()) :: principal()
def authenticate(conn) do
conn
|> credentials()
|> Enum.find_value(:anonymous, &verify(&1, conn.remote_ip))
end
# Every credential shape the request might be carrying, most specific
# first. `{username, secret}`; `username` is nil where the scheme
# doesn't carry one.
defp credentials(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Basic " <> b64 | _] ->
case decode_basic(b64) do
{:ok, username, secret} ->
# twine puts the token in the password with `__token__` as
# the username; some tools do the reverse.
[{username, secret}, {nil, secret}, {nil, username}]
:error ->
[]
end
["Bearer " <> secret | _] ->
[{nil, secret}]
[raw | _] ->
[{nil, String.trim(raw)}]
[] ->
[]
end
end
defp decode_basic(b64) do
with {:ok, decoded} <- Base.decode64(b64),
[username, secret] <- String.split(decoded, ":", parts: 2) do
{:ok, username, secret}
else
_ -> :error
end
end
defp verify({username, secret}, ip) do
case Packages.verify_registry_token(secret) do
{:ok, repo_id, scopes} ->
{:token, repo_id, scopes}
:error ->
verify_job_or_user(username, secret, ip)
end
end
defp verify_job_or_user(username, secret, ip) do
case RunnerJwt.verify_active(secret) do
{:ok, %{"pid" => repo_id} = claims} when is_integer(repo_id) ->
{:job, repo_id, claims["perm"]}
_ ->
case Accounts.verify_personal_access_token(secret) do
# The token's scopes travel with it: a `ci:read` token is not a
# way to publish packages.
{:ok, %User{} = user, scopes} ->
{:user, user, scopes}
# A password counts against `PasswordThrottle`; once out of
# guesses the caller is anonymous, wrong password or not.
:error ->
with true <- is_binary(username),
{:ok, user} <- PasswordThrottle.authenticate(username, secret, ip) do
{:user, user, :all}
else
_ -> nil
end
end
end
end
@doc """
May `principal` perform `action` (`:read` or `:write`) on `repo`, for
package format `format`?
Same question as `authorize/3`, plus the one exception to "packages
follow the repository": a repository may name formats it publishes
regardless of its visibility, and for those a read is allowed to
anyone.
It widens reads only, and only for the named format. A repository
publishing `fdroid` has not opened its `raw` bucket, has not opened
publishing to anonymous callers, and has not made its source
readable — nothing here is consulted outside the package registry.
"""
@spec authorize(principal(), Repository.t(), :read | :write, String.t() | nil) :: boolean()
def authorize(principal, %Repository{} = repo, :read, format) when is_binary(format) do
authorize(principal, repo, :read) or public_format?(repo, format)
end
def authorize(principal, %Repository{} = repo, action, _format),
do: authorize(principal, repo, action)
@doc """
Does `repo` publish `format` regardless of its visibility?
"""
@spec public_format?(Repository.t(), String.t()) :: boolean()
def public_format?(%Repository{public_package_formats: formats}, format)
when is_list(formats),
do: format in formats
def public_format?(%Repository{}, _format), do: false
@doc """
May `principal` perform `action` (`:read` or `:write`) on `repo`?
See `GitGud.Packages.Access`.
"""
@spec authorize(principal(), Repository.t(), :read | :write) :: boolean()
def authorize(principal, %Repository{} = repo, action) when action in [:read, :write],
do: Access.allowed?(principal, repo, action)
@doc """
The `WWW-Authenticate` challenge to send with a 401.
`mix` and `cargo` never follow a challenge — they send their token
or fail — but `pip` and `apt` both retry against Basic, so this is
what makes a private index usable from a bare URL.
"""
def challenge(format) do
~s(Basic realm="gitgud #{format} registry", charset="UTF-8")
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.7 KiB · text
History
3d1b0af