Merge pull request #46 from sethbergman/fix/nlb-client-ip-and-kms-dat… #153
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| jobs: | |
| terraform: | |
| name: Terraform fmt/validate/test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: hashicorp/setup-terraform@v4 | |
| with: | |
| # terraform_wrapper wraps the binary in a script that mangles | |
| # exit codes and output, which breaks `terraform test`. | |
| terraform_wrapper: false | |
| # Pinned for the same reason the integration job pins the Vault | |
| # CLI: a floating version makes failures hard to attribute, and | |
| # `fmt -check` in particular can start failing on formatting | |
| # rules a new release introduced rather than on anything here. | |
| # | |
| # It also stops an upstream outage from reddening main. The | |
| # action's default is `latest`, which resolves the version over | |
| # the network on every run; that lookup returned a 504 on | |
| # 2026-08-25 and failed the job before any Terraform ran. | |
| # | |
| # terraform/{aws,azure} declare required_version >= 1.7. | |
| terraform_version: "1.15.9" | |
| - name: terraform fmt | |
| run: terraform fmt -check -recursive terraform/ | |
| - name: terraform validate (aws) | |
| run: | | |
| cd terraform/aws | |
| terraform init -backend=false | |
| terraform validate | |
| - name: terraform validate (azure) | |
| run: | | |
| cd terraform/azure | |
| terraform init -backend=false | |
| terraform validate | |
| - name: terraform test (aws) | |
| run: | | |
| # Runs against mocked providers, so no AWS credentials and | |
| # nothing is created. These assert on configuration semantics | |
| # that validate cannot see — quorum arithmetic, whether an | |
| # "internal" load balancer really lands in private subnets, | |
| # whether the health check keeps standby nodes in the pool. | |
| cd terraform/aws | |
| terraform test | |
| - name: terraform test (azure) | |
| run: | | |
| # Same arrangement: mocked providers, no Azure credentials, | |
| # nothing created. Catches things validate cannot — notably | |
| # Key Vault's 24-character name limit, which constrains the | |
| # value rather than the schema and so fails only at apply. | |
| cd terraform/azure | |
| terraform test | |
| ansible-lint: | |
| name: Ansible lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.12" | |
| - run: pip install ansible-lint | |
| - run: ansible-lint ansible/ | |
| env: | |
| ANSIBLE_ROLES_PATH: ${{ github.workspace }}/ansible/roles | |
| # Lint and parse catch different things: ansible-lint is happy with | |
| # a playbook that references a role variable no inventory defines, | |
| # and --syntax-check is happy with style problems. Run both. | |
| - name: Syntax-check the playbook | |
| run: ansible-playbook --syntax-check -i inventory/local playbooks/site.yml | |
| working-directory: ansible | |
| shellcheck: | |
| name: Shellcheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Run shellcheck | |
| run: | | |
| sudo apt-get update && sudo apt-get install -y shellcheck | |
| shellcheck scripts/*.sh | |
| # Test harnesses and their shims are shell too, and a bug in | |
| # them shows up as a *passing* test rather than a failing one. | |
| # | |
| # Discovered rather than listed: an enumerated list silently | |
| # stops covering each new suite, which is the same failure mode | |
| # these lint rules exist to catch. | |
| mapfile -t harness < <(git ls-files 'tests/**/*.sh' 'tests/*/fake-bin/*') | |
| printf 'Linting %d test files\n' "${#harness[@]}" | |
| [ "${#harness[@]}" -gt 0 ] || { echo "No test scripts found — the glob is wrong"; exit 1; } | |
| shellcheck -s bash "${harness[@]}" | |
| # Windows checkouts do not track the executable bit, so a script | |
| # committed from one lands as 100644 and CI fails with "Permission | |
| # denied" rather than anything about the script. Cheaper to assert | |
| # the invariant than to rediscover it each time. | |
| - name: Every shell script must be executable | |
| run: | | |
| # Also covers tests/*/fake-bin/*, whose shims deliberately have | |
| # no .sh suffix — they stand in for `vault`, `aws`, `curl` and | |
| # must be named exactly that to be found on PATH. | |
| non_exec="$(git ls-files -s '*.sh' 'tests/*/fake-bin/*' | grep -v '^100755' || true)" | |
| if [[ -n "$non_exec" ]]; then | |
| echo "These scripts are missing the executable bit:" | |
| echo "$non_exec" | |
| echo "Fix with: git update-index --chmod=+x <path>" | |
| exit 1 | |
| fi | |
| shell: bash | |
| lint-invariants: | |
| name: Shell invariants | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # Properties shellcheck has no opinion about, each added because the | |
| # pattern it rejects shipped here and was found by reading rather | |
| # than by any test. | |
| - name: Check shell invariants | |
| run: ./tests/lint/run-tests.sh | |
| markdownlint: | |
| name: Markdown lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: DavidAnson/markdownlint-cli2-action@v24 | |
| with: | |
| globs: "**/*.md" | |
| docs-index: | |
| name: Docs index | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # docs/README.md is generated from the H1 and H2 headings of every | |
| # file in docs/. A hand-edit to it, a renamed section, or a new | |
| # document nobody indexed all surface here as a diff, which is the | |
| # only thing keeping an index true to what it indexes. | |
| - name: Docs index is current | |
| run: ./scripts/generate-docs-index.sh --check | |
| # Staleness is not the only way the index can be wrong: a generator | |
| # that misreads a heading produces a wrong description this job | |
| # would then defend, since both halves of the diff come from it. | |
| - name: Generator reads the documents correctly | |
| run: ./tests/docs-index/run-tests.sh | |
| smoke-test: | |
| name: Deploy + smoke test (Docker Compose) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Bring up the local HA Vault cluster (Transit auto-unseal + monitoring) | |
| run: | | |
| ROOT_TOKEN=$(./scripts/bootstrap-dev-cluster.sh --with-monitoring) | |
| echo "::add-mask::$ROOT_TOKEN" | |
| echo "VAULT_ROOT_TOKEN=$ROOT_TOKEN" >> "$GITHUB_ENV" | |
| - name: Wait for the cluster to be ready to serve writes | |
| run: | | |
| # Defence in depth. bootstrap-dev-cluster.sh already waits for | |
| # the leader to become active, but every step below writes to | |
| # Vault, and "unsealed" is not the same as "active" — writing in | |
| # that window fails with "local node not active but active | |
| # cluster node not found". Cheap to re-check here so a failure | |
| # points at the cluster rather than at whichever write happened | |
| # to go first. | |
| for i in $(seq 1 30); do | |
| if curl --cacert docker/dev/tls/ca.crt -fsS -o /dev/null https://127.0.0.1:8200/v1/sys/health; then | |
| echo "Cluster is active and ready for writes." | |
| exit 0 | |
| fi | |
| echo "Waiting for an active node... ($i/30)" | |
| sleep 2 | |
| done | |
| echo "No active node after 60s. Seal status:" | |
| curl --cacert docker/dev/tls/ca.crt -fsS https://127.0.0.1:8200/v1/sys/seal-status | jq . || true | |
| echo "Leader status:" | |
| curl --cacert docker/dev/tls/ca.crt -fsS https://127.0.0.1:8200/v1/sys/leader | jq . || true | |
| exit 1 | |
| - name: Verify cluster health | |
| run: | | |
| STATUS=$(curl --cacert docker/dev/tls/ca.crt -fsS -o /dev/null -w '%{http_code}' https://127.0.0.1:8200/v1/sys/health) | |
| echo "Health endpoint returned: $STATUS" | |
| # 200 = initialized+unsealed+active, 429 = unsealed+standby, | |
| # 472/473 = DR/perf standby — all indicate a working node. | |
| case "$STATUS" in | |
| 200|429|472|473) echo "Cluster is healthy." ;; | |
| *) echo "Unexpected health status: $STATUS"; exit 1 ;; | |
| esac | |
| - name: Smoke test — write and read a secret | |
| run: | | |
| cd docker/dev | |
| docker compose exec -T -e VAULT_TOKEN="$VAULT_ROOT_TOKEN" vault-0 vault secrets enable -path=secret -version=2 kv | |
| docker compose exec -T -e VAULT_TOKEN="$VAULT_ROOT_TOKEN" vault-0 vault kv put secret/ci-smoke-test value=ok | |
| docker compose exec -T -e VAULT_TOKEN="$VAULT_ROOT_TOKEN" vault-0 vault kv get secret/ci-smoke-test | |
| - name: Verify Vault metrics are queryable in Prometheus | |
| run: | | |
| # The bootstrap script already waited for the scrape targets to be | |
| # up. This goes further and asserts a real Vault metric has landed | |
| # with the right value — proving the telemetry stanza works, not | |
| # just that the endpoint answers. | |
| RESULT=$(curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=vault_core_unsealed') | |
| echo "$RESULT" | jq . | |
| UNSEALED=$(echo "$RESULT" | jq '[.data.result[] | select(.value[1] == "1")] | length') | |
| echo "Nodes reporting unsealed via Prometheus: $UNSEALED" | |
| [ "$UNSEALED" -eq 3 ] || { echo "Expected 3 unsealed nodes in metrics, got $UNSEALED"; exit 1; } | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v | |
| secret-rotation-test: | |
| name: AppRole bootstrap + secret_id rotation | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install vault CLI | |
| run: | | |
| VAULT_VERSION=1.17.2 | |
| curl -fsSL -o vault.zip "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip" | |
| curl -fsSL -o SHA256SUMS "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS" | |
| EXPECTED=$(grep "vault_${VAULT_VERSION}_linux_amd64.zip" SHA256SUMS | awk '{print $1}') | |
| ACTUAL=$(sha256sum vault.zip | awk '{print $1}') | |
| [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ] || { echo "Checksum mismatch or missing entry"; exit 1; } | |
| unzip -o -q vault.zip vault | |
| sudo mv vault /usr/local/bin/vault | |
| vault version | |
| - name: Bring up a single Vault node (Transit auto-unseal) | |
| run: | | |
| ROOT_TOKEN=$(./scripts/bootstrap-dev-cluster.sh --nodes vault-0) | |
| echo "::add-mask::$ROOT_TOKEN" | |
| echo "VAULT_ADDR=https://127.0.0.1:8200" >> "$GITHUB_ENV" | |
| # The CLI verifies against the dev CA rather than skipping | |
| # verification, so a broken chain fails the job. | |
| echo "VAULT_CACERT=$PWD/docker/dev/tls/ca.crt" >> "$GITHUB_ENV" | |
| echo "VAULT_TOKEN=$ROOT_TOKEN" >> "$GITHUB_ENV" | |
| - name: Enable KV v2 and seed a test secret | |
| run: | | |
| vault secrets enable -path=secret -version=2 kv | |
| vault kv put secret/app/config greeting=hello | |
| - name: Bootstrap AppRole role | |
| run: | | |
| ./scripts/bootstrap-approle.sh \ | |
| --role app \ | |
| --policy-file examples/policies/app-readonly.hcl \ | |
| --secret-id-ttl 1h \ | |
| --token-ttl 15m \ | |
| --token-max-ttl 30m | |
| - name: First rotation — issue and use a secret_id | |
| run: | | |
| ROLE_ID=$(vault read -field=role_id auth/approle/role/app/role-id) | |
| echo "ROLE_ID=$ROLE_ID" >> "$GITHUB_ENV" | |
| SECRET_ID_1=$(./scripts/rotate-secret-id.sh --role app 2>rotate1.log) | |
| cat rotate1.log | |
| echo "::add-mask::$SECRET_ID_1" | |
| echo "SECRET_ID_1=$SECRET_ID_1" >> "$GITHUB_ENV" | |
| CLIENT_TOKEN_1=$(vault write -field=token auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID_1") | |
| echo "::add-mask::$CLIENT_TOKEN_1" | |
| VAULT_TOKEN="$CLIENT_TOKEN_1" vault kv get secret/app/config | |
| - name: Second rotation — old secret_id must be rejected, new one must work | |
| run: | | |
| SECRET_ID_2=$(./scripts/rotate-secret-id.sh --role app 2>rotate2.log) | |
| cat rotate2.log | |
| echo "::add-mask::$SECRET_ID_2" | |
| echo "Confirming the first secret_id no longer authenticates..." | |
| if vault write auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID_1" >should_fail.log 2>&1; then | |
| echo "ERROR: old secret_id still authenticated after rotation" | |
| cat should_fail.log | |
| exit 1 | |
| fi | |
| echo "Old secret_id correctly rejected." | |
| echo "Confirming the new secret_id authenticates and can read the seeded secret..." | |
| CLIENT_TOKEN_2=$(vault write -field=token auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID_2") | |
| echo "::add-mask::$CLIENT_TOKEN_2" | |
| VAULT_TOKEN="$CLIENT_TOKEN_2" vault kv get secret/app/config | |
| echo "Rotation verified end to end." | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v | |
| jwt-github-oidc-test: | |
| name: GitHub Actions OIDC login (JWT auth) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| # Required to mint the OIDC token this job authenticates with. Note | |
| # that pull requests from forks never get this, so a fork PR will not | |
| # be able to run this job — that restriction is GitHub's, and it's | |
| # also exactly why bound claims matter. | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install vault CLI | |
| run: | | |
| VAULT_VERSION=1.17.2 | |
| curl -fsSL -o vault.zip "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip" | |
| curl -fsSL -o SHA256SUMS "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS" | |
| EXPECTED=$(grep "vault_${VAULT_VERSION}_linux_amd64.zip" SHA256SUMS | awk '{print $1}') | |
| ACTUAL=$(sha256sum vault.zip | awk '{print $1}') | |
| [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ] || { echo "Checksum mismatch or missing entry"; exit 1; } | |
| unzip -o -q vault.zip vault | |
| sudo mv vault /usr/local/bin/vault | |
| vault version | |
| - name: Bring up a single Vault node (Transit auto-unseal) | |
| run: | | |
| ROOT_TOKEN=$(./scripts/bootstrap-dev-cluster.sh --nodes vault-0) | |
| echo "::add-mask::$ROOT_TOKEN" | |
| echo "VAULT_ADDR=https://127.0.0.1:8200" >> "$GITHUB_ENV" | |
| # The CLI verifies against the dev CA rather than skipping | |
| # verification, so a broken chain fails the job. | |
| echo "VAULT_CACERT=$PWD/docker/dev/tls/ca.crt" >> "$GITHUB_ENV" | |
| echo "VAULT_TOKEN=$ROOT_TOKEN" >> "$GITHUB_ENV" | |
| - name: Enable KV v2 and seed a CI secret | |
| run: | | |
| vault secrets enable -path=secret -version=2 kv | |
| vault kv put secret/ci/build greeting=hello-from-ci | |
| - name: Bootstrap the JWT role for this repository | |
| run: | | |
| ./scripts/bootstrap-jwt-github.sh \ | |
| --role github-ci \ | |
| --policy-file examples/policies/ci-readonly.hcl \ | |
| --repository "${{ github.repository }}" \ | |
| --audience vault \ | |
| --token-ttl 15m \ | |
| --token-max-ttl 30m | |
| - name: Mint a real GitHub OIDC token | |
| run: | | |
| # GitHub mints this fresh for this job. It is never stored | |
| # anywhere — that is the whole point compared to AppRole. | |
| JWT=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=vault" | jq -r '.value') | |
| [ -n "$JWT" ] && [ "$JWT" != "null" ] || { echo "Failed to mint an OIDC token"; exit 1; } | |
| echo "::add-mask::$JWT" | |
| echo "GH_JWT=$JWT" >> "$GITHUB_ENV" | |
| # Claims are not secret (the signature is what matters), so show | |
| # what Vault will be matching against — invaluable when a bound | |
| # claim doesn't match and login fails. | |
| echo "Token claims:" | |
| echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, repository, ref, sub}' || true | |
| - name: Log in to Vault with the OIDC token and read the secret | |
| run: | | |
| CI_TOKEN=$(vault write -field=token auth/jwt/login role=github-ci jwt="$GH_JWT") | |
| echo "::add-mask::$CI_TOKEN" | |
| echo "Logged in via GitHub OIDC — no stored credential involved." | |
| VAULT_TOKEN="$CI_TOKEN" vault kv get secret/ci/build | |
| - name: A token from another repository must be rejected | |
| run: | | |
| # The signature on our token is valid GitHub-issued, so without | |
| # bound claims this role would accept it. Binding to a different | |
| # repository is what must make it fail — this proves the claim | |
| # check is actually enforced rather than just configured. | |
| ./scripts/bootstrap-jwt-github.sh \ | |
| --role github-ci-otherrepo \ | |
| --policy-file examples/policies/ci-readonly.hcl \ | |
| --repository "some-other-org/some-other-repo" \ | |
| --audience vault | |
| if vault write auth/jwt/login role=github-ci-otherrepo jwt="$GH_JWT" >should_fail.log 2>&1; then | |
| echo "ERROR: token authenticated against a role bound to a different repository" | |
| cat should_fail.log | |
| exit 1 | |
| fi | |
| echo "Correctly rejected — bound claims are enforced:" | |
| grep -i "claim\|denied\|error" should_fail.log | head -5 || true | |
| - name: The read-only policy must not permit writes | |
| run: | | |
| CI_TOKEN=$(vault write -field=token auth/jwt/login role=github-ci jwt="$GH_JWT") | |
| echo "::add-mask::$CI_TOKEN" | |
| if VAULT_TOKEN="$CI_TOKEN" vault kv put secret/ci/build greeting=tampered >should_fail.log 2>&1; then | |
| echo "ERROR: ci-readonly policy allowed a write" | |
| cat should_fail.log | |
| exit 1 | |
| fi | |
| echo "Write correctly denied by the read-only policy." | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v | |
| human-oidc-test: | |
| name: Human OIDC login (Dex) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install vault CLI | |
| run: | | |
| VAULT_VERSION=1.17.2 | |
| curl -fsSL -o vault.zip "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip" | |
| curl -fsSL -o SHA256SUMS "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS" | |
| EXPECTED=$(grep "vault_${VAULT_VERSION}_linux_amd64.zip" SHA256SUMS | awk '{print $1}') | |
| ACTUAL=$(sha256sum vault.zip | awk '{print $1}') | |
| [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ] || { echo "Checksum mismatch or missing entry"; exit 1; } | |
| unzip -o -q vault.zip vault | |
| sudo mv vault /usr/local/bin/vault | |
| vault version | |
| - name: Make "dex" resolve on the host too | |
| run: | | |
| # The OIDC issuer has to be one URL that both Vault (inside the | |
| # compose network) and the browser (out here) can reach, or the | |
| # issuer check fails. Compose DNS handles the container side; | |
| # this handles the host side, pointing at the published port. | |
| echo "127.0.0.1 dex" | sudo tee -a /etc/hosts | |
| getent hosts dex | |
| - name: Bring up Vault + Dex | |
| run: | | |
| ROOT_TOKEN=$(./scripts/bootstrap-dev-cluster.sh --nodes vault-0 --with-oidc) | |
| echo "::add-mask::$ROOT_TOKEN" | |
| echo "VAULT_ADDR=https://127.0.0.1:8200" >> "$GITHUB_ENV" | |
| # The CLI verifies against the dev CA rather than skipping | |
| # verification, so a broken chain fails the job. | |
| echo "VAULT_CACERT=$PWD/docker/dev/tls/ca.crt" >> "$GITHUB_ENV" | |
| echo "VAULT_TOKEN=$ROOT_TOKEN" >> "$GITHUB_ENV" | |
| - name: Seed secrets the developer policy grants access to | |
| run: | | |
| vault secrets enable -path=secret -version=2 kv | |
| vault kv put secret/app/config greeting=hello-developer | |
| - name: Configure OIDC auth and map IdP groups to policies | |
| run: | | |
| ./scripts/bootstrap-oidc.sh \ | |
| --discovery-url http://dex:5556/dex \ | |
| --client-id vault \ | |
| --client-secret vault-dev-client-secret \ | |
| --group vault-developers:examples/policies/developer.hcl \ | |
| --group vault-operators:examples/policies/operator.hcl | |
| - name: Log in as a developer through the real OIDC flow | |
| run: | | |
| TOKEN=$(./scripts/oidc-login-test.sh \ | |
| --username developer@example.com \ | |
| --password password) | |
| echo "::add-mask::$TOKEN" | |
| echo "DEV_TOKEN=$TOKEN" >> "$GITHUB_ENV" | |
| echo "Token identity and policies:" | |
| # identity_policies is the one that matters here: policies that | |
| # come from an identity group land there, not in policies. | |
| VAULT_TOKEN="$TOKEN" vault token lookup -format=json \ | |
| | jq '.data | {display_name, entity_id, policies, identity_policies}' | |
| - name: Group membership must have produced the developer policy | |
| run: | | |
| # This is the assertion that matters. Login succeeding only | |
| # proves the IdP authenticated someone; it says nothing about | |
| # authorization. If the group alias were missing or misspelled, | |
| # login would still succeed and the token would carry only | |
| # "default" — which is exactly the failure this catches. | |
| LOOKUP=$(VAULT_TOKEN="$DEV_TOKEN" vault token lookup -format=json) | |
| echo "$LOOKUP" | jq '.data | {policies, identity_policies, entity_id}' | |
| # Policies granted through an identity group appear under | |
| # identity_policies, not policies — check both so this asserts | |
| # the thing it claims to. | |
| POLICIES=$(echo "$LOOKUP" | jq -r \ | |
| '[(.data.policies // [])[], (.data.identity_policies // [])[]] | join(",")') | |
| echo "Effective policies: $POLICIES" | |
| echo "$POLICIES" | grep -q "vault-developers" || { | |
| echo "ERROR: group claim did not map to the vault-developers policy" | |
| exit 1 | |
| } | |
| echo "IdP group mapped to a Vault policy correctly." | |
| - name: Developer can read app secrets | |
| run: | | |
| VAULT_TOKEN="$DEV_TOKEN" vault kv get secret/app/config | |
| - name: Developer must NOT have operator privileges | |
| run: | | |
| # Proves the policies are actually distinct rather than everyone | |
| # collapsing to the same access. | |
| if VAULT_TOKEN="$DEV_TOKEN" vault operator raft snapshot save /tmp/x.snap >should_fail.log 2>&1; then | |
| echo "ERROR: developer policy allowed a raft snapshot" | |
| cat should_fail.log | |
| exit 1 | |
| fi | |
| echo "Correctly denied — developers don't get operator access." | |
| - name: An operator logs in and gets a different policy set | |
| run: | | |
| OP_TOKEN=$(./scripts/oidc-login-test.sh \ | |
| --username operator@example.com \ | |
| --password password) | |
| echo "::add-mask::$OP_TOKEN" | |
| POLICIES=$(VAULT_TOKEN="$OP_TOKEN" vault token lookup -format=json \ | |
| | jq -r '[(.data.policies // [])[], (.data.identity_policies // [])[]] | join(",")') | |
| echo "Operator effective policies: $POLICIES" | |
| echo "$POLICIES" | grep -q "vault-operators" || { | |
| echo "ERROR: operator did not receive the vault-operators policy" | |
| exit 1 | |
| } | |
| # Same IdP, same Vault, different group — and therefore the | |
| # mirror image of the developer's access. | |
| if VAULT_TOKEN="$OP_TOKEN" vault kv get secret/app/config >should_fail.log 2>&1; then | |
| echo "ERROR: operator policy allowed reading application secrets" | |
| cat should_fail.log | |
| exit 1 | |
| fi | |
| echo "Operator correctly cannot read application secrets." | |
| - name: Bad credentials must be rejected | |
| run: | | |
| if ./scripts/oidc-login-test.sh \ | |
| --username developer@example.com \ | |
| --password wrong-password >should_fail.log 2>&1; then | |
| echo "ERROR: login succeeded with the wrong password" | |
| exit 1 | |
| fi | |
| echo "Wrong password correctly rejected." | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v | |
| dr-drill-test: | |
| name: DR restore drill | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Run the disaster recovery drill | |
| run: ./scripts/dr-drill.sh | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v | |
| upgrade-test: | |
| name: Rolling upgrade tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/vault-upgrade.sh | |
| run: ./tests/upgrade/run-tests.sh | |
| ansible-handoff-test: | |
| name: Terraform to Ansible handoff tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.12" | |
| # jinja2 renders the role template the way Ansible would, so the | |
| # tests exercise the real template rather than a copy of it. | |
| - name: Install rendering dependencies | |
| run: pip install jinja2 pyyaml | |
| - name: Exercise the Terraform to Ansible handoff | |
| run: ./tests/ansible/run-tests.sh | |
| snapshot-test: | |
| name: Scheduled snapshot tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/snapshot.sh | |
| run: ./tests/snapshot/run-tests.sh | |
| pki-test: | |
| name: Vault PKI node certificate tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/bootstrap-pki.sh and scripts/issue-node-cert.sh | |
| run: ./tests/pki/run-tests.sh | |
| integration-test: | |
| name: Integration (real cluster) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The scripts run as a host process against the cluster's published | |
| # ports, the way they would on a real node — so the runner needs the | |
| # CLI. Pinned to the version the container image uses, because | |
| # `snapshot inspect` and the PKI endpoints are version-sensitive and | |
| # a floating CLI would make failures hard to attribute. | |
| - name: Install the Vault CLI | |
| run: | | |
| VAULT_VERSION=1.17.2 | |
| curl -fsSL -o /tmp/vault.zip "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip" | |
| unzip -o -d /tmp /tmp/vault.zip vault | |
| sudo install -m 0755 /tmp/vault /usr/local/bin/vault | |
| vault version | |
| - name: Run the operational scripts against a real cluster | |
| run: ./tests/integration/run-tests.sh | |
| - name: Tear down | |
| if: always() | |
| run: | | |
| cd docker/dev | |
| docker compose down -v || true | |
| database-test: | |
| name: Dynamic database credentials tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/bootstrap-database-secrets.sh | |
| run: ./tests/database/run-tests.sh | |
| alert-routing-test: | |
| name: Alert routing tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install PyYAML | |
| run: pip install --quiet pyyaml | |
| # The amtool cases run `amtool` out of the pinned Alertmanager | |
| # image, so which route matches a label set is answered by | |
| # Alertmanager rather than by a reimplementation of its matching | |
| # rules in the test. Runners already have Docker; the suite skips | |
| # those cases anywhere it is missing. | |
| - name: Exercise the Alertmanager routing tree | |
| run: ./tests/alert-routing/run-tests.sh | |
| alerting-test: | |
| name: Alerting rule tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # promtool ships with Prometheus. Pinned to the same version the | |
| # compose profile runs, so a rule that passes here is one that | |
| # version will actually load. | |
| - name: Install promtool | |
| run: | | |
| PROM_VERSION=2.54.1 | |
| curl -fsSL -o /tmp/prom.tar.gz "https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz" | |
| tar -xzf /tmp/prom.tar.gz -C /tmp | |
| sudo install -m 0755 "/tmp/prometheus-${PROM_VERSION}.linux-amd64/promtool" /usr/local/bin/promtool | |
| promtool --version | |
| - name: Exercise the alerting rules | |
| run: ./tests/alerting/run-tests.sh | |
| audit-test: | |
| name: Audit device tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/bootstrap-audit.sh | |
| run: ./tests/audit/run-tests.sh | |
| audit-chain-test: | |
| name: Audit chain and anchor tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise the collector's chaining and its verifier | |
| run: ./tests/audit-chain/run-tests.sh | |
| pki-migration-test: | |
| name: PKI migration driver tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/migrate-to-vault-pki.sh | |
| run: ./tests/pki-migration/run-tests.sh | |
| agent-test: | |
| name: Vault Agent bootstrap tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Exercise scripts/bootstrap-agent.sh | |
| run: ./tests/agent/run-tests.sh | |
| cloud-preflight-test: | |
| name: Cloud pre-flight and teardown tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # These cover the decisions the scripts make — whether a missing | |
| # key pair fails or warns, whether teardown empties the bucket | |
| # before calling destroy, whether the paging loop terminates. | |
| # | |
| # They cannot cover whether AWS agrees; nothing here has ever been | |
| # applied. docs/cloud-apply.md lists what a human has to observe | |
| # once, against a real account, for that gap to close. | |
| - name: Exercise scripts/preflight-cloud.sh and teardown-cloud.sh | |
| run: ./tests/cloud-preflight/run-tests.sh | |
| security-scan: | |
| name: Security scanning | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # gitleaks needs history to scan past commits, not just the tip. | |
| # A secret committed and then removed is still a leaked secret. | |
| fetch-depth: 0 | |
| - name: Scan for committed secrets | |
| uses: gitleaks/gitleaks-action@v3 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Scan Terraform and Dockerfiles for misconfigurations | |
| uses: aquasecurity/trivy-action@v0.36.0 | |
| with: | |
| scan-type: config | |
| scan-ref: . | |
| trivy-config: "" | |
| ignore-unfixed: false | |
| # Fail on HIGH and above. Accepted findings live in | |
| # .trivyignore.yaml, each with the reason it is accepted — a | |
| # suppression with no justification is indistinguishable from | |
| # never having run the scanner. | |
| severity: HIGH,CRITICAL | |
| exit-code: "1" | |
| trivyignores: .trivyignore.yaml |