From 215c33117477d4f7cd83f52544099174d87effd4 Mon Sep 17 00:00:00 2001 From: Luca Ghersi Date: Mon, 14 Sep 2026 17:42:24 +0200 Subject: [PATCH] feat(auth): support Workload Identity Federation for Artifact Registry Adds gcp-workload-identity-provider and gcp-service-account as an alternative to docker-username / docker-password. When both are set the action exchanges the job's OIDC token for a short-lived Google access token and logs in with it, so pushing to Artifact Registry needs no long-lived key. The calling job must grant permissions: id-token: write, which a composite action cannot request for itself. Artifact Registry accepts that token as a basic-auth password under the fixed username oauth2accesstoken, so the retag step keeps working against the registry v2 API without changes. The split is deliberate: the script decides whether authentication is possible, and the steps select the credential inline. Neither half of a credential passes through a step output or the environment file. The three steps previously gated on docker-username and docker-password now share that resolved flag, so they run under either credential source. Half-configured credentials now stop the run instead of skipping the build. A username with no password, or a provider with no service account, are what a mistyped secret name looks like, and skipping the build there left the job green while the deployment went stale. Each case names the missing half. Supplying no credentials at all stays a warning: that is the documented deploy-only usage. Both inputs are left without a default on purpose: this action is public, and a default would make every caller attempt Google authentication and fail any job that has not granted id-token: write. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 54 ++++++++++++++++ action.yml | 44 +++++++++++-- scripts/resolve-registry-auth.sh | 86 +++++++++++++++++++++++++ tests/resolve-registry-auth.bats | 106 +++++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 7 deletions(-) create mode 100755 scripts/resolve-registry-auth.sh create mode 100644 tests/resolve-registry-auth.bats diff --git a/README.md b/README.md index 033414d..510c2ad 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,58 @@ jobs: clusters/customization/prod/mothership/my-service/my-service-helm.yaml spec.template.spec.containers.redbook.image ``` +### Authenticating to a registry + +The action reaches the registry in one of two ways, and picks between them on its own. + +**Username and password.** Pass `docker-username` and `docker-password`. This is how Harbor is +reached. + +**Workload Identity Federation.** Pass `gcp-workload-identity-provider` and `gcp-service-account` +instead. The action exchanges the job's OIDC token for a short-lived Google access token and logs +in with it, so no long-lived key is stored anywhere. Google Artifact Registry accepts that token as +a basic-auth password under the fixed username `oauth2accesstoken`, which is what makes the retag +step work unchanged against the registry v2 API. + +```yaml +jobs: + ci-cd: + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write # required — see below + steps: + - uses: actions/checkout@v6 + - uses: Staffbase/gitops-github-action@v8 + with: + docker-registry: europe-docker.pkg.dev + docker-image: my-project/my-repo/my-service + gcp-workload-identity-provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github + gcp-service-account: ci-push@my-project.iam.gserviceaccount.com +``` + +> **The calling job must grant `permissions: id-token: write`.** A composite action cannot request +> a permission for itself, and `id-token` is never granted by default — not even when a repository's +> default token permissions are set to permissive. A reusable workflow cannot grant it either: a +> called workflow's permissions can only narrow what the caller already has. Without it, +> authentication fails before the build starts. + +When a Google access token is available it wins over `docker-username` / `docker-password`, so a +repository can be switched over by adding the GCP inputs without first removing the old pair. + +**Half-configured credentials fail the run.** A username with no password, or a WIF provider with no +service account, are what a mistyped secret name looks like, and the old behaviour — skip the build, carry on green — is how a deployment quietly goes stale. Those +now stop the run with an error naming the missing half. + +Supplying *no* credentials at all stays a warning rather than an error, because it is the documented +[deploy-only](#deploy-docker-image) configuration: the build and push steps are skipped and only the +GitOps update runs. + +Both GCP inputs are deliberately left without a default. This action is public and is used outside +Staffbase, and a default would make every caller attempt Google authentication — failing any job +that has not yet added the `id-token` permission. Staffbase-wide defaults belong one layer up, in +the `gha-workflows` templates. + ### Deployment tracking annotations By default (`deployment-annotations: 'true'`), whenever the action updates a GitOps file it stamps the following annotations onto the manifest's `metadata.annotations`: @@ -207,6 +259,8 @@ Pass the same `docker-*` inputs to both jobs — the merge job recomputes the ta | `docker-tag-keep-v-prefix` | Keep the leading `v` on release (`v*`) tags (`v1.2.3` → `v1.2.3`). Default strips it (`v1.2.3` → `1.2.3`) | `false` | | `docker-username` | Username for the Docker Registry | | | `docker-password` | Password for the Docker Registry | | +| `gcp-workload-identity-provider` | Full resource name of the Workload Identity Provider to authenticate to Google Cloud with. Set together with `gcp-service-account` to reach Artifact Registry without a long-lived key. See [Authenticating to a registry](#authenticating-to-a-registry) | | +| `gcp-service-account` | Google service account to impersonate through Workload Identity Federation. Required together with `gcp-workload-identity-provider` | | | `docker-file` | Dockerfile | `./Dockerfile` | | `docker-build-args` | List of build-time variables | | | `docker-build-secrets` | List of secrets to expose to the build (e.g., key=string, GIT_AUTH_TOKEN=mytoken) | | diff --git a/action.yml b/action.yml index 7449fa8..13848b3 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,12 @@ inputs: docker-password: description: 'Password for the Docker Registry' required: false + gcp-workload-identity-provider: + description: 'Full resource name of the Workload Identity Provider used to authenticate to Google Cloud, e.g. projects/123456789/locations/global/workloadIdentityPools/github/providers/github. Set together with gcp-service-account to push to Artifact Registry without a long-lived key, in place of docker-username / docker-password. The calling job must grant `permissions: id-token: write` — a composite action cannot request that for itself.' + required: false + gcp-service-account: + description: 'Email of the Google service account to impersonate through Workload Identity Federation. Required together with gcp-workload-identity-provider; setting one without the other fails the run.' + required: false docker-file: description: 'Path of the Dockerfile. Should be relative to input.working-directory' required: true @@ -181,21 +187,45 @@ runs: INPUT_DOCKER_BUILD_PLATFORMS: ${{ steps.build_config.outputs.platforms }} run: ${{ github.action_path }}/scripts/verify-architecture.sh + - name: Authenticate to Google Cloud + id: gcp_auth + if: inputs.gcp-workload-identity-provider != '' && inputs.gcp-service-account != '' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ inputs.gcp-workload-identity-provider }} + service_account: ${{ inputs.gcp-service-account }} + token_format: access_token + + - name: Resolve Registry Auth + id: registry_auth + shell: bash + env: + INPUT_GCP_ACCESS_TOKEN: ${{ steps.gcp_auth.outputs.access_token }} + INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.gcp-workload-identity-provider }} + INPUT_GCP_SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} + INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} + INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} + run: ${{ github.action_path }}/scripts/resolve-registry-auth.sh + - name: Set up Docker Buildx - if: inputs.docker-username != '' && inputs.docker-password != '' + if: steps.registry_auth.outputs.authenticated == 'true' uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + # A Google access token authenticates as the fixed user "oauth2accesstoken"; + # anything else is the docker-username / docker-password pair. Both halves are + # selected here rather than in the script above, because a password belongs in + # neither a step output nor the environment file. - name: Login to Registry - if: inputs.docker-username != '' && inputs.docker-password != '' + if: steps.registry_auth.outputs.authenticated == 'true' uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ inputs.docker-registry }} - username: ${{ inputs.docker-username }} - password: ${{ inputs.docker-password }} + username: ${{ steps.gcp_auth.outputs.access_token != '' && 'oauth2accesstoken' || inputs.docker-username }} + password: ${{ steps.gcp_auth.outputs.access_token || inputs.docker-password }} - name: Build id: docker_build - if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' && inputs.docker-username != '' && inputs.docker-password != '' + if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' && steps.registry_auth.outputs.authenticated == 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ inputs.working-directory }} @@ -260,8 +290,8 @@ runs: if: steps.preparation.outputs.build == 'false' && inputs.multiarch-mode != 'build' shell: bash env: - INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} - INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} + INPUT_DOCKER_USERNAME: ${{ steps.gcp_auth.outputs.access_token != '' && 'oauth2accesstoken' || inputs.docker-username }} + INPUT_DOCKER_PASSWORD: ${{ steps.gcp_auth.outputs.access_token || inputs.docker-password }} INPUT_DOCKER_REGISTRY_API: ${{ inputs.docker-registry-api }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} INPUT_TAG: ${{ steps.preparation.outputs.tag }} diff --git a/scripts/resolve-registry-auth.sh b/scripts/resolve-registry-auth.sh new file mode 100755 index 0000000..340c0e1 --- /dev/null +++ b/scripts/resolve-registry-auth.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Decides whether the registry steps can authenticate, and refuses to continue on +# a half-configured credential. +# +# Two sources are supported. A Google access token wins when present: Artifact +# Registry accepts it as a basic-auth password under the fixed username +# "oauth2accesstoken", both for `docker login` and for the registry v2 API the +# retag step talks to. Otherwise the action falls back to the docker-username / +# docker-password pair, which is how Harbor is reached. +# +# No credentials at all is a supported configuration, not a mistake: the +# deploy-only usage in the README passes none, and the build and push steps are +# skipped. Partial credentials are always a mistake, because they are what a +# mistyped secret name looks like, and silently skipping the build there is how a +# deployment quietly goes stale. Those fail here instead. +# +# No credential passes through this script. A password belongs in neither a step +# output nor the environment file, so action.yml selects both halves inline and +# only the decision is made here. +# +# Optional env vars: INPUT_GCP_ACCESS_TOKEN, INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER, +# INPUT_GCP_SERVICE_ACCOUNT, INPUT_DOCKER_USERNAME, +# INPUT_DOCKER_PASSWORD +# +# Outputs (via GITHUB_OUTPUT): authenticated, mode + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +GCP_ACCESS_TOKEN="${INPUT_GCP_ACCESS_TOKEN:-}" +GCP_PROVIDER="${INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER:-}" +GCP_SERVICE_ACCOUNT="${INPUT_GCP_SERVICE_ACCOUNT:-}" +DOCKER_USERNAME="${INPUT_DOCKER_USERNAME:-}" +DOCKER_PASSWORD="${INPUT_DOCKER_PASSWORD:-}" + +# --- Google, when the auth step produced a token --- + +if [[ -n "$GCP_ACCESS_TOKEN" ]]; then + log_info "Registry authentication mode: wif" + set_output "authenticated" "true" + set_output "mode" "wif" + exit 0 +fi + +# --- Google, half-configured --- + +if [[ -n "$GCP_PROVIDER" && -z "$GCP_SERVICE_ACCOUNT" ]]; then + log_error "gcp-workload-identity-provider is set but gcp-service-account is empty. Workload Identity Federation needs both." + exit 1 +fi + +if [[ -z "$GCP_PROVIDER" && -n "$GCP_SERVICE_ACCOUNT" ]]; then + log_error "gcp-service-account is set but gcp-workload-identity-provider is empty. Workload Identity Federation needs both." + exit 1 +fi + +if [[ -n "$GCP_PROVIDER" && -n "$GCP_SERVICE_ACCOUNT" ]]; then + log_error "Workload Identity Federation is configured but no access token was produced. The calling job must grant 'permissions: id-token: write' — a composite action cannot request it for itself." + exit 1 +fi + +# --- Username and password --- + +if [[ -n "$DOCKER_USERNAME" && -z "$DOCKER_PASSWORD" ]]; then + log_error "docker-username is set but docker-password is empty. Check that the password secret exists and is spelled correctly." + exit 1 +fi + +if [[ -z "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then + log_error "docker-password is set but docker-username is empty. Check that the username variable exists and is spelled correctly." + exit 1 +fi + +if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then + log_info "Registry authentication mode: basic" + set_output "authenticated" "true" + set_output "mode" "basic" + exit 0 +fi + +# --- Nothing supplied: the deploy-only configuration --- + +log_warn "No registry credentials supplied. The build and push steps will be skipped, which is expected only for the deploy-only usage. Pass docker-username and docker-password, or the gcp-* inputs, to build and push." +set_output "authenticated" "false" +set_output "mode" "none" diff --git a/tests/resolve-registry-auth.bats b/tests/resolve-registry-auth.bats new file mode 100644 index 0000000..08525c1 --- /dev/null +++ b/tests/resolve-registry-auth.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/resolve-registry-auth.sh" + +setup() { + setup_common + unset INPUT_GCP_ACCESS_TOKEN INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER \ + INPUT_GCP_SERVICE_ACCOUNT INPUT_DOCKER_USERNAME INPUT_DOCKER_PASSWORD +} + +teardown() { + teardown_common +} + +# --- basic auth (Harbor and anything else username/password) --- + +@test "username and password select basic auth" { + export INPUT_DOCKER_USERNAME="robot\$ci" + export INPUT_DOCKER_PASSWORD="secret" + run "$SCRIPT" + assert_success + assert_output_value "authenticated" "true" + assert_output_value "mode" "basic" +} + +# --- workload identity federation --- + +@test "a Google access token selects federated auth" { + export INPUT_GCP_ACCESS_TOKEN="ya29.token" + export INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER="projects/1/locations/global/workloadIdentityPools/p/providers/gh" + export INPUT_GCP_SERVICE_ACCOUNT="ci@example.iam.gserviceaccount.com" + run "$SCRIPT" + assert_success + assert_output_value "authenticated" "true" + assert_output_value "mode" "wif" +} + +@test "a Google access token wins over docker credentials" { + export INPUT_GCP_ACCESS_TOKEN="ya29.token" + export INPUT_DOCKER_USERNAME="robot\$ci" + export INPUT_DOCKER_PASSWORD="secret" + run "$SCRIPT" + assert_success + assert_output_value "mode" "wif" +} + +# --- half-configured credentials fail instead of silently skipping --- + +@test "a username without a password fails" { + export INPUT_DOCKER_USERNAME="robot\$ci" + run "$SCRIPT" + assert_failure + assert_output --partial "docker-password is empty" +} + +@test "a password without a username fails" { + export INPUT_DOCKER_PASSWORD="secret" + run "$SCRIPT" + assert_failure + assert_output --partial "docker-username is empty" +} + +@test "a WIF provider without a service account fails" { + export INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER="projects/1/locations/global/workloadIdentityPools/p/providers/gh" + run "$SCRIPT" + assert_failure + assert_output --partial "gcp-service-account is empty" +} + +@test "a service account without a WIF provider fails" { + export INPUT_GCP_SERVICE_ACCOUNT="ci@example.iam.gserviceaccount.com" + run "$SCRIPT" + assert_failure + assert_output --partial "gcp-workload-identity-provider is empty" +} + +@test "complete WIF inputs that produced no token name the missing permission" { + export INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER="projects/1/locations/global/workloadIdentityPools/p/providers/gh" + export INPUT_GCP_SERVICE_ACCOUNT="ci@example.iam.gserviceaccount.com" + run "$SCRIPT" + assert_failure + assert_output --partial "id-token: write" +} + +# --- no credentials at all is the documented deploy-only configuration --- + +@test "no credentials warns and skips instead of failing" { + run "$SCRIPT" + assert_success + assert_output_value "authenticated" "false" + assert_output_value "mode" "none" + assert_output --partial "::warning::" + assert_output --partial "deploy-only" +} + +@test "the resolved password is never written to an output" { + export INPUT_DOCKER_USERNAME="robot\$ci" + export INPUT_DOCKER_PASSWORD="super-secret" + run "$SCRIPT" + assert_success + refute_output --partial "super-secret" + run grep -c "super-secret" "$GITHUB_OUTPUT" + assert_output "0" +}