Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/actions/gcp-secret-get/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: "GCP Secret Get"
description: "Fetches a JSON-bundle secret from Google Secret Manager via Direct Workload Identity Federation and returns its fields"
author: "GForce Innovation"

# Required caller permissions (Direct WIF — no service account, no key file):
# id-token: write
# contents: read
# The caller's job also needs an `environment:` whose name matches the WIF
# provider's attribute_condition — the identity that decides what this
# action can read comes from that claim, not from any input here.
#
# Outputs: none. Every top-level key in the secret's JSON payload is
# exported as an environment variable named by upper-casing that key
# (username -> USERNAME, client_id -> CLIENT_ID, jwt_key -> JWT_KEY, ...)
# and masked in the log. Deliberately generic, like aws-secret-get: this
# action does not know what shape any particular secret's JSON takes.
# Callers that need a specific contract (e.g. sf-org-login's JWT_KEY_B64)
# do their own remapping after calling this — see sf-org-login's
# "Stage credentials from GCP" step.

inputs:
gcp-workload-identity-provider:
description: "Full WIF provider resource name (projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>)"
required: true
gcp-project-id:
description: "GCP project ID holding the secret"
required: true
gcp-secret-name:
description: "Secret Manager secret ID, e.g. salesforce-devhub"
required: true

runs:
using: "composite"
steps:
- name: Authenticate to Google Cloud (Direct WIF)
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ inputs.gcp-workload-identity-provider }}
project_id: ${{ inputs.gcp-project-id }}

- name: Fetch secret from Secret Manager
uses: google-github-actions/get-secretmanager-secrets@v3
with:
secrets: |-
SECRET_JSON:${{ inputs.gcp-project-id }}/${{ inputs.gcp-secret-name }}

- name: Export secret fields as environment variables
shell: bash
env:
SECRET_NAME: ${{ inputs.gcp-secret-name }}
run: |
set -euo pipefail

# node rather than jq: node ships wherever the sf CLI runs, containers
# included, so this works on images that carry no jq (matches the
# convention in sf-org-login's "Resolve org identity" step).
node -e '
const data = JSON.parse(process.env.SECRET_JSON || "{}");
const keys = Object.keys(data);
if (keys.length === 0) {
console.error(`::error::Secret "${process.env.SECRET_NAME}" is empty or not valid JSON.`);
process.exit(1);
}
const fs = require("fs");
const lines = [];
for (const [key, value] of Object.entries(data)) {
console.log(`::add-mask::${value}`);
lines.push(`${key.toUpperCase()}=${value}`);
}
fs.appendFileSync(process.env.GITHUB_ENV, lines.join("\n") + "\n");
'
112 changes: 96 additions & 16 deletions .github/actions/sf-org-login/action.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
name: "Salesforce Org Login"
description: "Authenticate to a Salesforce org by SFDX auth URL or JWT bearer flow, with credentials from AWS Secrets Manager or GitHub Environment secrets"
description: "Authenticate to a Salesforce org by SFDX auth URL or JWT bearer flow, with credentials from AWS Secrets Manager, GitHub Environment secrets, or GCP Secret Manager"
author: "GForce Innovation"

# ==============================================================================
# One login action. Two ways to authenticate, two places credentials come from.
# One login action. Two ways to authenticate, three places credentials come from.
#
# auth-method HOW we authenticate auth-url | jwt
# credential-source WHERE credentials live aws | github-env
# credential-source WHERE credentials live aws | github-env | gcp
#
# Every combination ends in the same place — an authenticated `sf` CLI under
# `org-alias` — so downstream actions depend on one contract instead of
Expand All @@ -15,14 +15,21 @@ author: "GForce Innovation"
# credential-source: aws -> AWS Secrets Manager via OIDC role assumption
# credential-source: github-env -> values passed in from GitHub Environment
# secrets and variables (no cloud account)
# credential-source: gcp -> GCP Secret Manager via Direct Workload
# Identity Federation (no service account,
# no key file — see gforce-google-infra)
#
# `credential-source` exists so adding a source is additive. A future `gcp`
# branch slots in beside these two without touching a single caller.
# `credential-source` exists so adding a source is additive — `gcp` slots in
# beside the other two without touching a single caller of theirs.
#
# Caller permissions:
# auth-method: auth-url -> none beyond the job default
# auth-method: jwt + credential-source: aws -> id-token: write, contents: read
# auth-method: auth-url -> none beyond the job default
# auth-method: jwt + credential-source: aws -> id-token: write, contents: read
# auth-method: jwt + credential-source: github-env -> none beyond the job default
# auth-method: jwt + credential-source: gcp -> id-token: write, contents: read,
# plus a job `environment:` whose
# name matches the WIF attribute
# condition (e.g. `devhub`)
# ==============================================================================

