GitHub Actions

A workflow can log in to an edgekms tenant with the OIDC token GitHub issues to every job, so no Vault token, Cloudflare credential or other long-lived secret is stored in the repository. The tenant's config decides which repositories, branches, tags or environments may log in, and which keys their token may use.

The examples use the tenant acme (https://acme.edgekms.dev), a key infra-prod and the repository acme/app. Swap in your own.

1. Find the repository's numeric ids

Roles bind GitHub's numeric repository_id and repository_owner_id, not the names. A deleted repository's or organization's name can be registered again by someone else, and a role bound to the name would trust them.

gh api repos/acme/app --jq '{id, owner_id: .owner.id}'

The console's role form looks the ids up for you for a public repository; see its GitHub Actions recipe.

2. Add a mount, a role, a key and a policy

Fetch the config, merge these into its keys, mounts, roles and policies objects, and set it back:

export EDGEKMS_CONTROL_TOKEN="$(edgekms account login)"
edgekms tenant config get acme > config.json
# edit config.json
edgekms tenant config set acme config.json
{
  "keys": {
    "infra-prod": { "id": "k_3f9a1c7e5b2d4086", "latest_version": 1, "min_decryption_version": 1 }
  },
  "mounts": {
    "actions": {
      "type": "jwt",
      "issuer": "https://token.actions.githubusercontent.com",
      "jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks"
    }
  },
  "roles": {
    "deploy-main": {
      "mount": "actions",
      "bound_audiences": ["https://acme.edgekms.dev"],
      "bound_claims": {
        "repository_id": "812345678",
        "repository_owner_id": "1234567",
        "ref": "refs/heads/main"
      },
      "policies": ["infra-prod-decrypt"],
      "token_ttl_secs": 900
    }
  },
  "policies": {
    "infra-prod-decrypt": { "keys": { "infra-prod": ["decrypt"] } }
  }
}

A key id is any fresh value of lowercase letters, digits and _; it's where the key's material comes from, so changing it later makes a different key. The new config reaches every edge location within about 30 seconds.

Every bound claim must match exactly, and the token's aud must be one of bound_audiences. The audience is your tenant's URL, so a GitHub token minted for some other service isn't accepted here. For more than one branch or tag, write the value as a glob, where * matches any run of characters, slashes included:

{
  "roles": {
    "release": {
      "mount": "actions",
      "bound_audiences": ["https://acme.edgekms.dev"],
      "bound_claims": {
        "repository_id": "812345678",
        "repository_owner_id": "1234567",
        "ref": { "glob": "refs/tags/v*" }
      },
      "policies": ["infra-prod-decrypt"],
      "token_ttl_secs": 900
    }
  }
}

A glob without a * is refused, and a * in a plain string is just a character. To hold a role to one deployment environment's jobs, add "environment": "prod"; leave ref out to admit every branch and tag. GitHub's claims are listed in About security hardening with OpenID Connect.

3. Log in from the workflow

Give the job id-token: write, ask GitHub for a token with your tenant as the audience, and swap it at the mount's login path, /v1/auth/actions/login:

permissions:
  id-token: write
  contents: read

steps:
  - name: Log in to edgekms
    run: |
      jwt=$(curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$ACTIONS_ID_TOKEN_REQUEST_TOKEN") \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://acme.edgekms.dev" | jq -r .value)
      token=$(jq -n --arg jwt "$jwt" '{role: "deploy-main", jwt: $jwt}' |
        curl -fsS -X POST -H 'Content-Type: application/json' -d @- \
          https://acme.edgekms.dev/v1/auth/actions/login | jq -r .auth.client_token)
      echo "::add-mask::$token"
      echo "VAULT_ADDR=https://acme.edgekms.dev" >> "$GITHUB_ENV"
      echo "VAULT_TOKEN=$token" >> "$GITHUB_ENV"

-H @<(printf ...) hands curl the header through a file descriptor, which keeps the request token off curl's command line. The edgekms token lasts the role's token_ttl_secs, 15 minutes here.

hashicorp/vault-action does the same with method: jwt, path: actions, role: deploy-main and jwtGithubAudience: https://acme.edgekms.dev.

Every later step sees VAULT_ADDR and VAULT_TOKEN, which is all Pulumi, SOPS and the vault CLI need.

When a login is refused

A refusal is 400 permission denied whatever the reason, so it doesn't say which roles exist. Check, in order: the audience in the token request is exactly the URL in bound_audiences; the ids are the repository's and its owner's, as strings; ref matches the event (refs/heads/main for a push, refs/pull/<n>/merge for a pull request, refs/tags/<tag> for a tag); and the mount name in the login path is the one in the config.