8.8 KiB · text History 95a46eb

Plan: a job token that expires with the job and honours permissions:

Status: ready to execute. Written to be carried out start to finish by one agent without further questions. Prerequisite for workflow-fork-pull-requests.md, which needs a read-only token. Independent of the other CI plans.

Why

The job token (${{ github.token }}; secrets.GITHUB_TOKEN once workflow-runner-context.md is in) is minted by GitGud.Workflows.RunnerJwt.mint/2 when a job is claimed and checked by RunnerJwt.verify/1 everywhere it is accepted. Two things differ from what a workflow author assumes:

  1. It outlives the job by up to 24 hours. verify/1 checks the signature, issuer, audience and exp (ttl_seconds, default 86 400) — never whether the job is still running. GitHub’s token expires when the job finishes. A token that leaks into a log, an artifact or a third-party action keeps working for a day: it can publish packages to the repository and read its private code. Silent.
  2. permissions: is ignored. Whatever a workflow or job declares, the token can do the same fixed set of things (below). A workflow that says permissions: contents: read to sandbox a third-party action still hands it package-publish rights; one that says contents: write to push a tag or a version bump gets a 403 from git (receive-pack is refused to every job token in GitGudWeb.Plugs.GitSmartHttp). The first is silent, the second loud.

What a job token can do today (verified):

where what code
git over HTTP clone/fetch own repo only (upload-pack) GitGudWeb.Plugs.GitSmartHttp (authenticate/1 → {:job, pid}, the {:job, pid} clause)
package registries + OCI read and write own repo’s packages; read a repo that granted it GitGud.Packages.Auth.verify_job_or_user/2, GitGud.Packages.Access.allowed?/3 ({:job, …} clauses)
REST API read own repo’s metadata GitGudWeb.Api.V1.RepositoryController.runner_token_for?/2
workflow_dispatch API dispatch a repo that granted dispatch GitGudWeb.Api.V1.WorkflowController.authorize/2, GitGud.Workflows.JobTokenGrants.allows?/3
artifacts + cache own run / own repo GitGudWeb.Plugs.RunnerJwtAuth + the two controllers

Semantics to match (GitHub, “Controlling permissions for GITHUB_TOKEN”)

  • permissions: at workflow level, overridable per job (job wins entirely, no merge). Values: read-all, write-all, {} (nothing), or a map of scope → read | write | none. When a map is given, every scope not listed is none.
  • Scopes that mean something here: contents (git read/write), packages (registry read/write), actions (write = may dispatch workflows, subject to the target’s job-token grant; read = nothing extra today), metadata (always read). Others (issues, pull-requests, statuses, checks, deployments, id-token, …) are accepted and ignored — gitgud’s API doesn’t let a job token do those things yet; list them in the docs.
  • Default when no permissions: key at either level: keep today’s rights so no existing workflow breaks: contents: read, packages: write, actions: write, metadata: read. (GitHub’s “permissive” default would also grant contents: write; don’t widen silently — a workflow that wants to push says so.)
  • contents: write allows git push to the job’s own repository, subject to branch protection as a push by nobody in particular: it can’t satisfy required approvals, and force-push/deletion rules apply (BranchProtections.check_ref_update/5 already treats a push without a PR that way).
  • The token stops working when the job ends, with a short grace period for the runner’s post-job steps (cache save, artifact finalize): accept a token whose job is non-terminal, or terminal for less than 5 minutes.

Where things are

Token lib/git_gud/workflows/runner_jwt.ex mint/2, verify/1; minted in lib/git_gud_web/controllers/runner_controller.ex build_task/1
Consumers the five in the table above, plus lib/git_gud_web/plugs/runner_jwt_auth.ex
Job definition job.definition (raw job YAML: permissions), run.workflow_doc (top level permissions)
Git push path GitSmartHttp (can?/3, the receive-pack branch), and the pre-receive check lib/git_gud_web/controllers/pre_receive_controller.ex (how does it learn who pushed? check what the hook sends and what it does with a job principal)
Tests test/git_gud/workflows/runner_jwt_test.exs, test/git_gud_web/ tests for git smart HTTP, package registry and API job-token cases (grep RunnerJwt.mint under test/)