inputs:
Expand All @@ -34,8 +41,8 @@ inputs:
credential-source:
description: |
Where JWT credentials come from: 'aws' (Secrets Manager, the historical
default) or 'github-env' (passed in directly). Ignored when auth-method
is auth-url.
default), 'github-env' (passed in directly), or 'gcp' (GCP Secret
Manager via Direct WIF). Ignored when auth-method is auth-url.
required: false
default: "aws"

Expand All @@ -59,6 +66,20 @@ inputs:
required: false
default: ""

# --- credential-source: gcp -------------------------------------------------
gcp-workload-identity-provider:
description: "Full WIF provider resource name. Required when credential-source is gcp."
required: false
default: ""
gcp-project-id:
description: "GCP project ID holding the secret. Required when credential-source is gcp."
required: false
default: ""
gcp-secret-name:
description: "Secret Manager secret ID with a username/client_id/instance_url/jwt_key JSON bundle, e.g. salesforce-devhub. Required when credential-source is gcp."
required: false
default: ""

# --- credential-source: github-env -----------------------------------------
jwt-key-b64:
description: "Base64-encoded RSA private key. Always pass a secret, never a literal."
Expand Down Expand Up @@ -134,6 +155,9 @@ runs:
CREDENTIAL_SOURCE: ${{ inputs.credential-source }}
SFDX_AUTH_URL: ${{ inputs.sfdx-auth-url }}
AWS_ROLE_ARN: ${{ inputs.aws-role-arn }}
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.gcp-workload-identity-provider }}
GCP_PROJECT_ID: ${{ inputs.gcp-project-id }}
GCP_SECRET_NAME: ${{ inputs.gcp-secret-name }}
JWT_KEY_B64_IN: ${{ inputs.jwt-key-b64 }}
USERNAME_IN: ${{ inputs.username }}
CLIENT_ID_IN: ${{ inputs.client-id }}
Expand Down Expand Up @@ -179,8 +203,18 @@ runs:
;;
esac
;;
gcp)
MISSING=""
[ -n "$GCP_WORKLOAD_IDENTITY_PROVIDER" ] || MISSING="$MISSING gcp-workload-identity-provider"
[ -n "$GCP_PROJECT_ID" ] || MISSING="$MISSING gcp-project-id"
[ -n "$GCP_SECRET_NAME" ] || MISSING="$MISSING gcp-secret-name"
if [ -n "$MISSING" ]; then
echo "::error::credential-source 'gcp' is missing:$MISSING" >&2
exit 1
fi
;;
*)
echo "::error::credential-source must be 'aws' or 'github-env', got '$CREDENTIAL_SOURCE'." >&2
echo "::error::credential-source must be 'aws', 'github-env', or 'gcp', got '$CREDENTIAL_SOURCE'." >&2
exit 1
;;
esac
Expand Down Expand Up @@ -257,8 +291,48 @@ runs:
echo "INSTANCE_URL=$INSTANCE_URL_IN"
} >> "$GITHUB_ENV"

- name: Fetch credentials from GCP Secret Manager
if: ${{ inputs.auth-method == 'jwt' && inputs.credential-source == 'gcp' }}
# Own-repo ref: composite actions cannot use relative paths, so this
# resolves at runtime against the released tag — see ADR 0002, decision 6.
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/gcp-secret-get@v2
with:
gcp-workload-identity-provider: ${{ inputs.gcp-workload-identity-provider }}
gcp-project-id: ${{ inputs.gcp-project-id }}
gcp-secret-name: ${{ inputs.gcp-secret-name }}

