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" +}