Steps

1. Effective permissions

GitGud.Workflows.Permissions.effective(workflow_doc, job_definition) → %{"contents" => :read | :write | :none, "packages" => …, "actions" => …} per the rules above (job-level replaces workflow-level; read-all / write-all / {}; unknown scopes dropped; malformed values → :none, logged). Pure, unit-tested.

2. Put them in the token

RunnerJwt.mint/2 → mint(job, run, perms) adding claim "perm" (e.g. %{"contents" => "read", "packages" => "write", "actions" => "write"}). build_task/1 computes perms from run.workflow_doc and job.definition. A token without "perm" (minted before this change, still within its 24 h) is treated as the old default — so nothing in flight breaks during the deploy.

3. Expire with the job

Add RunnerJwt.verify_active(token): verify/1, then load the job (claims["tid"]) and require it to exist and be non-terminal, or terminal with completed_at within the last 5 minutes. Replace verify/1 with verify_active/1 in every consumer (the table above; grep RunnerJwt.verify(). Keep verify/1 for tests and internal use. One Repo.get per authenticated request is acceptable; if a consumer is hot (the OCI registry does many requests per push), cache the job’s state in the conn assigns for the request only — no global cache.

4. Enforce the scopes

Add RunnerJwt.permits?(claims, scope, level). Then:

  • packages: in GitGud.Packages.Access.allowed?/3 the principal {:job, repo_id} becomes {:job, repo_id, perms} (update Packages.Auth and every match on {:job, …} — grep it). Own-repo :write requires packages: write; own-repo :read requires at least packages: read; reading a granting repo requires packages: read.
  • contents: in GitSmartHttp, a job principal may upload-pack its own repo with contents: read|write, and receive-pack its own repo with contents: write. Follow the receive-pack path to the pre-receive hook and make sure a job push is checked against branch protection and attributed (the run’s triggered_by_id if any; otherwise record the pusher as nil — read how CommitBackfill treats pusher_id: nil). A push by a job must not trigger the same workflow forever: GitHub doesn’t start push workflows for pushes made with GITHUB_TOKEN. Do the same: carry a marker through the hook (e.g. the job id in an env var the pre-receive/post-receive hook forwards — find how pusher_id gets into CommitBackfill.new_job/1 and add job_token: true next to it), and in CommitBackfill skip dispatch_workflows/5 for such pushes (still record commits, webhooks, PR refresh — but no synchronize dispatch either).
  • actions: WorkflowController.authorize/2 additionally requires actions: write.
  • metadata: always allowed.

5. Docs

README CI list: token lifetime, permissions: support (which scopes mean something), the default, and that pushes made with the job token don’t start workflows.

Tests

  • Permissions.effective/2: no key → default; read-all; write-all; {}; map with one scope → others none; job overrides workflow.
  • verify_active/1: running job ok; job terminal 1 minute ago ok; 10 minutes ago → error; deleted job → error; signature still checked.
  • Packages: permissions: {contents: read} → publishing with the job token is refused, reading too; default → publishing works (regression).
  • Git: contents: write → push to own repo succeeds, to another repo fails; default → push refused (403/401 as today); a push to a protected branch requiring approvals is refused.
  • A push made with a job token creates no workflow runs (on-disk test through CommitBackfill.perform/1 with the marker set).
  • Dispatch API with a granted job token lacking actions: write → 403.

Done when

  • mix test passes; no new compile warnings (compare mix compile --force warning lists before/after); mix format only touched files.
  • One commit in the repository’s message style, calling out that leaked job tokens previously stayed valid for 24 hours.
  • Not pushed.