- name: Stage credentials from GCP
if: ${{ inputs.auth-method == 'jwt' && inputs.credential-source == 'gcp' }}
shell: bash
run: |
set -euo pipefail

# gcp-secret-get exported the bundle's own field names uppercased
# (username -> USERNAME, client_id -> CLIENT_ID, instance_url ->
# INSTANCE_URL, jwt_key -> JWT_KEY). Everything downstream of here
# expects the same JWT_KEY_B64/USERNAME/CLIENT_ID/INSTANCE_URL shape
# as the aws and github-env branches, so this step is the one place
# that bridges the gap — including base64-encoding JWT_KEY, which the
# GCP bundle stores as raw PEM (design doc §4.2), not pre-encoded.
for FIELD in JWT_KEY USERNAME CLIENT_ID INSTANCE_URL; do
if [ -z "${!FIELD:-}" ]; then
echo "::error::gcp-secret-get did not export $FIELD — check the secret's JSON has a username/client_id/instance_url/jwt_key bundle." >&2
exit 1
fi
done

JWT_KEY_B64_VAL=$(printf '%s' "$JWT_KEY" | base64 | tr -d '\n')
echo "::add-mask::$JWT_KEY_B64_VAL"

{
echo "JWT_KEY_B64=$JWT_KEY_B64_VAL"
echo "USERNAME=$USERNAME"
echo "CLIENT_ID=$CLIENT_ID"
echo "INSTANCE_URL=$INSTANCE_URL"
} >> "$GITHUB_ENV"

# ==========================================================================
# 2c. JWT login — shared by both credential sources
# 2c. JWT login — shared by all three credential sources
# ==========================================================================
- name: Log in with the JWT bearer flow
if: ${{ inputs.auth-method == 'jwt' }}
Expand All @@ -274,11 +348,17 @@ runs:

for FIELD in JWT_KEY_B64 USERNAME CLIENT_ID INSTANCE_URL; do
if [ -z "${!FIELD:-}" ]; then
if [ "$CREDENTIAL_SOURCE" = "aws" ]; then
echo "::error::Secret '$SECRET_NAME' exposed no $FIELD field. Expected: JWT_KEY_B64, USERNAME, CLIENT_ID, INSTANCE_URL." >&2
else
echo "::error::$FIELD was not staged from the GitHub environment." >&2
fi
case "$CREDENTIAL_SOURCE" in
aws)
echo "::error::Secret '$SECRET_NAME' exposed no $FIELD field. Expected: JWT_KEY_B64, USERNAME, CLIENT_ID, INSTANCE_URL." >&2
;;
gcp)
echo "::error::$FIELD was not staged from GCP Secret Manager." >&2
;;
*)
echo "::error::$FIELD was not staged from the GitHub environment." >&2
;;
esac
exit 1
fi
done
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/smoke-sf-org-login.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ jobs:
username: someone@example.com
instance-url: https://login.salesforce.com

- name: a missing gcp-workload-identity-provider
auth-method: jwt
credential-source: gcp
gcp-workload-identity-provider: ""
gcp-project-id: gforce-salesforce-cicd
gcp-secret-name: salesforce-devhub
instance-url: https://login.salesforce.com

- name: an unknown auth-method
auth-method: telepathy
credential-source: github-env
Expand All @@ -96,6 +104,9 @@ jobs:
client-id: ${{ matrix.case.client-id }}
username: ${{ matrix.case.username }}
instance-url: ${{ matrix.case.instance-url }}
gcp-workload-identity-provider: ${{ matrix.case.gcp-workload-identity-provider }}
gcp-project-id: ${{ matrix.case.gcp-project-id }}
gcp-secret-name: ${{ matrix.case.gcp-secret-name }}
org-alias: should-not-exist

- name: Assert it was rejected
Expand Down
Loading