diff --git a/.github/actions/github-app-token/action.yml b/.github/actions/github-app-token/action.yml new file mode 100644 index 0000000..6e4375f --- /dev/null +++ b/.github/actions/github-app-token/action.yml @@ -0,0 +1,279 @@ +name: 'GitHub App Token' +description: 'Mint a short-lived, scoped GitHub App installation token — the sanctioned replacement for cross-repo personal access tokens' +author: 'GForce Innovation' + +# ============================================================================== +# Why this action exists. +# +# `secrets.GITHUB_TOKEN` cannot touch another repository. Every cross-repo job +# in the fleet therefore reached for a PAT, and a PAT is the worst credential +# available for the purpose: +# +# * it belongs to a person, so the audit log says "gambe94", not "the CI" +# * it lives until someone remembers to rotate it +# * its scopes are account-wide — `repo` grants write to every repo the human +# can see, not the one repo the job needs +# * it dies with the person's account +# +# An App installation token is the opposite on all four counts: it expires in +# one hour, it is attributable to the bot, and it is scoped twice over — to a +# named list of repositories AND to a named list of permissions. +# +# Callers need the App's credentials, which live as org-level secrets so a new +# repo inherits them without a per-repo copy: +# +# vars.GFORCE_CI_APP_ID App ID (or client ID — see below) +# secrets.GFORCE_CI_APP_PRIVATE_KEY the PEM, whole file including headers +# +# ------------------------------------------------------------------------------ +# Scoping is enforced, not suggested. +# +# `actions/create-github-app-token` defaults are generous in both directions: +# omit `repositories` and the token reaches every repo in the installation; +# omit the `permission-*` inputs and it inherits every permission the App holds. +# Two omissions and the token you minted to replace an over-broad PAT is an +# over-broad token with a shorter life. +# +# So this action fails closed: name the repositories, name the permissions, or +# set `allow-broad-scope: true` and say in the diff why you need it. +# ============================================================================== + +inputs: + # --- identity -------------------------------------------------------------- + client-id: + description: | + GitHub App Client ID (`Iv23li...`). Preferred over app-id — upstream + deprecated the numeric App ID in create-github-app-token v3. Find it on + the App's settings page directly under the App ID. + required: false + default: '' + app-id: + description: | + Numeric GitHub App ID. Still works, but upstream emits a deprecation + warning on every run. Pass client-id instead when you can. + required: false + default: '' + private-key: + description: | + The App's PEM private key — always a secret, never a literal. Pass the + whole file including the BEGIN/END lines; a key pasted without them + fails with a signature error that names nothing useful. + required: true + + # --- scope: which repositories --------------------------------------------- + owner: + description: 'Org or user that owns the installation. Defaults to the repository owner.' + required: false + default: '' + repositories: + description: | + Newline- or comma-separated repository names (bare names, not owner/name) + the token may act on, e.g. `sf-develop-demo`. Required unless + allow-broad-scope is true. + required: false + default: '' + + # --- scope: which permissions ---------------------------------------------- + # Pass-through to create-github-app-token. Each is 'read' or 'write', and each + # must already be held by the App — asking for more than the installation has + # is an error, not a silent downgrade. + # + # `metadata` is deliberately absent: GitHub grants it implicitly to every + # installation token, so requesting it would be noise. + permission-actions: + description: "Workflows, workflow runs and artifacts. 'write' is what dispatching a workflow in another repo needs." + required: false + default: '' + permission-contents: + description: 'Repository contents, commits, branches, releases.' + required: false + default: '' + permission-packages: + description: "GitHub Packages / GHCR. 'read' is what pulling a candidate container image needs." + required: false + default: '' + permission-organization-packages: + description: | + Org-level package administration. Distinct from permission-packages, and + the difference is not obvious: reading or pushing a package uses + `packages`, but `PATCH /orgs/{org}/packages/...` (visibility) and + `DELETE .../versions/{id}` (cleanup) are org endpoints and 403 with + `packages` alone. + required: false + default: '' + permission-pull-requests: + description: 'Pull requests and related comments, assignees, labels, milestones.' + required: false + default: '' + permission-issues: + description: 'Issues and related comments, assignees, labels, milestones.' + required: false + default: '' + permission-checks: + description: 'Checks on code.' + required: false + default: '' + permission-statuses: + description: 'Commit statuses.' + required: false + default: '' + permission-deployments: + description: 'Deployments and deployment statuses.' + required: false + default: '' + permission-workflows: + description: 'Updating files in .github/workflows. Needed only to COMMIT workflow changes, not to run them.' + required: false + default: '' + + # --- escape hatch ---------------------------------------------------------- + allow-broad-scope: + description: | + Skip the repository and permission guards, taking whatever the + installation grants. Off by default. Turn it on only with a comment in + the caller explaining which broad scope is actually required and why. + required: false + default: 'false' + skip-token-revoke: + description: | + Leave the token valid when the job ends instead of revoking it. Off by + default. Only turn this on when a later job in the same workflow consumes + the token through an output. + required: false + default: 'false' + +outputs: + token: + description: 'The installation access token, masked in the log. Expires in 1 hour.' + value: ${{ steps.mint.outputs.token }} + installation-id: + description: 'Installation the token was issued for — the id to quote when an App permission needs changing.' + value: ${{ steps.mint.outputs.installation-id }} + app-slug: + description: "Slug of the App the token belongs to; this is the actor name that appears in the target repo's audit log." + value: ${{ steps.mint.outputs.app-slug }} + +runs: + using: 'composite' + steps: + - name: Validate inputs + shell: bash + env: + CLIENT_ID: ${{ inputs.client-id }} + APP_ID: ${{ inputs.app-id }} + PRIVATE_KEY: ${{ inputs.private-key }} + REPOSITORIES: ${{ inputs.repositories }} + ALLOW_BROAD_SCOPE: ${{ inputs.allow-broad-scope }} + PERMS: >- + ${{ inputs.permission-actions }}${{ inputs.permission-contents }}${{ inputs.permission-packages }}${{ + inputs.permission-organization-packages }}${{ inputs.permission-pull-requests }}${{ + inputs.permission-issues }}${{ inputs.permission-checks }}${{ inputs.permission-statuses }}${{ + inputs.permission-deployments }}${{ inputs.permission-workflows }} + run: | + set -euo pipefail + + # An unset org variable arrives as an empty string rather than being + # absent, so a typo'd `vars.GFORCE_CI_APP_ID` looks identical to not + # passing one. Name that here, where the message can say which secret. + if [ -z "$CLIENT_ID" ] && [ -z "$APP_ID" ]; then + echo "::error::Pass client-id (preferred) or app-id. Both were empty — check vars.GFORCE_CI_APP_ID is set at the org and visible to this repo." >&2 + exit 1 + fi + + if [ -z "$PRIVATE_KEY" ]; then + echo "::error::private-key is empty. Check secrets.GFORCE_CI_APP_PRIVATE_KEY is set at the org and this repo is in its selected-repositories list." >&2 + exit 1 + fi + + # A key pasted without its armour signs nothing, and the resulting + # error arrives from GitHub as a bare 401. + case "$PRIVATE_KEY" in + *"BEGIN"*"PRIVATE KEY"*) ;; + *) + echo "::error::private-key does not look like a PEM. Paste the whole .pem file including the -----BEGIN RSA PRIVATE KEY----- and -----END----- lines." >&2 + exit 1 + ;; + esac + + if [ "$ALLOW_BROAD_SCOPE" = "true" ]; then + echo "::warning::allow-broad-scope is on — this token takes every repository and permission the installation holds." + exit 0 + fi + + if [ -z "$REPOSITORIES" ]; then + echo "::error::repositories is empty, so the token would reach every repo in the installation. Name the repos this job touches, or set allow-broad-scope: true with a comment saying why." >&2 + exit 1 + fi + + # Concatenated rather than counted: any one permission being non-empty + # is enough to prove the caller thought about scope. + if [ -z "$(echo "$PERMS" | tr -d '[:space:]')" ]; then + echo "::error::No permission-* input was set, so the token would inherit every permission the App holds. Name the permissions this job needs (e.g. permission-actions: write), or set allow-broad-scope: true with a comment saying why." >&2 + exit 1 + fi + + - name: Mint the installation token + id: mint + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ inputs.client-id }} + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + owner: ${{ inputs.owner || github.repository_owner }} + repositories: ${{ inputs.repositories }} + skip-token-revoke: ${{ inputs.skip-token-revoke }} + permission-actions: ${{ inputs.permission-actions }} + permission-contents: ${{ inputs.permission-contents }} + permission-packages: ${{ inputs.permission-packages }} + permission-organization-packages: ${{ inputs.permission-organization-packages }} + permission-pull-requests: ${{ inputs.permission-pull-requests }} + permission-issues: ${{ inputs.permission-issues }} + permission-checks: ${{ inputs.permission-checks }} + permission-statuses: ${{ inputs.permission-statuses }} + permission-deployments: ${{ inputs.permission-deployments }} + permission-workflows: ${{ inputs.permission-workflows }} + + # What the token can do is invisible at the point of use — a 403 twenty + # lines later reads as "the API refused" rather than "the App lacks + # actions: write". Print the scope here so the log says which. + - name: Report the scope + shell: bash + env: + APP_SLUG: ${{ steps.mint.outputs.app-slug }} + INSTALLATION_ID: ${{ steps.mint.outputs.installation-id }} + OWNER: ${{ inputs.owner || github.repository_owner }} + REPOSITORIES: ${{ inputs.repositories }} + ALLOW_BROAD_SCOPE: ${{ inputs.allow-broad-scope }} + P_ACTIONS: ${{ inputs.permission-actions }} + P_CONTENTS: ${{ inputs.permission-contents }} + P_PACKAGES: ${{ inputs.permission-packages }} + P_ORG_PACKAGES: ${{ inputs.permission-organization-packages }} + P_PULL_REQUESTS: ${{ inputs.permission-pull-requests }} + P_ISSUES: ${{ inputs.permission-issues }} + P_CHECKS: ${{ inputs.permission-checks }} + P_STATUSES: ${{ inputs.permission-statuses }} + P_DEPLOYMENTS: ${{ inputs.permission-deployments }} + P_WORKFLOWS: ${{ inputs.permission-workflows }} + run: | + set -euo pipefail + + echo "✅ Minted an installation token" + echo " App: ${APP_SLUG:-unknown} (installation ${INSTALLATION_ID:-unknown})" + echo " Owner: $OWNER" + if [ "$ALLOW_BROAD_SCOPE" = "true" ]; then + echo " Repositories: ALL in the installation (allow-broad-scope)" + echo " Permissions: ALL the App holds (allow-broad-scope)" + else + echo " Repositories: $(echo "$REPOSITORIES" | tr '\n' ' ' | tr -s ' ')" + echo " Permissions:" + for PAIR in \ + "actions:$P_ACTIONS" "contents:$P_CONTENTS" "packages:$P_PACKAGES" \ + "organization-packages:$P_ORG_PACKAGES" \ + "pull-requests:$P_PULL_REQUESTS" "issues:$P_ISSUES" "checks:$P_CHECKS" \ + "statuses:$P_STATUSES" "deployments:$P_DEPLOYMENTS" "workflows:$P_WORKFLOWS"; do + NAME="${PAIR%%:*}" + LEVEL="${PAIR#*:}" + [ -n "$LEVEL" ] && printf ' %-14s %s\n' "$NAME" "$LEVEL" + done + fi + echo " Expires: 1 hour from now" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 733c087..ea1661c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,7 @@ updates: directories: - "/" - "/.github/actions/aws-secret-get" + - "/.github/actions/github-app-token" - "/.github/actions/sf-org-login" schedule: interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49473ab..7041f74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,3 +178,54 @@ jobs: run: | test "$PROMOTE_OUTCOME" = "failure" test "$INSTALL_OUTCOME" = "failure" + + # github-app-token guards scope before it ever calls GitHub, so all three + # rejections run here with a throwaway key and no App credentials at all. + # The guards are the reason the action exists — an unscoped installation + # token is just a PAT with a shorter life — so they are what CI pins. + - name: Smoke github-app-token (must reject a non-PEM private key) + id: app-token-bad-key + continue-on-error: true + uses: ./.github/actions/github-app-token + with: + client-id: Iv23liSMOKE0000000 + private-key: not-a-pem + repositories: shared-github-actions + permission-contents: read + + - name: Smoke github-app-token (must reject an unnamed repository scope) + id: app-token-no-repos + continue-on-error: true + uses: ./.github/actions/github-app-token + with: + client-id: Iv23liSMOKE0000000 + private-key: | + -----BEGIN RSA PRIVATE KEY----- + SMOKE + -----END RSA PRIVATE KEY----- + permission-contents: read + + - name: Smoke github-app-token (must reject an unnamed permission scope) + id: app-token-no-perms + continue-on-error: true + uses: ./.github/actions/github-app-token + with: + client-id: Iv23liSMOKE0000000 + private-key: | + -----BEGIN RSA PRIVATE KEY----- + SMOKE + -----END RSA PRIVATE KEY----- + repositories: shared-github-actions + + - name: Assert github-app-token fails closed on every unscoped route + env: + BAD_KEY_OUTCOME: ${{ steps.app-token-bad-key.outcome }} + NO_REPOS_OUTCOME: ${{ steps.app-token-no-repos.outcome }} + NO_PERMS_OUTCOME: ${{ steps.app-token-no-perms.outcome }} + run: | + test "$BAD_KEY_OUTCOME" = "failure" + # These two are the ones that matter: both would otherwise SUCCEED, + # handing back a working token that reaches every repo in the + # installation with every permission the App holds. + test "$NO_REPOS_OUTCOME" = "failure" + test "$NO_PERMS_OUTCOME" = "failure" diff --git a/README.md b/README.md index 1857a4b..6a3666c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,14 @@ matches plus a reference scan of test classes in the source tree. assumption; JSON fields are exported as env vars (reference them as `${{ env.FIELD }}`, not step outputs). Requires `id-token: write` + `contents: read`. +- **`github-app-token`** — mint a short-lived GitHub App installation token: the + sanctioned way to reach another repository, since `secrets.GITHUB_TOKEN` cannot + and a PAT is a person's credential with account-wide scope and no expiry. + **Fails closed** — it will not mint until the caller names both the + `repositories` and the `permission-*` levels, because omitting either is how a + replacement for an over-broad PAT becomes an over-broad token. Outputs `token` + (masked, 1-hour lifetime), `installation-id`, `app-slug`. One-time org setup: + [`docs/github-app-setup.md`](docs/github-app-setup.md). - **`sf-org-login`** — authenticate to a Salesforce org, either from an SFDX auth URL held in a GitHub secret (`auth-method: auth-url`, the default, no cloud dependency) or via the JWT bearer flow with credentials from AWS Secrets diff --git a/docs/claude-actions-reference.md b/docs/claude-actions-reference.md index 8d7e3bb..e533613 100644 --- a/docs/claude-actions-reference.md +++ b/docs/claude-actions-reference.md @@ -33,6 +33,51 @@ Fetches a secret from AWS Secrets Manager using OIDC role assumption. Parsed JSO **Inputs:** `aws-region` (default `eu-central-1`), `secret-name`, `aws-role-arn` **Outputs:** None declared. Secret fields are exposed as env vars (e.g. `$JWT_KEY_B64`, `$USERNAME`, `$CLIENT_ID`, `$INSTANCE_URL`); reference them as `${{ env.FIELD }}` in later steps, not as step outputs. +### `github-app-token` (`.github/actions/github-app-token/action.yml`) + +Mints a GitHub App installation access token. This is the sanctioned way to do anything +cross-repository — `secrets.GITHUB_TOKEN` cannot reach another repo, and the alternative +the fleet used before this action was a personal access token. + +One-time org setup (registering the App, its permissions, installing it, storing the +credentials): [`docs/github-app-setup.md`](github-app-setup.md). + +**Why it is not a thin wrapper.** `actions/create-github-app-token` is generous in two +directions at once: omit `repositories` and the token reaches every repo in the +installation; omit every `permission-*` and it inherits every permission the App holds. +Do both and you have replaced an over-broad PAT with an over-broad token that merely +expires sooner. So this action **fails closed** — it refuses to mint until the caller has +named the repositories *and* named the permissions, or has explicitly set +`allow-broad-scope: true`. Those two refusals are what `ci.yml`'s smoke job pins; they +run with a throwaway key and need no App credentials. + +**Inputs:** `client-id` (preferred) or `app-id` (upstream-deprecated), `private-key`, +`owner` (default: the repository owner), `repositories`, `permission-actions` / +`-contents` / `-packages` / `-organization-packages` / `-pull-requests` / `-issues` / +`-checks` / `-statuses` / `-deployments` / `-workflows`, `allow-broad-scope` (default +`false`), `skip-token-revoke` (default `false`) +**Outputs:** `token` (masked, 1-hour lifetime), `installation-id`, `app-slug` + +**Traps:** + +- `metadata` has no input on purpose — GitHub grants it implicitly to every installation + token, so an input for it would be noise. +- `packages` and `organization-packages` are different permissions and the split is easy + to get wrong. Pulling or pushing an image uses `packages`; changing a package's + visibility (`PATCH /orgs/{org}/packages/...`) or deleting a version is an **org** + endpoint that 403s with `packages` alone. A cleanup job that deletes throwaway tags + needs `organization-packages: write`. +- A requested permission the App does **not** hold is a hard error, not a silent + downgrade. Widening what a caller asks for means widening the App itself first, in the + org's App settings. +- The App must be *installed* on the target repo. Being registered in the org is not + enough, and the resulting failure is a 404 on the installation lookup, which reads like + a wrong App ID rather than a missing installation. +- Credentials live at org level (`vars.GFORCE_CI_APP_ID`, `secrets.GFORCE_CI_APP_PRIVATE_KEY`) + so a new repo inherits them. An org secret that is not shared with the calling repo + arrives as an empty string, indistinguishable from never being passed — the validation + step names that case specifically. + ### `sf-artifact-build` (`.github/actions/sf-artifact-build/action.yml`) Converts source to metadata format with Salesforce string replacements applied, then checksums diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md new file mode 100644 index 0000000..693cafa --- /dev/null +++ b/docs/github-app-setup.md @@ -0,0 +1,133 @@ +# Setting up the `gforce-ci-bot` GitHub App + +The one-time org setup behind [`github-app-token`](../.github/actions/github-app-token/action.yml). +Everything here is manual because **there is no API to create a GitHub App** — the +manifest flow needs a browser redirect, so a human clicks it once and then never again. + +## Why a second App + +`Gforce-Innovation-Kft` already has an App: **Github Gforce App** (`2453054`), which +`sf-develop-demo` authenticates as from Apex (`GitHubAppAuthService`) to dispatch +workflows. It would work for CI too, and that is exactly the reason not to use it. + +Its private key lives in Salesforce custom metadata +(`GitHub_App_Settings__mdt.Private_Key_Base64__c`). Reusing it for CI means the same key +also lives in GitHub org secrets: two systems, one credential, and a rotation that has to +land in both or silently break one. A CI-only App keeps the blast radius at CI. + +## 1. Register the App + + + +| Field | Value | +| ------------------------------------------- | --------------------------------------------------------------------------- | +| **GitHub App name** | `gforce-ci-bot` | +| **Homepage URL** | `https://github.com/Gforce-Innovation-Kft/shared-github-actions` | +| **Webhook** | **Uncheck "Active"** — nothing listens, and an inactive webhook cannot leak | +| **Where can this GitHub App be installed?** | Only on this account | + +### Permissions + +Two of these live under different headings in the UI, and the difference is the single +easiest thing to get wrong here. + +**Repository permissions** + +| Permission | Level | Used by | +| ---------- | ------------------ | -------------------------------------------------------------------------------- | +| Actions | **Read and write** | dispatching `weather2gp-release.yml` into `sf-develop-demo` and watching the run | +| Contents | **Read-only** | `gh run watch` resolving the dispatched run | +| Metadata | Read-only | mandatory, granted automatically | + +**Organization permissions** + +| Permission | Level | Used by | +| ---------- | ------------------ | ---------------------------------------------------------------------- | +| Packages | **Read and write** | making the throwaway GHCR candidate public, and deleting it afterwards | + +> There is a **Packages** entry under _Repository_ permissions as well. That one is not +> enough. Pulling and pushing an image uses the repository permission; setting a +> package's visibility (`PATCH /orgs/{org}/packages/...`) and deleting a version are org +> endpoints and will 403 with only the repository one. Grant the **organization** +> Packages permission. + +## 2. Generate a private key + +On the App's page → **Private keys** → **Generate a private key**. A `.pem` downloads. +That file is the credential; GitHub keeps no copy you can retrieve later. + +Note the **Client ID** (`Iv23li…`) from the same page — prefer it over the numeric App +ID, which `actions/create-github-app-token` deprecated in v3. + +## 3. Install it on the repositories + +App page → **Install App** → `Gforce-Innovation-Kft` → **Only select repositories**: + +- `sf-docker-images` +- `sf-develop-demo` + +Registering the App in the org is **not** the same as installing it. A missing +installation fails as a 404 on the installation lookup, which reads like a wrong App ID +and sends you back to check the wrong thing. + +## 4. Store the credentials at org level + +Org-level, so a new repo inherits them instead of copying the key around: + +```bash +gh variable set GFORCE_CI_APP_ID \ + --org Gforce-Innovation-Kft --visibility all \ + --body 'Iv23liXXXXXXXXXXXX' + +gh secret set GFORCE_CI_APP_PRIVATE_KEY \ + --org Gforce-Innovation-Kft --visibility all \ + < ~/Downloads/gforce-ci-bot.2026-08-14.private-key.pem +``` + +Then delete the local `.pem`. Generate a fresh one if you ever need it again — that is +cheaper than keeping a private key in `~/Downloads`. + +`--visibility all` covers private repos too. Use `--visibility selected --repos a,b` if +you would rather name them, but then adding a repo means remembering this step. + +## 5. Verify + +The next `sf-ci` pull request exercises all three token mints. In the job log the action +prints its own scope: + +``` +✅ Minted an installation token + App: gforce-ci-bot (installation 12345678) + Owner: Gforce-Innovation-Kft + Repositories: sf-develop-demo + Permissions: + actions write + contents read + Expires: 1 hour from now +``` + +Failure modes, in the order they actually happen: + +| Symptom | Cause | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `Pass client-id (preferred) or app-id. Both were empty` | the org variable is not shared with the calling repo — an unshared org variable arrives as an empty string, not an error | +| `private-key does not look like a PEM` | the key was pasted without its `-----BEGIN-----`/`-----END-----` lines | +| 404 on the installation lookup | App registered but not installed on that repo | +| 403 on `PATCH /orgs/.../packages/...` | the **organization** Packages permission is missing (the repository one was granted instead) | +| `repositories is empty` / `No permission-* input was set` | the caller did not scope the token; this is the action refusing to mint, not a misconfiguration | + +## Rotating the key + +Generate a new private key on the App page, run the `gh secret set` above again, then +delete the old key from the App. In that order — the reverse breaks every in-flight run. + +Nothing else needs to change: no workflow holds the key, only the org secret does. + +## Retiring the PATs + +`gforce-ci-bot` replaces `E2E_DISPATCH_TOKEN` (removed from `sf-docker-images` in +`feat(ci)!: authenticate the E2E gate as a GitHub App`). The fleet's other PAT, +`CATALOG_SCAN_TOKEN` in this repo's `catalog-refresh.yml`, can move to the same App — +it needs org-wide `contents: read` plus `pull-requests: write`, so it wants +`allow-broad-scope: true` or an explicit repository list, and is worth doing as its own +change.