diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36991bb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +**/.terraform +**/.bootstrap +local/.env +*.md +docs +adrs +runbooks +examples +aws +gcp +azure +.github diff --git a/.github/workflows/test-local.yml b/.github/workflows/test-local.yml index 0f1dfa3..36f88b1 100644 --- a/.github/workflows/test-local.yml +++ b/.github/workflows/test-local.yml @@ -1,4 +1,3 @@ -# Isolation without a database, then credentials as a second job. name: test-local on: @@ -9,174 +8,31 @@ on: permissions: contents: read -env: - VAULT_ADDR: http://127.0.0.1:8200 - jobs: - # Isolation, with no database anywhere. - isolation: - name: isolation (no database) + go: + name: go tests (docker) runs-on: ubuntu-latest - timeout-minutes: 15 - + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Prepare environment - working-directory: local - run: cp .env.example .env - - - name: Setup (auto-init + unseal, no database) - working-directory: local - run: ./setup.sh - - - name: Confirm no database container is running - working-directory: local - # The assertion this whole job exists for. If a database is running, - # every isolation result below is contaminated by a dependency that - # the isolation capability is supposed not to have. - run: | - set -euo pipefail - running="$(docker compose ps --format '{{.Service}}')" - echo "running: ${running}" - if echo "${running}" | grep -q postgres; then - echo "FAIL: PostgreSQL is running during the isolation-only job" - exit 1 - fi - - - name: Isolation conformance - working-directory: local - env: - # Supplied by the local target. The tests contain no Docker; they - # invoke whatever command the environment provides. - AUDIT_READ_CMD: docker compose exec -T vault cat /vault/logs/audit.log - run: | - set -euo pipefail - export VAULT_TOKEN="$(cat .bootstrap/provisioning.token)" - ../tests/run-conformance.sh --layer isolation - - - name: Requesting a disabled capability must fail, not skip - working-directory: local - # A skipped suite that exits 0 is indistinguishable from a passing one, - # and nobody reads the output of a green CI run. - run: | - set -euo pipefail - export VAULT_TOKEN="$(cat .bootstrap/provisioning.token)" - if ../tests/run-conformance.sh --layer credentials; then - echo "FAIL: the credentials suite reported success while disabled" - exit 1 - fi - echo "correctly refused to run a disabled capability" - - - name: Local runtime tests - working-directory: local - run: ./tests/runtime-test.sh - - - name: Capture audit log on failure - if: failure() - working-directory: local - run: | - docker compose exec -T vault cat /vault/logs/audit.log > /tmp/audit.json || true - docker compose logs vault > /tmp/vault.log 2>&1 || true - - - name: Upload diagnostics - if: failure() - uses: actions/upload-artifact@v4 + - uses: actions/setup-go@v5 with: - name: isolation-diagnostics - # HMAC-ed, so no plaintext secrets - but it does reveal paths, - # identities, and timing, so retention is short. - path: | - /tmp/audit.json - /tmp/vault.log - retention-days: 3 + go-version: "1.23.x" - - name: Tear down - if: always() - working-directory: local - run: ./reset.sh --yes || true + - name: Isolation and credentials + run: go test ./internal/vaultcluster -count=1 -timeout 15m - # Dynamic credentials, plus the isolation matrix re-run. - credentials: - name: dynamic credentials + stack: + name: compose runtime conformance runs-on: ubuntu-latest timeout-minutes: 20 - # Sequential on purpose. If isolation is red, credential results are noise: - # they would be measured against an unproven isolation model, which is the - # same reasoning as the checkpoint in the implementation plan. - needs: isolation - - env: - ENABLE_DYNAMIC_CREDENTIALS: "true" - steps: - uses: actions/checkout@v4 - - name: Prepare environment - working-directory: local - run: cp .env.example .env - - - name: Setup (auto-init + unseal, with PostgreSQL) - working-directory: local - run: ./setup.sh --with-credentials - - - name: Full conformance - working-directory: local - env: - PSQL_CMD: docker compose exec -T postgres psql - DB_TEST_HOST: localhost - DB_TEST_NAME: appdb - DB_ADMIN_USER: postgres - DB_ADMIN_PASSWORD: local-dev-only-not-a-real-secret - AUDIT_READ_CMD: docker compose exec -T vault cat /vault/logs/audit.log - # --layer all re-runs the isolation matrix with credentials enabled. - # Additive changes are where a widened boundary goes unnoticed, since - # the isolation job never sees dynamic credentials at all. - run: | - set -euo pipefail - export VAULT_TOKEN="$(cat .bootstrap/provisioning.token)" - ../tests/run-conformance.sh --layer all - - - name: Final residue check - working-directory: local - # Independent of the suite's own scan. If the tests themselves leaked a - # role, the suite that leaked it is not the right thing to ask. - run: | - set -euo pipefail - leftover="$(docker compose exec -T postgres psql \ - "postgresql://postgres:local-dev-only-not-a-real-secret@localhost:5432/appdb" \ - -tAc "SELECT count(*) FROM pg_roles WHERE rolname LIKE 'v-%';" | tr -d '[:space:]')" - echo "vault-created roles remaining: ${leftover}" - if [ "${leftover}" != "0" ]; then - docker compose exec -T postgres psql \ - "postgresql://postgres:local-dev-only-not-a-real-secret@localhost:5432/appdb" \ - -c "SELECT rolname, rolvaliduntil FROM pg_roles WHERE rolname LIKE 'v-%';" - echo "NOTE: roles remain. Acceptable only if backed by live leases." - fi - - - name: Capture diagnostics on failure - if: failure() - working-directory: local - run: | - docker compose exec -T vault cat /vault/logs/audit.log > /tmp/audit.json || true - docker compose logs > /tmp/services.log 2>&1 || true - docker compose exec -T postgres psql \ - "postgresql://postgres:local-dev-only-not-a-real-secret@localhost:5432/appdb" \ - -c "SELECT rolname, rolvaliduntil FROM pg_roles WHERE rolname LIKE 'v-%';" \ - > /tmp/pg_roles.txt 2>&1 || true - - - name: Upload diagnostics - if: failure() - uses: actions/upload-artifact@v4 + - uses: actions/setup-go@v5 with: - name: credentials-diagnostics - path: | - /tmp/audit.json - /tmp/services.log - /tmp/pg_roles.txt - retention-days: 3 + go-version: "1.23.x" - - name: Tear down - if: always() - working-directory: local - run: ./reset.sh --yes || true + - name: Local compose runtime test + run: go test ./local -count=1 -timeout 15m -v diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 06b6e0d..06718ae 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,8 +1,4 @@ -# Static validation. Runs on every push and pull request. -# -# Needs no Vault, no Docker, and no secrets, so it is fast and cannot be flaky. -# Everything here catches a defect class that is otherwise invisible until -# something is deployed. +# Static validation. name: validate on: @@ -14,72 +10,18 @@ permissions: contents: read jobs: - shellcheck: - name: shellcheck + go: + name: go unit tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install shellcheck - run: sudo apt-get update && sudo apt-get install -y shellcheck + - uses: actions/setup-go@v5 + with: + go-version: "1.23.x" - - name: Lint all shell scripts - run: | - set -euo pipefail - mapfile -t files < <(find config scripts tests local -name '*.sh' | sort) - printf 'checking %d scripts\n' "${#files[@]}" - # -x follows sourced files, so cross-file problems are caught too. - shellcheck -x --severity=warning --shell=bash "${files[@]}" - - - name: Every script must be executable - run: | - set -euo pipefail - # A non-executable script fails at the least convenient moment, in - # someone else's environment, with a confusing error. - fail=0 - while IFS= read -r f; do - case "$f" in */lib/*|*/lib.sh|*/common/*) continue ;; esac - [ -x "$f" ] || { echo "not executable: $f"; fail=1; } - done < <(find config scripts tests local -name '*.sh' | sort) - exit "$fail" - - policy-lint: - name: policy linter self-test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Linter must reject every BAD fixture and accept GOOD ones - # The detector self-test. A linter that has never rejected anything is - # indistinguishable from one with a broken pattern - both report - # success on every run. - run: tests/lint/lint-self-test.sh - - - name: Real templates must render and pass lint - run: | - set -euo pipefail - scripts/tenants/render-policy.sh provisioning >/dev/null - scripts/tenants/render-policy.sh operator >/dev/null - for t in reader writer database; do - scripts/tenants/render-policy.sh "tenant-$t" tenant-a "tenant-tenant-a-$t" >/dev/null - done - tests/lint/policy-lint.sh config/policies/rendered/*.hcl - - - name: Tenant ID validation accepts and rejects correctly - run: | - set -euo pipefail - fail=0 - for id in tenant-a tenant-b acme-corp; do - scripts/validation/validate-tenant-id.sh "$id" >/dev/null \ - || { echo "should have been ACCEPTED: $id"; fail=1; } - done - # Each of these would become an ACL path if accepted. - for id in 'a/b' '../etc' 'tenant-*' 'TENANT' 'ab' 'sys' 'data' 'a b' 'a_b' '-x' 'x-'; do - if scripts/validation/validate-tenant-id.sh "$id" >/dev/null 2>&1; then - echo "should have been REJECTED: $id"; fail=1 - fi - done - exit "$fail" + - name: Unit tests (no Docker) + run: go test -short ./... architecture: name: runtime separation @@ -87,37 +29,26 @@ jobs: steps: - uses: actions/checkout@v4 - - name: shared config/scripts/tests must not reference any runtime - # Shared config must not depend on a runtime. Enforced mechanically. - # A rule checked only at review time erodes; this fails the build. + - name: HTTP-only trees must not reference any runtime run: | set -euo pipefail fail=0 check() { local pattern="$1" label="$2" - if grep -rInE "$pattern" config/ scripts/ tests/ --include='*.sh' --include='*.tpl' --include='*.hcl' \ + if grep -rInE "$pattern" \ + internal/vaultcluster/policies \ + --include='*.sh' --include='*.tpl' --include='*.hcl' \ | grep -vE '^\s*[^:]+:[0-9]+:\s*#' ; then - echo "FAIL: shared tree references $label" + echo "FAIL: HTTP-only tree references $label" fail=1 fi } - # Comment lines are excluded above: the prohibition is on depending - # on a runtime, not on explaining why the rule exists. check '\bdocker\b' 'docker' check '\bdocker[- ]compose\b' 'docker compose' check 'vault-cluster-(vault|postgres)' 'container names' check '/vault/(file|logs|config)' 'container filesystem paths' exit "$fail" - - name: Conformance tests must be driven only by VAULT_ADDR - run: | - set -euo pipefail - if grep -rIn 'docker' tests/conformance/ | grep -vE ':\s*#'; then - echo "FAIL: conformance tests reference docker." - echo "Runtime-specific assertions belong in local/tests/." - exit 1 - fi - security: name: secret scan and CE guard runs-on: ubuntu-latest @@ -128,14 +59,12 @@ jobs: run: | set -euo pipefail fail=0 - # Names first: these files must never exist in the tree at all. while IFS= read -r f; do echo "FAIL: bootstrap material committed: $f"; fail=1 done < <(find . -path ./.git -prune -o \ \( -name 'vault-init.json' -o -name '*.snap' \ -o -name '*.token' -o -name 'unseal*' \) -print) - # Then content: an unseal key or root token pasted into a doc. if grep -rInE 'hvs\.[A-Za-z0-9_-]{20,}|"root_token"\s*:\s*"hvs' . \ --exclude-dir=.git --exclude=CHANGELOG.md; then echo "FAIL: what looks like a real Vault token is committed"; fail=1 @@ -143,32 +72,26 @@ jobs: exit "$fail" - name: Fake credentials are labelled as fake - # Fixture values that look plausible eventually get copied somewhere - # real. Everything in this repo is prefixed FAKE- or local-dev-. run: | set -euo pipefail if grep -rInE '(password|secret|api_key)\s*[:=]\s*"[A-Za-z0-9]{16,}"' \ - config/ scripts/ tests/ 2>/dev/null \ + local internal 2>/dev/null \ | grep -viE 'FAKE-|local-dev-|\{\{|\$\{|not-a-real'; then echo "FAIL: an unlabelled credential-shaped literal was found" exit 1 fi - name: No Vault Enterprise features are used - # Community Edition only. An Enterprise-only endpoint - # works against a trial binary and fails on the licensed-out one, which - # is the worst possible time to find out. run: | set -euo pipefail fail=0 - # sys/namespaces, control groups, performance replication, Sentinel. if grep -rInE 'sys/namespaces|X-Vault-Namespace|sys/replication|sys/control-group|sentinel' \ - config/ scripts/ tests/ local/ --include='*.sh' --include='*.hcl' --include='*.tpl' \ + local/ cmd/ internal/ --include='*.sh' --include='*.hcl' --include='*.tpl' --include='*.go' \ | grep -vE ':\s*#' | grep -v 'VAULT_NAMESPACE'; then echo "FAIL: Vault Enterprise-only feature referenced (Community Edition only)" fail=1 fi - if grep -rIn 'hashicorp/vault-enterprise' config scripts tests local --exclude-dir=.git; then + if grep -rIn 'hashicorp/vault-enterprise' local cmd internal --exclude-dir=.git; then echo "FAIL: Enterprise image referenced"; fail=1 fi exit "$fail" @@ -177,7 +100,7 @@ jobs: run: | set -euo pipefail fail=0 - if grep -nE '^[A-Z_]*IMAGE=' local/.env.example | grep -v '@sha256:'; then + if grep -nE '^\s*image:' local/compose.yml | grep -v '@sha256:'; then echo "FAIL: an image is not digest-pinned"; fail=1 fi if grep -rn ':latest' local/ --include='*.yml' --include='.env.example'; then @@ -191,7 +114,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Compose file is valid and PostgreSQL is profile-gated + - name: Compose file is valid with a one-shot bootstrap run: | set -euo pipefail cd local @@ -201,26 +124,19 @@ jobs: default_svcs="$(docker compose --env-file .env config --services)" printf 'default services:\n%s\n' "${default_svcs}" - if printf '%s\n' "${default_svcs}" | grep -qx 'postgres'; then - echo "FAIL: postgres appears without the credentials profile" + if printf '%s\n' "${default_svcs}" | grep -qx 'unseal'; then + echo "FAIL: long-running unseal sidecar should not be a default service" exit 1 fi - - cred_svcs="$(COMPOSE_PROFILES=credentials docker compose --env-file .env --profile credentials config --services)" - printf 'credentials services:\n%s\n' "${cred_svcs}" - if ! printf '%s\n' "${cred_svcs}" | grep -qx 'postgres'; then - echo "FAIL: postgres missing from the credentials profile" + if ! printf '%s\n' "${default_svcs}" | grep -qx 'bootstrap'; then + echo "FAIL: bootstrap one-shot missing from default compose services" exit 1 fi - - if ! printf '%s\n' "${default_svcs}" | grep -qx 'unseal'; then - echo "FAIL: unseal sidecar missing from the default compose services" + if printf '%s\n' "${default_svcs}" | grep -qx 'postgres'; then + echo "FAIL: the local stack must not run PostgreSQL" exit 1 fi - grep -A20 '^ postgres:' docker-compose.yml | grep -q 'credentials' \ - || { echo "FAIL: postgres is not declared behind the credentials profile in docker-compose.yml"; exit 1; } - docs: name: documentation runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3b0e9b5..a6221d0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ examples/ adrs/ adapters/ -# Unseal keys, tokens, snapshots — never commit. +# Unseal keys, tokens, snapshots. Never commit. .bootstrap/ **/.bootstrap/ *.unseal @@ -31,7 +31,7 @@ local/audit/ local/backups/ # Generated policies. -config/policies/rendered/ +local/.rendered-policies/ *.rendered.hcl # Secrets and local env. @@ -59,6 +59,10 @@ override.tf.json *_override.tf *_override.tf.json +# Go build artifacts. +/vault-utils +coverage.out + # Test/editor noise. test-results/ *.tap diff --git a/CHANGELOG.md b/CHANGELOG.md index 00630eb..e23ce31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,133 +1,2 @@ -# Changelog - -All notable changes to the Secrets Vault Cluster. - -Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Fixed - -- `local/bootstrap/compose-unseal.sh` - chown `vault-init.json` to the bind-mount - owner so host `jq` can read it on Linux CI (root-owned mode 600). -- `.github/workflows/validate.yml` - enable the credentials profile with - `COMPOSE_PROFILES` and `--env-file` so postgres is visible in `compose config`. -- `local/vault/config.hcl` — `disable_mlock = true` so Vault starts in Compose - and GitHub Actions (`Failed to lock memory: cannot allocate memory`). -- `.github/workflows/validate.yml` — CE image grep no longer matches the - workflow file itself. - -### Changed - -- Single root `README.md` is the operator and architecture guide. Removed - `vault-cluster-readme.md` and `vault-cluster-technical-doc.md`. -- `.github/workflows` run CI on `master` and `develop` as well as `main`. -- `tests/conformance/credentials/*` — credential suites revoke the leases they - issue as the tenant (provisioning cannot `revoke-prefix`). Residue scan no - longer treats leftover test roles as orphans. -- `local/bootstrap/health.sh` — reports the database engine from the mount, - not only from `.env`. -- `local/setup.sh` — `--with-credentials` persists `ENABLE_DYNAMIC_CREDENTIALS=true` - in `.env` so later health/compose runs stay consistent. -- `local/bootstrap/bootstrap.sh`, `local/setup.sh` — `./setup.sh --with-credentials` - on an isolation Vault now mounts the database engine and refreshes tenant - roles instead of skipping as "already configured". -- Publish only root `README.md` and `CHANGELOG.md`. - ADRs, design docs, runbooks, examples, and nested READMEs stay local via - `.gitignore`. -- Trim essay comments and INV labels from policies, scripts, tests, and CI. - Keep image pins, tenant IDs, and HTTP 403 checks. - -### Added - -- `./setup.sh` one-command local start, plus a Compose `unseal` sidecar: - first run initializes a persistent Shamir Vault; every later start and every - Vault container restart unseals automatically. Local Shamir automation, not - production KMS auto-unseal. `./setup.sh` still configures the platform and - synthetic tenants on a new machine. -- ADR-0009: repository layout uses `local/`, `aws/`, `gcp/`, and `azure/` as - deployment targets. Shared Vault behaviour lives in `config/`, `scripts/`, - and `tests/`. Phase 1 implements only `local/`; cloud directories are - documentation stubs. There is no Terraform `source = "./${var.deployment_target}"` - switch. - -**Platform contract** - -- `docs/PLATFORM_CONTRACT.md`: provider-independent inputs, safe outputs, the - four-identity model, and twelve numbered security invariants, each citing the - check that enforces it. -- ADR-0006: platform contract with runtime adapters, superseding the - cloud-agnostic Terraform contract module in ADR-0005. -- ADR-0007: the provisioning identity has no access to tenant secret data, - narrowing the grant drafted in ADR-0002. -- ADR-0008: capability layering — isolation standalone, dynamic credentials - optional and off by default. - -**Vault configuration** - -- Policy templates for tenant reader, tenant writer, tenant database, - provisioning, and operator. Every tenant template covers both KV v2 path - families and carries explicit denies. -- KV v2, AppRole, file audit device, and PostgreSQL database engine - configuration, all idempotent and driven purely by `VAULT_ADDR`. -- Tenant automation: validation, render, lint, apply, role binding, and - offboarding with lease revocation. -- Policy linter enforcing INV-7, including detection of the KV v2 path-split - mistake, with fixtures it must reject. - -**Conformance suite** - -- Isolation: KV lifecycle, cross-tenant matrix, path traversal and prefix - anchoring, administrative surfaces, identity separation, and audit content. -- Credentials: issuance and connection, least privilege, cross-tenant denial, - revocation and TTL expiry, and residue with a planted-orphan self-test. -- Denials are asserted as HTTP 403 specifically; a 404 is a distinct failure, - because a test that accepts any failure as a denial passes against an empty - Vault. - -**Local Docker target** - -- Compose environment with Vault CE 1.21.4 and PostgreSQL 16.15, pinned by tag - and digest. No dev mode, no `latest`. -- Single-node Raft on a named volume, with audit logs on a **separate** volume, - and the listener published to loopback only. -- Scripts for up, down, bootstrap, health, reset, and Raft snapshot with - checksum. Destructive operations require an explicit flag. -- Runtime tests for persistence across restart, volume separation, port - binding, and digest pinning. - -**Documentation** - -- Implementation plan with acceptance criteria for runtime, isolation, dynamic - credentials, and revocation. -- Runbooks: bootstrap, health check and triage, tenant onboarding, backup and - restore including a restore drill, and break-glass. -- Worked examples for both capability configurations. - -**CI** - -- `validate.yml`: shellcheck, linter self-test, secret scan, Community Edition - guard, and enforcement of the one-way dependency rule. -- `test-local.yml`: isolation with no database present, then credentials as a - separate job. - -### Security - -- Initial root token is revoked at the end of bootstrap. -- Bootstrap material is written under `umask 077` rather than chmod-ed - afterwards, so it never exists world-readable even briefly. -- Audit is enabled first during bootstrap, so platform configuration is itself - recorded. -- `log_raw` stays false; the audit suite asserts both that no fixture value - appears in plaintext and that HMAC markers are present, so "no plaintext - found" cannot be satisfied by an empty log. - -### Known limitations - -- TLS is not enabled; loopback-only binding is the compensating control - (ADR-0004). -- Single-node Raft proves persistence, not high availability. -- The provisioning identity retains an indirect path to tenant data by - authoring a permissive `tenant-*` policy and binding a role to it. The direct - path is closed and the indirect one is audit-visible; separating policy - authorship from role binding is recorded as follow-up work. +# 0.1.0 (Unreleased) +* Initial release diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fefa82 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ cmd/ +COPY internal/ internal/ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /vault-utils ./cmd/vault-utils + +FROM alpine:3.21 +COPY --from=build /vault-utils /usr/local/bin/vault-utils +ENTRYPOINT ["/usr/local/bin/vault-utils"] diff --git a/README.md b/README.md index a685cfc..7514a75 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Multi-platform modules to configure a self-hosted Vault cluster on local, AWS, G Applications share one Vault. They do not see each other's secrets. Isolation is a path prefix plus ACL policy plus AppRole. Vault CE has no namespaces. This is not Enterprise. -**Dev machine:** `./setup.sh`. You do not init or unseal by hand. +**Dev machine:** `cd local && docker compose up -d --build`. You do not init or unseal by hand. | Target | Role | Status | |---|---|---| @@ -36,13 +36,13 @@ There is no `module "vault_cluster" { source = "./${var.cloud}" }` switch. Share Implemented: -- Vault CE 1.21.4 (never `-dev`, never Enterprise) +- Vault CE 2.0 (never `-dev`, never Enterprise) - Docker Compose, Raft on a named volume - KV v2 at `kv/customers/{tenant}/*` -- Optional PostgreSQL dynamic credentials +- Dynamic PostgreSQL credentials in the Go library only (`TestCredentialsMatrix`); the local stack runs no PostgreSQL and never mounts `database/` - File audit on a volume separate from Raft -- Auto-init (first start) and auto-unseal (Compose sidecar on every reseal) -- Isolation and credentials conformance tests +- Auto-init (first start) and one-shot Shamir unseal via `vault-utils` +- Isolation tests in Go (`go test`); credentials tests in Go (`TestCredentialsMatrix`) Not implemented: @@ -65,16 +65,12 @@ Docker Compose | KV v2 | ACL policies | AppRole - | Database engine (optional) | Audit - | unseal sidecar (local Shamir only) | - +-- PostgreSQL (Compose profile `credentials` only) + +-- vault-utils bootstrap (one-shot: init, unseal, configure) ``` -`config/`, `scripts/`, and `tests/` use HTTP to Vault. They must not name Docker, clouds, or host paths. `local/` owns Compose, volumes, and where unseal keys live. - -Isolation runs Vault alone. PostgreSQL starts only with `--with-credentials`. +Isolation and credentials tests are Go (`go test ./internal/vaultcluster`). Both run throwaway containers; the credentials suite brings its own PostgreSQL, so the local stack never runs one. ## Repository layout @@ -82,33 +78,31 @@ Isolation runs Vault alone. PostgreSQL starts only with `--with-credentials`. vault-cluster/ ├── README.md ├── CHANGELOG.md -├── setup.sh -├── config/ policies, AppRole, KV, database engine, audit -├── scripts/ tenant onboard/offboard, tenant-id validation -├── tests/ isolation and credentials conformance, policy linter -├── local/ Compose, unseal sidecar, snapshots, runtime tests -├── aws/ -├── gcp/ -└── azure/ +├── Dockerfile vault-utils image +├── cmd/ Go app entrypoints (vault-utils CLI) +├── internal/ Go libraries, policy templates, lint fixtures +├── local/ Compose target, snapshots +├── aws/ Nullstone Terraform module (not yet implemented) +├── gcp/ Nullstone Terraform module (not yet implemented) +└── azure/ Nullstone Terraform module (not yet implemented) ``` ## Prerequisites -Docker Desktop (Compose v2), `curl`, and `jq`. Vault CLI is optional except break-glass decode. +Docker Desktop (Compose v2). Go 1.23 for `go test`. `curl` and `jq` for the manual examples below; Vault CLI is optional except break-glass decode. -Images are pinned by tag and digest in `local/.env.example` (Vault 1.21.4, PostgreSQL 16.15-alpine). Never `latest`. +Images are pinned by tag and digest in `local/compose.yml` (Vault 2.0, PostgreSQL 18-alpine). Never `latest`. ```bash docker --version docker compose version -jq --version -curl --version ``` ## Quick start ```bash -./setup.sh +cd local +docker compose up -d ``` First run: @@ -119,15 +113,10 @@ First run: 4. Unseals 5. Enables audit, KV v2, AppRole, policies 6. Revokes the root token -7. Onboards synthetic `tenant-a` and `tenant-b` - -Later runs skip init. The Compose sidecar unseals if Vault resealed. -Dynamic credentials (starts PostgreSQL): +Bootstrap only initializes the cluster. Tenants are created explicitly with `tenants create`. -```bash -./setup.sh --with-credentials -``` +Later runs skip init if the provisioning token still works. A Vault process restart reseals. Unseal is a one-shot (`docker compose run --rm bootstrap`), not a long-running sidecar. `docker compose up -d` starts Vault and runs bootstrap once, then bootstrap exits. Then: @@ -142,24 +131,21 @@ Do not commit `.bootstrap/` or `.env`. Do not run `vault operator unseal`. ## Commands -Run from the repository root unless noted. Destructive commands require `--yes`. +Run from `local/` unless noted. Destructive commands require `--yes`. | Command | Destructive | Purpose | |---|---|---| -| `./setup.sh` | no | Start, init (first time), unseal, configure, synthetic tenants | -| `./setup.sh --with-credentials` | no | Same, plus PostgreSQL and the database engine | -| `./local/stop.sh` | no | Stop containers. Keeps all data. | -| `./local/bootstrap/up.sh` | no | Start Compose only (Vault + unseal sidecar) | -| `./local/bootstrap/health.sh` | no | Health with a reason for each failure | -| `./local/bootstrap/snapshot.sh take` | no | Raft snapshot plus SHA-256 | -| `./local/bootstrap/snapshot.sh restore --yes` | yes | Replaces all Vault state | -| `./local/reset.sh --yes` | yes | Destroys volumes and unseal keys | -| `./scripts/tenants/create-tenant.sh ` | no | Onboard a tenant | -| `./scripts/tenants/offboard-tenant.sh --yes` | yes (access) | Revoke access; secrets kept | -| `./scripts/tenants/offboard-tenant.sh --yes --purge-secrets` | yes | Also destroy secret versions | -| `./tests/run-conformance.sh --layer isolation` | no | Isolation tests | -| `./tests/run-conformance.sh --layer all` | no | Isolation plus credentials | -| `./local/tests/runtime-test.sh` | no | Persistence and unseal sidecar tests | +| `docker compose up -d --build` | no | Start, init (first time), unseal, configure | +| `docker compose down` | no | Stop containers. Keeps all data. | +| `docker compose down --volumes --remove-orphans && rm -rf .bootstrap` | yes | Destroys volumes and unseal keys | +| `docker compose run --rm -e VAULT_TOKEN=... bootstrap tenants create ` | no | Onboard a tenant | +| `docker compose run --rm -e VAULT_TOKEN=... bootstrap tenants destroy --yes` | yes (access) | Revoke access; secrets kept | +| `docker compose run --rm -e VAULT_TOKEN=... bootstrap tenants destroy --yes --purge-secrets` | yes | Also destroy secret versions | +| `docker compose run --rm bootstrap snapshot take` | no | Raft snapshot plus SHA-256 | +| `docker compose run --rm bootstrap snapshot restore --yes` | yes | Replaces all Vault state | +| `go test -short ./...` | no | Unit tests (tenant ID, policy lint, render, compose lint) — repo root | +| `go test ./internal/vaultcluster` | no | Isolation and credentials (needs Docker) — repo root | +| `go test ./local` | no | Compose runtime conformance (needs Docker) — repo root | ## Tenant isolation @@ -175,11 +161,10 @@ kv/metadata/customers/{tenant_id}/* Tenant IDs: `^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$` (3-32 characters). Rejected: `/`, `..`, `*`, `sys`, `data`, `root`, and similar reserved names. ```bash -./scripts/validation/validate-tenant-id.sh acme-corp - export VAULT_ADDR=http://127.0.0.1:8200 export VAULT_TOKEN=$(cat local/.bootstrap/provisioning.token) -./scripts/tenants/create-tenant.sh acme-corp +cd local +docker compose run --rm -e VAULT_TOKEN bootstrap tenants create acme-corp ``` `role_id` and `secret_id` print once and are not stored. Re-issue a secret_id if lost. @@ -195,13 +180,15 @@ curl -s -H "X-Vault-Token: ${TENANT_TOKEN}" \ Offboard (revoke access, keep secrets): ```bash -./scripts/tenants/offboard-tenant.sh acme-corp --yes +cd local +docker compose run --rm -e VAULT_TOKEN bootstrap tenants destroy acme-corp --yes ``` Offboard and destroy data (needs break-glass; provisioning cannot read or purge KV): ```bash -./scripts/tenants/offboard-tenant.sh acme-corp --yes --purge-secrets +cd local +docker compose run --rm -e VAULT_TOKEN bootstrap tenants destroy acme-corp --yes --purge-secrets ``` Cross-tenant, wildcard, and traversal reads return HTTP 403. That is the isolation contract. A 200 on those paths is a breach. @@ -218,8 +205,6 @@ Cross-tenant, wildcard, and traversal reads return HTTP 403. That is the isolati ## Health ```bash -./local/bootstrap/health.sh - curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8200/v1/sys/health ``` @@ -229,17 +214,16 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8200/v1/sys/health | 501 | Uninitialized | | 503 | Sealed | -After a Vault process restart, expect 503 for a few seconds. The unseal sidecar should return health to 200. If it stays sealed: +After a Vault process restart, expect 503 (sealed). Re-run the one-shot: ```bash -docker logs vault-cluster-unseal -./setup.sh +cd local && docker compose run --rm bootstrap ``` Vault fails closed when audit cannot write. If every request is denied: ```bash -docker exec vault-cluster-vault sh -c 'ls -la /vault/logs && df -h /vault/logs' +cd local && docker compose exec vault sh -c 'ls -la /vault/logs && df -h /vault/logs' ``` ## Backup, restore, and disaster recovery @@ -248,52 +232,55 @@ A snapshot is the whole cluster (secrets, policies, tokens). Treat it like Vault Keep the matching `vault-init.json` with each snapshot. After restore, Vault unseals only with the shares that were current when the snapshot was taken. +Snapshots are written to `local/.bootstrap/backups/` on the host, which the bootstrap container sees as `/bootstrap/backups/`. + ### Backup ```bash cd local -./bootstrap/snapshot.sh take -./bootstrap/snapshot.sh list -./bootstrap/snapshot.sh verify .bootstrap/backups/vault-.snap +docker compose run --rm bootstrap snapshot take +docker compose run --rm bootstrap snapshot list +docker compose run --rm bootstrap snapshot verify /bootstrap/backups/vault-.snap ``` ### Restore (destructive) +Restore needs a token with `sys/storage/raft/snapshot-force`. The operator token cannot restore; generate a break-glass root first (see [Break-glass](#break-glass)). + ```bash cd local -./bootstrap/snapshot.sh restore .bootstrap/backups/vault-.snap --yes -./setup.sh +docker compose run --rm -e VAULT_TOKEN= bootstrap snapshot restore /bootstrap/backups/vault-.snap --yes +docker compose run --rm bootstrap ``` ### Restore drill ```bash cd local -./setup.sh +docker compose up -d --build export VAULT_ADDR=http://127.0.0.1:8200 export VAULT_TOKEN=$(cat .bootstrap/provisioning.token) -../scripts/tenants/create-tenant.sh tenant-a +docker compose run --rm -e VAULT_TOKEN bootstrap tenants create tenant-a -./bootstrap/snapshot.sh take +docker compose run --rm bootstrap snapshot take cp .bootstrap/vault-init.json /tmp/keys-at-snapshot.json -../scripts/tenants/create-tenant.sh tenant-drill -./reset.sh --yes +docker compose run --rm -e VAULT_TOKEN bootstrap tenants create tenant-drill +docker compose down --volumes --remove-orphans && rm -rf .bootstrap -./bootstrap/up.sh +docker compose up -d cp /tmp/keys-at-snapshot.json .bootstrap/vault-init.json -./bootstrap/snapshot.sh restore .bootstrap/backups/vault-.snap --yes -./setup.sh -../tests/run-conformance.sh --layer isolation +# generate a break-glass root (see Break-glass), then: +docker compose run --rm -e VAULT_TOKEN= bootstrap snapshot restore /bootstrap/backups/vault-.snap --yes +docker compose run --rm bootstrap +go test ./internal/vaultcluster -run TestIsolationMatrix ``` Expected: `tenant-a` exists, `tenant-drill` does not, isolation suite passes. ### If unseal keys are lost -The Raft volume cannot be unsealed. Data is gone. That is Shamir working. Restore from a snapshot that still has its matching keys, or run `./local/reset.sh --yes` and start empty. - -PostgreSQL is not in the Vault snapshot. Dynamic credentials are re-issued. The fixture schema is recreated by `local/postgres/init.sh` on a fresh volume. +The Raft volume cannot be unsealed. Data is gone. That is Shamir working. Restore from a snapshot that still has its matching keys, or destroy the volumes (`docker compose down --volumes --remove-orphans && rm -rf .bootstrap` in `local/`) and start empty. ## Break-glass @@ -320,21 +307,15 @@ curl -s -X DELETE "${VAULT_ADDR}/v1/sys/generate-root/attempt" ## Testing ```bash -./tests/lint/lint-self-test.sh +go test -short ./... -export VAULT_ADDR=http://127.0.0.1:8200 -export VAULT_TOKEN=$(cat local/.bootstrap/provisioning.token) - -./tests/run-conformance.sh --layer isolation +go test ./internal/vaultcluster -ENABLE_DYNAMIC_CREDENTIALS=true \ - PSQL_CMD='docker compose -f local/docker-compose.yml --env-file local/.env exec -T postgres psql' \ - AUDIT_READ_CMD='docker compose -f local/docker-compose.yml --env-file local/.env exec -T vault cat /vault/logs/audit.log' \ - ./tests/run-conformance.sh --layer all - -cd local && ./tests/runtime-test.sh +go test ./local ``` +In `local/`, `TestLocalComposeStatic` lints `compose.yml` (digest pins, no dev mode, loopback-only ports). `TestLocalComposeRuntime` brings up an isolated copy of the Compose stack and verifies the one-shot bootstrap, Shamir over Raft, audit/Raft volume separation, and persistence across a restart. It never touches your dev stack or `local/.bootstrap/`. + Denials must be HTTP 403. A 404 is a different failure. ## Troubleshooting @@ -343,11 +324,10 @@ Denials must be HTTP 403. A 404 is a different failure. |---|---| | Docker daemon is not running | Start Docker Desktop | | Port already allocated | Change `VAULT_HOST_PORT` in `local/.env` | -| Initialized but `.bootstrap` missing | Restore `vault-init.json`, or `./local/reset.sh --yes` | -| Health 503 | Wait for the unseal sidecar, then `./setup.sh` | +| Initialized but `.bootstrap` missing | Restore `vault-init.json`, or destroy volumes and start empty | +| Health 503 | Vault is sealed. Run `docker compose run --rm bootstrap` in `local/` | | Permission denied on tenant create | Use the provisioning token, not operator | | Permission denied on tenant secrets | Expected for provisioning | -| `failed to verify connection` | Start with `--with-credentials` | | Everything denied | Audit volume full or unwritable | ## Security @@ -360,4 +340,4 @@ Denials must be HTTP 403. A 404 is a different failure. - Provisioning cannot read tenant KV - Operator cannot read tenant KV - Local Compose sets `disable_mlock = true` because Docker and GitHub Actions cannot mlock. Production hosts should use `IPC_LOCK` -- Dynamic credentials: bounded TTL, revoke drops the Postgres role, residue scan expects zero leftover `v-*` roles +- Dynamic credentials (Go library and tests only): bounded TTL, revoke drops the Postgres role, residue scan expects zero leftover `v-*` roles diff --git a/cmd/vault-utils/main.go b/cmd/vault-utils/main.go new file mode 100644 index 0000000..0b7620e --- /dev/null +++ b/cmd/vault-utils/main.go @@ -0,0 +1,204 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/nullstone-modules/vault-cluster/internal/vaultcluster" +) + +func main() { + log.SetFlags(0) + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + if err := run(os.Args[1], os.Args[2:]); err != nil { + log.Fatalf("error: %v", err) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `vault-utils + +Commands: + bootstrap local|aws|azure|gcp Initialize a cluster: init (once), unseal, configure + tenants create + tenants destroy --yes [--purge-secrets] + snapshot take|list|verify |restore --yes + health + +Key material for bootstrap local is stored under BOOTSTRAP_DIR (default .bootstrap). +`) +} + +func run(cmd string, args []string) error { + cfg := vaultcluster.ConfigFromEnv() + c, err := vaultcluster.New(cfg) + if err != nil { + return err + } + switch cmd { + case "bootstrap": + return runBootstrap(c, args) + case "tenants": + return runTenants(c, args) + case "snapshot": + return runSnapshot(c, args) + case "health": + return c.Health() + default: + usage() + return fmt.Errorf("unknown command %q", cmd) + } +} + +func runBootstrap(c *vaultcluster.Client, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: vault-utils bootstrap local|aws|azure|gcp") + } + switch args[0] { + case "local": + shares, _ := strconv.Atoi(getenv("VAULT_INIT_KEY_SHARES", "5")) + threshold, _ := strconv.Atoi(getenv("VAULT_INIT_KEY_THRESHOLD", "3")) + return c.RunBootstrap(keyStore(), vaultcluster.BootstrapOptions{ + Shares: shares, + Threshold: threshold, + KeepRoot: getenv("KEEP_ROOT", "false") == "true", + }) + case "aws", "azure", "gcp": + return fmt.Errorf("bootstrap %s is not implemented yet", args[0]) + default: + return fmt.Errorf("unknown platform %q (local, aws, azure, gcp)", args[0]) + } +} + +func runTenants(c *vaultcluster.Client, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: vault-utils tenants create|destroy ") + } + sub, rest := args[0], args[1:] + switch sub { + case "create": + id := "" + for _, a := range rest { + if !strings.HasPrefix(a, "-") { + id = a + } + } + if id == "" { + return fmt.Errorf("usage: vault-utils tenants create ") + } + return c.CreateTenant(id, true) + case "destroy": + yes, purge := false, false + id := "" + for _, a := range rest { + switch a { + case "--yes": + yes = true + case "--purge-secrets": + purge = true + default: + if !strings.HasPrefix(a, "-") { + id = a + } + } + } + if id == "" || !yes { + return fmt.Errorf("usage: vault-utils tenants destroy --yes [--purge-secrets]") + } + return c.OffboardTenant(id, purge) + default: + return fmt.Errorf("unknown subcommand %q (create, destroy)", sub) + } +} + +func runSnapshot(c *vaultcluster.Client, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: vault-utils snapshot take|list|verify |restore --yes") + } + backupDir := filepath.Join(bootstrapDir(), "backups") + switch args[0] { + case "take": + if err := useOperatorToken(c); err != nil { + return err + } + file, err := c.SnapshotTake(backupDir) + if err != nil { + return err + } + log.Printf("snapshot written: %s", file) + log.Printf("this file contains every secret in the cluster; treat it as one") + return nil + case "list": + files, err := vaultcluster.SnapshotList(backupDir) + if err != nil { + return err + } + if len(files) == 0 { + log.Printf("no snapshots under %s", backupDir) + return nil + } + for _, f := range files { + fmt.Println(f) + } + return nil + case "verify": + if len(args) < 2 { + return fmt.Errorf("usage: vault-utils snapshot verify ") + } + if err := vaultcluster.SnapshotVerify(args[1]); err != nil { + return err + } + log.Printf("checksum OK: %s", args[1]) + return nil + case "restore": + if len(args) < 3 || args[2] != "--yes" { + return fmt.Errorf("restore replaces the entire cluster; re-run with: vault-utils snapshot restore --yes") + } + if c.Cfg.Token == "" { + return fmt.Errorf("restore requires VAULT_TOKEN with sys/storage/raft/snapshot-force (break-glass root); the operator token cannot restore") + } + if err := c.SnapshotRestore(args[1]); err != nil { + return err + } + log.Printf("restore submitted; Vault will seal") + log.Printf("unseal with the key shares that were current when this snapshot was taken") + return nil + default: + return fmt.Errorf("unknown subcommand %q (take, list, verify, restore)", args[0]) + } +} + +func useOperatorToken(c *vaultcluster.Client) error { + if c.Cfg.Token != "" { + return nil + } + tok, err := keyStore().LoadToken("operator") + if err != nil { + return fmt.Errorf("set VAULT_TOKEN or bootstrap first (operator token not found): %w", err) + } + c.API.SetToken(tok) + c.Cfg.Token = tok + return nil +} + +func keyStore() vaultcluster.FileKeyStore { + return vaultcluster.FileKeyStore{Dir: bootstrapDir()} +} + +func bootstrapDir() string { + return getenv("BOOTSTRAP_DIR", ".bootstrap") +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/config/audit/file-device.sh b/config/audit/file-device.sh deleted file mode 100755 index a624538..0000000 --- a/config/audit/file-device.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# Enable file audit. log_raw stays false. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../scripts/lib/common.sh -. "${SCRIPT_DIR}/../../scripts/lib/common.sh" -# shellcheck source=../../scripts/lib/vault.sh -. "${SCRIPT_DIR}/../../scripts/lib/vault.sh" - -platform_defaults -vault_require_env - -: "${AUDIT_LOG_PATH:?AUDIT_LOG_PATH must be supplied by the deployment target}" -AUDIT_DEVICE="${AUDIT_DEVICE_NAME:-file}" - -if [ "${ENABLE_AUDIT}" != "true" ]; then - # Vault refuses every request when no audit device can write. Running without - # one is therefore not "less logging", it is an unmonitored secrets store. - warn "ENABLE_AUDIT is false - skipping. Do not do this outside a throwaway environment." - exit 0 -fi - -if vault_audit_device_exists "${AUDIT_DEVICE}"; then - info "audit device '${AUDIT_DEVICE}' already enabled" - exit 0 -fi - -info "enabling file audit device at ${AUDIT_LOG_PATH}" - -# log_raw=false: HMAC values, never raw secrets. -vault_must PUT "sys/audit/${AUDIT_DEVICE}" "$(jq -nc \ - --arg path "${AUDIT_LOG_PATH}" \ - '{ - type: "file", - description: "Platform audit device", - options: { - file_path: $path, - log_raw: "false", - hmac_accessor: "true", - mode: "0600", - format: "json" - } - }')" - -info "audit device enabled" diff --git a/config/auth/approle.sh b/config/auth/approle.sh deleted file mode 100755 index 192b4f9..0000000 --- a/config/auth/approle.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Enable AppRole. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../scripts/lib/common.sh -. "${SCRIPT_DIR}/../../scripts/lib/common.sh" -# shellcheck source=../../scripts/lib/vault.sh -. "${SCRIPT_DIR}/../../scripts/lib/vault.sh" - -platform_defaults -vault_require_env - -if vault_auth_exists "${AUTH_MOUNT}"; then - info "auth method '${AUTH_MOUNT}/' already enabled" -else - info "enabling AppRole auth at '${AUTH_MOUNT}/'" - vault_must POST "sys/auth/${AUTH_MOUNT}" "$(jq -nc \ - '{ - type: "approle", - description: "Tenant workload identities" - }')" -fi - -# Mount-level TTL ceiling. Per-role TTLs may be shorter but cannot exceed this, -# so a mistake in one tenant role cannot mint a longer-lived token than the -# platform contract allows. -info "tuning token TTLs (default ${DEFAULT_TOKEN_TTL}, max ${MAX_TOKEN_TTL})" -vault_must POST "sys/auth/${AUTH_MOUNT}/tune" "$(jq -nc \ - --arg d "${DEFAULT_TOKEN_TTL}" \ - --arg m "${MAX_TOKEN_TTL}" \ - '{ default_lease_ttl: $d, max_lease_ttl: $m }')" - -info "AppRole ready at '${AUTH_MOUNT}/'" diff --git a/config/secret-engines/database.sh b/config/secret-engines/database.sh deleted file mode 100755 index 1856e71..0000000 --- a/config/secret-engines/database.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# Mount the database secrets engine when ENABLE_DYNAMIC_CREDENTIALS=true. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../scripts/lib/common.sh -. "${SCRIPT_DIR}/../../scripts/lib/common.sh" -# shellcheck source=../../scripts/lib/vault.sh -. "${SCRIPT_DIR}/../../scripts/lib/vault.sh" - -platform_defaults - -if ! credentials_enabled; then - info "dynamic credentials disabled - skipping database engine (isolation only)" - exit 0 -fi - -vault_require_env - -: "${DATABASE_CONNECTION_URL:?DATABASE_CONNECTION_URL must be supplied by the deployment target}" -: "${DATABASE_USERNAME:?DATABASE_USERNAME must be supplied by the deployment target}" -: "${DATABASE_PASSWORD:?DATABASE_PASSWORD must be supplied by the deployment target}" - -DATABASE_CONNECTION_NAME="${DATABASE_CONNECTION_NAME:-app}" - -if vault_mount_exists "${DATABASE_MOUNT}"; then - info "database mount '${DATABASE_MOUNT}/' already exists" -else - info "mounting database engine at '${DATABASE_MOUNT}/'" - vault_must POST "sys/mounts/${DATABASE_MOUNT}" "$(jq -nc \ - --arg d "${DATABASE_DEFAULT_TTL}" \ - --arg m "${DATABASE_MAX_TTL}" \ - '{ - type: "database", - description: "Dynamic database credentials", - config: { default_lease_ttl: $d, max_lease_ttl: $m } - }')" -fi - -info "configuring connection '${DATABASE_CONNECTION_NAME}'" - -# allowed_roles is the engine-level boundary. Restricting it to tenant-* means -# a role created outside the naming convention cannot use this connection at -# all - so the convention that makes per-tenant policy scoping possible is -# enforced by Vault rather than by reviewer discipline. -# -# max_open_connections caps Vault's share of PostgreSQL's connection budget. -# Left unbounded, credential issuance under load can exhaust max_connections -# and take down the application that is merely a neighbour here. -vault_must POST "${DATABASE_MOUNT}/config/${DATABASE_CONNECTION_NAME}" "$(jq -nc \ - --arg url "${DATABASE_CONNECTION_URL}" \ - --arg user "${DATABASE_USERNAME}" \ - --arg pass "${DATABASE_PASSWORD}" \ - --argjson maxopen "${DATABASE_MAX_OPEN_CONNECTIONS:-8}" \ - '{ - plugin_name: "postgresql-database-plugin", - connection_url: $url, - username: $user, - password: $pass, - allowed_roles: ["tenant-*"], - max_open_connections: $maxopen, - max_idle_connections: 2, - max_connection_lifetime: "5m", - verify_connection: true, - password_authentication: "password" - }')" - -# The write above verifies the connection, so reaching this point means Vault -# authenticated to PostgreSQL successfully. Reported explicitly because the -# alternative - discovering it at first credential request - attributes a -# configuration error to whichever tenant happened to ask first. -info "database engine ready at '${DATABASE_MOUNT}/' (connection verified)" diff --git a/config/secret-engines/kv.sh b/config/secret-engines/kv.sh deleted file mode 100755 index 7d480cf..0000000 --- a/config/secret-engines/kv.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Mount KV v2. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../scripts/lib/common.sh -. "${SCRIPT_DIR}/../../scripts/lib/common.sh" -# shellcheck source=../../scripts/lib/vault.sh -. "${SCRIPT_DIR}/../../scripts/lib/vault.sh" - -platform_defaults -vault_require_env - -if vault_mount_exists "${KV_MOUNT}"; then - info "KV mount '${KV_MOUNT}/' already exists" -else - info "mounting KV v2 at '${KV_MOUNT}/'" - vault_must POST "sys/mounts/${KV_MOUNT}" "$(jq -nc \ - '{ - type: "kv", - description: "Multi-tenant secret store", - options: { version: "2" } - }')" -fi - -# Version 2 is the whole basis of the metadata/data path split the policies -# depend on. A KV v1 mount here would make every tenant policy match nothing, so -# verify rather than assume, since a pre-existing mount was not necessarily -# created by this script. -vault_must GET "sys/mounts/${KV_MOUNT}/tune" -if ! jq -e '.data.options.version == "2"' >/dev/null 2>&1 <<<"$(vault_body)"; then - die "'${KV_MOUNT}/' is not KV v2 (sys/mounts/${KV_MOUNT}/tune options.version != 2). - Every tenant policy in this platform targets ${KV_MOUNT}/data/... and - ${KV_MOUNT}/metadata/..., which do not exist on v1, so they would apply - cleanly and grant nothing." -fi - -info "configuring KV v2 defaults" - -# max_versions bounds unbounded version growth; a rotated-hourly secret would -# otherwise accumulate forever. 10 keeps enough history for `kv rollback` to be -# a usable mitigation when a rotation breaks a consumer. -vault_must POST "${KV_MOUNT}/config" "$(jq -nc \ - --argjson max "${KV_MAX_VERSIONS:-10}" \ - '{ - max_versions: $max, - cas_required: false, - delete_version_after: "0s" - }')" - -info "KV v2 ready at '${KV_MOUNT}/' (max_versions=${KV_MAX_VERSIONS:-10})" diff --git a/config/tenants/.gitkeep b/config/tenants/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..78c2fe4 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/nullstone-modules/vault-cluster + +go 1.23.0 + +require github.com/hashicorp/vault/api v1.16.0 + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c87563 --- /dev/null +++ b/go.sum @@ -0,0 +1,79 @@ +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= +github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/vaultcluster/bootstrap.go b/internal/vaultcluster/bootstrap.go new file mode 100644 index 0000000..bbccef7 --- /dev/null +++ b/internal/vaultcluster/bootstrap.go @@ -0,0 +1,186 @@ +package vaultcluster + +import ( + "fmt" + "log" + "time" + + "github.com/hashicorp/vault/api" +) + +type BootstrapOptions struct { + Shares int + Threshold int + KeepRoot bool +} + +const revokedRootMarker = "revoked-at-bootstrap" + +func (c *Client) RunBootstrap(store KeyStore, opts BootstrapOptions) error { + if err := c.WaitReady(60 * time.Second); err != nil { + return err + } + + st, err := c.API.Sys().SealStatus() + if err != nil { + return err + } + if !st.Initialized { + log.Printf("initializing Vault (%d/%d Shamir)", opts.Shares, opts.Threshold) + resp, err := c.API.Sys().Init(&api.InitRequest{ + SecretShares: opts.Shares, + SecretThreshold: opts.Threshold, + }) + if err != nil { + return err + } + if err := store.SaveInit(resp); err != nil { + return err + } + log.Printf("initialized; key material saved (not logged)") + } else if _, err := store.LoadInit(); err != nil { + return fmt.Errorf("Vault is initialized but key material is missing: %w", err) + } + + if err := c.unseal(store, opts.Threshold); err != nil { + return err + } + + if tok, err := store.LoadToken("provisioning"); err == nil { + c.API.SetToken(tok) + if _, err := c.API.Auth().Token().LookupSelf(); err == nil { + c.Cfg.Token = tok + if err := c.enableCredentialsIfNeeded(store); err != nil { + return err + } + log.Printf("platform already bootstrapped") + return nil + } + } + + initResp, err := store.LoadInit() + if err != nil { + return err + } + root := initResp.RootToken + if root == "" || root == revokedRootMarker { + return fmt.Errorf("root token is missing or already revoked; restore key material or bootstrap with keep-root") + } + c.API.SetToken(root) + c.Cfg.Token = root + + if err := c.Configure(); err != nil { + return err + } + + for _, name := range []string{"provisioning", "operator"} { + tok, err := c.issueOrphanToken(name) + if err != nil { + return err + } + if err := store.SaveToken(name, tok); err != nil { + return err + } + } + + if opts.KeepRoot { + log.Printf("keeping the root token active") + return nil + } + if err := c.API.Auth().Token().RevokeSelf(""); err != nil { + log.Printf("root token revocation failed: %v", err) + } else { + initResp.RootToken = revokedRootMarker + _ = store.SaveInit(initResp) + log.Printf("revoked the initial root token") + } + return nil +} + +func (c *Client) WaitReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + st, err := c.API.Sys().SealStatus() + if err == nil && st != nil { + return nil + } + time.Sleep(time.Second) + } + return fmt.Errorf("Vault did not respond within %s", timeout) +} + +func (c *Client) unseal(store KeyStore, threshold int) error { + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + st, err := c.API.Sys().SealStatus() + if err != nil { + time.Sleep(time.Second) + continue + } + if !st.Sealed { + return nil + } + initResp, err := store.LoadInit() + if err != nil { + return err + } + keys := initResp.KeysB64 + if len(keys) == 0 { + keys = initResp.Keys + } + n := threshold + if n > len(keys) { + n = len(keys) + } + for i := 0; i < n; i++ { + if _, err := c.API.Sys().Unseal(keys[i]); err != nil { + log.Printf("unseal share %d: %v", i, err) + } + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("Vault remained sealed") +} + +func (c *Client) enableCredentialsIfNeeded(store KeyStore) error { + if !c.Cfg.EnableCredentials || c.databaseMounted() { + return nil + } + tok, err := store.LoadToken("operator") + if err != nil { + return fmt.Errorf("database engine is not mounted and the operator token is missing") + } + if err := c.WithToken(tok).mountDatabase(); err != nil { + return fmt.Errorf("enable database engine: %w", err) + } + log.Printf("enabled database engine") + return nil +} + +func (c *Client) databaseMounted() bool { + mounts, err := c.API.Sys().ListMounts() + if err != nil { + return false + } + _, ok := mounts[c.Cfg.DatabaseMount+"/"] + return ok +} + +func (c *Client) issueOrphanToken(policy string) (string, error) { + sec, err := c.API.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{policy}, + Period: "24h", + Renewable: boolPtr(true), + DisplayName: policy, + NoParent: true, + }) + if err != nil { + return "", err + } + if sec == nil || sec.Auth == nil { + return "", fmt.Errorf("failed to issue %s token", policy) + } + return sec.Auth.ClientToken, nil +} + +func boolPtr(b bool) *bool { return &b } diff --git a/internal/vaultcluster/client.go b/internal/vaultcluster/client.go new file mode 100644 index 0000000..13ad194 --- /dev/null +++ b/internal/vaultcluster/client.go @@ -0,0 +1,95 @@ +package vaultcluster + +import ( + "fmt" + "io" + "net/url" + "strings" + + "github.com/hashicorp/vault/api" +) + +type Client struct { + API *api.Client + Cfg Config +} + +func New(cfg Config) (*Client, error) { + if cfg.Addr == "" { + return nil, fmt.Errorf("VAULT_ADDR is not set") + } + ac := api.DefaultConfig() + ac.Address = cfg.Addr + if cfg.HTTPTimeout > 0 { + ac.Timeout = cfg.HTTPTimeout + } + vc, err := api.NewClient(ac) + if err != nil { + return nil, err + } + if cfg.Token != "" { + vc.SetToken(cfg.Token) + } + return &Client{API: vc, Cfg: cfg}, nil +} + +func (c *Client) WithToken(token string) *Client { + clone, err := c.API.Clone() + if err != nil { + cp := *c + return &cp + } + clone.SetToken(token) + cp := *c + cp.API = clone + cp.Cfg.Token = token + return &cp +} + +type HTTPResult struct { + Status int + Body []byte +} + +func (c *Client) Do(method, path string, body any) (HTTPResult, error) { + path = strings.TrimPrefix(path, "/") + query := "" + if i := strings.Index(path, "?"); i >= 0 { + query = path[i+1:] + path = path[:i] + } + req := c.API.NewRequest(method, "/v1/"+path) + if query != "" { + vals, err := url.ParseQuery(query) + if err != nil { + return HTTPResult{}, err + } + req.Params = vals + } + if body != nil { + if err := req.SetJSONBody(body); err != nil { + return HTTPResult{}, err + } + } + resp, err := c.API.RawRequest(req) + if err != nil && resp == nil { + return HTTPResult{Status: 0}, err + } + if resp == nil { + return HTTPResult{}, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return HTTPResult{Status: resp.StatusCode, Body: b}, nil +} + +func (c *Client) Must(method, path string, body any) (HTTPResult, error) { + r, err := c.Do(method, path, body) + if err != nil { + return r, err + } + if r.Status < 200 || r.Status >= 300 { + return r, fmt.Errorf("%s %s failed (HTTP %d): %s", method, path, r.Status, strings.TrimSpace(string(r.Body))) + } + return r, nil +} diff --git a/internal/vaultcluster/config.go b/internal/vaultcluster/config.go new file mode 100644 index 0000000..530115b --- /dev/null +++ b/internal/vaultcluster/config.go @@ -0,0 +1,85 @@ +package vaultcluster + +import ( + "fmt" + "os" + "strings" + "time" +) + +type Config struct { + Addr string + Token string + KVMount string + TenantPrefix string + AuthMount string + DatabaseMount string + AuditPath string + AuditDevice string + EnableAudit bool + EnableCredentials bool + DatabaseURL string + DatabaseUsername string + DatabasePassword string + DatabaseConnName string + DatabaseTTL string + DatabaseMaxTTL string + TokenTTL string + TokenMaxTTL string + HTTPTimeout time.Duration +} + +func ConfigFromEnv() Config { + c := Config{ + Addr: getenv("VAULT_ADDR", ""), + Token: getenv("VAULT_TOKEN", ""), + KVMount: getenv("KV_MOUNT", "kv"), + TenantPrefix: getenv("TENANT_PREFIX", "customers"), + AuthMount: getenv("AUTH_MOUNT", "approle"), + DatabaseMount: getenv("DATABASE_MOUNT", "database"), + AuditPath: getenv("AUDIT_LOG_PATH", "/vault/logs/audit.log"), + AuditDevice: getenv("AUDIT_DEVICE_NAME", "file"), + EnableAudit: getenv("ENABLE_AUDIT", "true") == "true", + // The dynamic-credentials fields (EnableCredentials, DatabaseURL, + // DatabaseUsername, ...) are never set from the environment: the + // database engine is configured only by callers that opt in + // programmatically. DatabaseMount stays because the tenant and + // platform policies reference its paths even when the engine is + // not mounted. + TokenTTL: getenv("DEFAULT_TOKEN_TTL", "1h"), + TokenMaxTTL: getenv("MAX_TOKEN_TTL", "24h"), + HTTPTimeout: 15 * time.Second, + } + return c +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func (c Config) TenantPolicy(kind, tenantID string) string { + return fmt.Sprintf("tenant-%s-%s", tenantID, kind) +} + +func (c Config) TenantRole(kind, tenantID string) string { + return fmt.Sprintf("tenant-%s-%s", tenantID, kind) +} + +func (c Config) KVDataPath(tenantID, secret string) string { + p := fmt.Sprintf("%s/data/%s/%s", c.KVMount, c.TenantPrefix, tenantID) + if secret != "" { + p += "/" + strings.TrimPrefix(secret, "/") + } + return p +} + +func (c Config) KVMetaPath(tenantID, secret string) string { + p := fmt.Sprintf("%s/metadata/%s/%s", c.KVMount, c.TenantPrefix, tenantID) + if secret != "" { + p += "/" + strings.TrimPrefix(secret, "/") + } + return p +} diff --git a/internal/vaultcluster/configure.go b/internal/vaultcluster/configure.go new file mode 100644 index 0000000..d474672 --- /dev/null +++ b/internal/vaultcluster/configure.go @@ -0,0 +1,166 @@ +package vaultcluster + +import ( + "fmt" + "log" + + "github.com/hashicorp/vault/api" +) + +func (c *Client) Configure() error { + if c.Cfg.Token == "" { + return fmt.Errorf("VAULT_TOKEN is not set") + } + log.Printf("configuring platform at %s", c.Cfg.Addr) + if err := c.enableAudit(); err != nil { + return err + } + if err := c.mountKV(); err != nil { + return err + } + if err := c.enableAppRole(); err != nil { + return err + } + for _, name := range []string{"provisioning", "operator"} { + hcl, err := RenderPolicy(name, "", c.Cfg) + if err != nil { + return err + } + if err := LintOrError(name, hcl, c.Cfg); err != nil { + return err + } + if err := c.API.Sys().PutPolicy(name, hcl); err != nil { + return fmt.Errorf("apply policy %s: %w", name, err) + } + log.Printf("applied policy %s", name) + } + if err := c.mountDatabase(); err != nil { + return err + } + log.Printf("platform configuration complete") + return nil +} + +func (c *Client) enableAudit() error { + if !c.Cfg.EnableAudit { + log.Printf("ENABLE_AUDIT is false; skipping") + return nil + } + if c.Cfg.AuditPath == "" { + return fmt.Errorf("AUDIT_LOG_PATH is required") + } + audits, err := c.API.Sys().ListAudit() + if err != nil { + return err + } + if _, ok := audits[c.Cfg.AuditDevice+"/"]; ok { + log.Printf("audit device %s already enabled", c.Cfg.AuditDevice) + return nil + } + return c.API.Sys().EnableAuditWithOptions(c.Cfg.AuditDevice, &api.EnableAuditOptions{ + Type: "file", + Description: "Platform audit device", + Options: map[string]string{ + "file_path": c.Cfg.AuditPath, + "log_raw": "false", + "hmac_accessor": "true", + "mode": "0600", + "format": "json", + }, + }) +} + +func (c *Client) mountKV() error { + mounts, err := c.API.Sys().ListMounts() + if err != nil { + return err + } + if _, ok := mounts[c.Cfg.KVMount+"/"]; !ok { + if err := c.API.Sys().Mount(c.Cfg.KVMount, &api.MountInput{ + Type: "kv", + Description: "Multi-tenant secret store", + Options: map[string]string{"version": "2"}, + }); err != nil { + return err + } + } + tune, err := c.API.Logical().Read("sys/mounts/" + c.Cfg.KVMount + "/tune") + if err != nil { + return err + } + ver := "" + if tune != nil { + if opts, ok := tune.Data["options"].(map[string]any); ok { + if v, ok := opts["version"].(string); ok { + ver = v + } + } + } + if ver != "2" { + return fmt.Errorf("%s/ is not KV v2", c.Cfg.KVMount) + } + _, err = c.API.Logical().Write(c.Cfg.KVMount+"/config", map[string]any{ + "max_versions": 10, + "cas_required": false, + "delete_version_after": "0s", + }) + return err +} + +func (c *Client) enableAppRole() error { + auths, err := c.API.Sys().ListAuth() + if err != nil { + return err + } + if _, ok := auths[c.Cfg.AuthMount+"/"]; !ok { + if err := c.API.Sys().EnableAuthWithOptions(c.Cfg.AuthMount, &api.EnableAuthOptions{ + Type: "approle", + Description: "Tenant workload identities", + }); err != nil { + return err + } + } + return c.API.Sys().TuneMount("auth/"+c.Cfg.AuthMount, api.MountConfigInput{ + DefaultLeaseTTL: c.Cfg.TokenTTL, + MaxLeaseTTL: c.Cfg.TokenMaxTTL, + }) +} + +func (c *Client) mountDatabase() error { + if !c.Cfg.EnableCredentials { + log.Printf("dynamic credentials disabled") + return nil + } + if c.Cfg.DatabaseURL == "" || c.Cfg.DatabaseUsername == "" || c.Cfg.DatabasePassword == "" { + return fmt.Errorf("database connection settings are required when credentials are enabled") + } + mounts, err := c.API.Sys().ListMounts() + if err != nil { + return err + } + if _, ok := mounts[c.Cfg.DatabaseMount+"/"]; !ok { + if err := c.API.Sys().Mount(c.Cfg.DatabaseMount, &api.MountInput{ + Type: "database", + Description: "Dynamic database credentials", + Config: api.MountConfigInput{ + DefaultLeaseTTL: c.Cfg.DatabaseTTL, + MaxLeaseTTL: c.Cfg.DatabaseMaxTTL, + }, + }); err != nil { + return err + } + } + _, err = c.API.Logical().Write(c.Cfg.DatabaseMount+"/config/"+c.Cfg.DatabaseConnName, map[string]any{ + "plugin_name": "postgresql-database-plugin", + "connection_url": c.Cfg.DatabaseURL, + "username": c.Cfg.DatabaseUsername, + "password": c.Cfg.DatabasePassword, + "allowed_roles": []string{"tenant-*"}, + "max_open_connections": 8, + "max_idle_connections": 2, + "max_connection_lifetime": "5m", + "verify_connection": true, + "password_authentication": "password", + }) + return err +} diff --git a/internal/vaultcluster/credentials_test.go b/internal/vaultcluster/credentials_test.go new file mode 100644 index 0000000..dc9d259 --- /dev/null +++ b/internal/vaultcluster/credentials_test.go @@ -0,0 +1,222 @@ +package vaultcluster + +import ( + "fmt" + "os/exec" + "strings" + "testing" + "time" +) + +func TestCredentialsMatrix(t *testing.T) { + pg, c := credentialsStack(t) + c.Cfg.EnableCredentials = true + if err := c.Configure(); err != nil { + t.Fatal(err) + } + if err := c.CreateTenant("tenant-a", false); err != nil { + t.Fatal(err) + } + if err := c.CreateTenant("tenant-b", false); err != nil { + t.Fatal(err) + } + + tokA, err := c.LoginAppRole(c.Cfg.TenantRole("writer", "tenant-a")) + if err != nil { + t.Fatal(err) + } + tokB, err := c.LoginAppRole(c.Cfg.TenantRole("writer", "tenant-b")) + if err != nil { + t.Fatal(err) + } + tokAR, err := c.LoginAppRole(c.Cfg.TenantRole("reader", "tenant-a")) + if err != nil { + t.Fatal(err) + } + + a := c.WithToken(tokA) + ro, err := a.API.Logical().Read(c.Cfg.DatabaseMount + "/creds/tenant-tenant-a-readonly") + if err != nil || ro == nil || ro.Data == nil { + t.Fatalf("issue readonly: %v %#v", err, ro) + } + roUser := fmt.Sprint(ro.Data["username"]) + roPass := fmt.Sprint(ro.Data["password"]) + + out, err := pgQuery(pg, roUser, roPass, "SELECT count(*) FROM app.items;") + if err != nil { + t.Fatalf("readonly SELECT: %v %s", err, out) + } + if strings.TrimSpace(out) != "1" { + t.Fatalf("readonly SELECT count=%q", out) + } + if _, err := pgQuery(pg, roUser, roPass, "INSERT INTO app.items (name) VALUES ('nope');"); err == nil { + t.Fatal("readonly INSERT succeeded") + } + + rw, err := a.API.Logical().Read(c.Cfg.DatabaseMount + "/creds/tenant-tenant-a-readwrite") + if err != nil || rw == nil || rw.Data == nil { + t.Fatalf("issue readwrite: %v", err) + } + rwUser := fmt.Sprint(rw.Data["username"]) + rwPass := fmt.Sprint(rw.Data["password"]) + if _, err := pgQuery(pg, rwUser, rwPass, "INSERT INTO app.items (name) VALUES ('ok');"); err != nil { + t.Fatalf("readwrite INSERT: %v", err) + } + + b := c.WithToken(tokB) + r, err := b.Do("GET", c.Cfg.DatabaseMount+"/creds/tenant-tenant-a-readonly", nil) + if err != nil && r.Status == 0 { + t.Fatal(err) + } + if r.Status != 403 { + t.Fatalf("cross-tenant creds: HTTP %d want 403", r.Status) + } + r, err = c.WithToken(tokAR).Do("GET", c.Cfg.DatabaseMount+"/creds/tenant-tenant-a-readonly", nil) + if err != nil && r.Status == 0 { + t.Fatal(err) + } + if r.Status != 403 { + t.Fatalf("reader creds: HTTP %d want 403", r.Status) + } + + if err := c.API.Sys().Revoke(ro.LeaseID); err != nil { + t.Fatal(err) + } + if _, err := pgQuery(pg, roUser, roPass, "SELECT 1;"); err == nil { + t.Fatal("revoked readonly still connects") + } +} + +func TestCredentialsAfterIsolation(t *testing.T) { + pg, c := credentialsStack(t) + c.Cfg.EnableCredentials = false + if err := c.Configure(); err != nil { + t.Fatal(err) + } + if err := c.CreateTenant("tenant-a", false); err != nil { + t.Fatal(err) + } + + store := FileKeyStore{Dir: t.TempDir()} + for _, name := range []string{"operator", "provisioning"} { + tok, err := c.issueOrphanToken(name) + if err != nil { + t.Fatal(err) + } + if err := store.SaveToken(name, tok); err != nil { + t.Fatal(err) + } + } + provTok, err := store.LoadToken("provisioning") + if err != nil { + t.Fatal(err) + } + prov := c.WithToken(provTok) + prov.Cfg.EnableCredentials = true + if err := prov.enableCredentialsIfNeeded(store); err != nil { + t.Fatal(err) + } + if err := prov.CreateTenant("tenant-a", false); err != nil { + t.Fatal(err) + } + + tokA, err := c.LoginAppRole(c.Cfg.TenantRole("writer", "tenant-a")) + if err != nil { + t.Fatal(err) + } + ro, err := c.WithToken(tokA).API.Logical().Read(c.Cfg.DatabaseMount + "/creds/tenant-tenant-a-readonly") + if err != nil || ro == nil || ro.Data == nil { + t.Fatalf("issue readonly after isolation: %v %#v", err, ro) + } + roUser := fmt.Sprint(ro.Data["username"]) + roPass := fmt.Sprint(ro.Data["password"]) + out, err := pgQuery(pg, roUser, roPass, "SELECT count(*) FROM app.items;") + if err != nil { + t.Fatalf("readonly SELECT: %v %s", err, out) + } + if strings.TrimSpace(out) != "1" { + t.Fatalf("readonly SELECT count=%q", out) + } +} + +func credentialsStack(t *testing.T) (string, *Client) { + t.Helper() + requireDocker(t) + + net := fmt.Sprintf("vctest-%d", time.Now().UnixNano()) + if err := exec.Command("docker", "network", "create", net).Run(); err != nil { + t.Fatalf("network create: %v", err) + } + t.Cleanup(func() { _ = exec.Command("docker", "network", "rm", net).Run() }) + + pgPass := "local-dev-only-not-a-real-secret" + pg := dockerRun(t, + "--name", fmt.Sprintf("vctest-pg-%d", time.Now().UnixNano()), + "--network", net, "--network-alias", "pg", + "-e", "POSTGRES_PASSWORD="+pgPass, + "-e", "POSTGRES_DB=appdb", + postgresTestImage, + ) + deadline := time.Now().Add(45 * time.Second) + var last string + ready := false + for time.Now().Before(deadline) { + out, err := pgQuery(pg, "postgres", pgPass, "SELECT 1;") + if err == nil { + ready = true + break + } + last = strings.TrimSpace(fmt.Sprintf("%v %s", err, out)) + time.Sleep(250 * time.Millisecond) + } + if !ready { + logs, _ := exec.Command("docker", "logs", pg).CombinedOutput() + t.Fatalf("postgres did not become ready: %s\n%s", last, logs) + } + + mustPG(t, pg, "postgres", pgPass, ` +CREATE ROLE app_readonly NOLOGIN; +CREATE ROLE app_readwrite NOLOGIN; +CREATE ROLE vault_admin LOGIN CREATEROLE PASSWORD 'local-dev-only-vault-admin-not-a-real-secret'; +GRANT app_readonly TO vault_admin WITH ADMIN OPTION; +GRANT app_readwrite TO vault_admin WITH ADMIN OPTION; +CREATE SCHEMA app; +CREATE TABLE app.items (id serial PRIMARY KEY, name text NOT NULL); +INSERT INTO app.items (name) VALUES ('row-a'); +GRANT USAGE ON SCHEMA app TO app_readonly, app_readwrite; +GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_readonly; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_readwrite; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_readwrite; +`) + + c := startVaultInmem(t, "--network", net) + c.Cfg.KVMount = "kv" + c.Cfg.TenantPrefix = "customers" + c.Cfg.AuthMount = "approle" + c.Cfg.DatabaseMount = "database" + c.Cfg.EnableAudit = false + c.Cfg.DatabaseURL = "postgresql://{{username}}:{{password}}@pg:5432/appdb?sslmode=disable" + c.Cfg.DatabaseUsername = "vault_admin" + c.Cfg.DatabasePassword = "local-dev-only-vault-admin-not-a-real-secret" + c.Cfg.DatabaseConnName = "app" + c.Cfg.DatabaseTTL = "1h" + c.Cfg.DatabaseMaxTTL = "24h" + c.Cfg.TokenTTL = "1h" + c.Cfg.TokenMaxTTL = "24h" + return pg, c +} + +func mustPG(t *testing.T, id, user, pass, sql string) { + t.Helper() + out, err := pgQuery(id, user, pass, sql) + if err != nil { + t.Fatalf("psql: %v\n%s", err, out) + } +} + +func pgQuery(id, user, pass, sql string) (string, error) { + cmd := exec.Command("docker", "exec", "-e", "PGPASSWORD="+pass, id, + "psql", "-U", user, "-d", "appdb", "-v", "ON_ERROR_STOP=1", "-tAc", sql) + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/internal/vaultcluster/docker_test.go b/internal/vaultcluster/docker_test.go new file mode 100644 index 0000000..cd91a00 --- /dev/null +++ b/internal/vaultcluster/docker_test.go @@ -0,0 +1,119 @@ +package vaultcluster + +import ( + "os/exec" + "strings" + "testing" + "time" + + "github.com/hashicorp/vault/api" +) + +const ( + vaultTestImage = "hashicorp/vault:1.21.4@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569" + postgresTestImage = "postgres:16.15-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685" +) + +func requireDocker(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("skipping docker") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("docker daemon is not running") + } +} + +func dockerOutput(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("docker", args...).Output() + if err != nil { + stderr := []byte(nil) + if ee, ok := err.(*exec.ExitError); ok { + stderr = ee.Stderr + } + t.Fatalf("docker %v: %v\n%s\n%s", args, err, out, stderr) + } + return strings.TrimSpace(string(out)) +} + +func dockerRun(t *testing.T, args ...string) string { + t.Helper() + id := lastLine(dockerOutput(t, append([]string{"run", "-d"}, args...)...)) + t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", id).Run() }) + return id +} + +func lastLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.LastIndex(s, "\n"); i >= 0 { + return strings.TrimSpace(s[i+1:]) + } + return s +} + +func vaultListenAddr(t *testing.T, id string) string { + t.Helper() + var portOut []byte + var portErr error + for i := 0; i < 20; i++ { + portOut, portErr = exec.Command("docker", "port", id, "8200").CombinedOutput() + if portErr == nil && strings.Contains(string(portOut), ":") { + break + } + time.Sleep(250 * time.Millisecond) + } + if portErr != nil || !strings.Contains(string(portOut), ":") { + logs, _ := exec.Command("docker", "logs", id).CombinedOutput() + t.Fatalf("docker port %s: %v\n%s\nlogs:\n%s", id, portErr, portOut, logs) + } + line := strings.TrimSpace(strings.Split(string(portOut), "\n")[0]) + hostPort := line + if i := strings.LastIndex(line, ":"); i >= 0 { + hostPort = line[i+1:] + } + return "http://127.0.0.1:" + hostPort +} + +func startVaultInmem(t *testing.T, extraRunArgs ...string) *Client { + t.Helper() + requireDocker(t) + cfgJSON := `{"disable_mlock":true,"listener":{"tcp":{"address":"0.0.0.0:8200","tls_disable":true}},"storage":{"inmem":{}}}` + args := append([]string{ + "-p", "127.0.0.1::8200", + "-e", "VAULT_LOCAL_CONFIG=" + cfgJSON, + "--cap-add", "IPC_LOCK", + }, extraRunArgs...) + args = append(args, vaultTestImage, "server") + id := dockerRun(t, args...) + c, err := New(Config{Addr: vaultListenAddr(t, id), HTTPTimeout: 10 * time.Second}) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if st, err := c.API.Sys().SealStatus(); err == nil && st != nil { + break + } + time.Sleep(200 * time.Millisecond) + } + resp, err := c.API.Sys().Init(&api.InitRequest{SecretShares: 1, SecretThreshold: 1}) + if err != nil { + t.Fatal(err) + } + key := "" + if len(resp.KeysB64) > 0 { + key = resp.KeysB64[0] + } else if len(resp.Keys) > 0 { + key = resp.Keys[0] + } + if _, err := c.API.Sys().Unseal(key); err != nil { + t.Fatal(err) + } + c.API.SetToken(resp.RootToken) + c.Cfg.Token = resp.RootToken + return c +} diff --git a/internal/vaultcluster/health.go b/internal/vaultcluster/health.go new file mode 100644 index 0000000..47268ff --- /dev/null +++ b/internal/vaultcluster/health.go @@ -0,0 +1,27 @@ +package vaultcluster + +import ( + "fmt" + "os" +) + +func (c *Client) Health() error { + st, err := c.API.Sys().SealStatus() + if err != nil { + return err + } + fmt.Printf("initialized %v\n", st.Initialized) + fmt.Printf("sealed %v\n", st.Sealed) + if st.Sealed { + return fmt.Errorf("vault is sealed") + } + if c.Cfg.Token != "" { + if _, err := c.API.Logical().Read("sys/mounts/" + c.Cfg.KVMount + "/tune"); err != nil { + fmt.Fprintf(os.Stderr, "kv mount: %v\n", err) + return err + } + fmt.Printf("kv %s/ (v2)\n", c.Cfg.KVMount) + } + fmt.Println("healthy") + return nil +} diff --git a/internal/vaultcluster/isolation_test.go b/internal/vaultcluster/isolation_test.go new file mode 100644 index 0000000..5386435 --- /dev/null +++ b/internal/vaultcluster/isolation_test.go @@ -0,0 +1,167 @@ +package vaultcluster + +import ( + "fmt" + "testing" + + "github.com/hashicorp/vault/api" +) + +func TestIsolationMatrix(t *testing.T) { + c := startVaultInmem(t) + c.Cfg.KVMount = "kv" + c.Cfg.TenantPrefix = "customers" + c.Cfg.AuthMount = "approle" + c.Cfg.DatabaseMount = "database" + c.Cfg.EnableAudit = false + if err := c.Configure(); err != nil { + t.Fatal(err) + } + if err := c.CreateTenant("tenant-a", false); err != nil { + t.Fatal(err) + } + if err := c.CreateTenant("tenant-b", false); err != nil { + t.Fatal(err) + } + + tokA, err := c.LoginAppRole(c.Cfg.TenantRole("reader", "tenant-a")) + if err != nil { + t.Fatal(err) + } + tokB, err := c.LoginAppRole(c.Cfg.TenantRole("reader", "tenant-b")) + if err != nil { + t.Fatal(err) + } + tokAW, err := c.LoginAppRole(c.Cfg.TenantRole("writer", "tenant-a")) + if err != nil { + t.Fatal(err) + } + tokBW, err := c.LoginAppRole(c.Cfg.TenantRole("writer", "tenant-b")) + if err != nil { + t.Fatal(err) + } + + provSec, err := c.API.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"provisioning"}, + Period: "1h", + NoParent: true, + DisplayName: "provisioning", + }) + if err != nil || provSec == nil || provSec.Auth == nil { + t.Fatalf("provisioning token: %v", err) + } + tokProv := provSec.Auth.ClientToken + + mustWrite := func(token, path, val string) { + t.Helper() + cl := c.WithToken(token) + if _, err := cl.API.Logical().Write(path, map[string]any{"data": map[string]any{"value": val}}); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + mustWrite(tokAW, c.Cfg.KVDataPath("tenant-a", "fixture"), "secret-a") + mustWrite(tokBW, c.Cfg.KVDataPath("tenant-b", "fixture"), "secret-b") + + assertStatus := func(token, method, path string, body any, want int) { + t.Helper() + cl := c.WithToken(token) + r, err := cl.Do(method, path, body) + if err != nil && r.Status == 0 { + t.Fatalf("%s %s: %v", method, path, err) + } + if r.Status != want { + t.Fatalf("%s %s: got HTTP %d want %d body %s", method, path, r.Status, want, r.Body) + } + } + deny := func(token, method, path string, body any) { + t.Helper() + assertStatus(token, method, path, body, 403) + } + + aData := c.Cfg.KVDataPath("tenant-a", "fixture") + bData := c.Cfg.KVDataPath("tenant-b", "fixture") + aMeta := c.Cfg.KVMetaPath("tenant-a", "fixture") + bMeta := c.Cfg.KVMetaPath("tenant-b", "fixture") + crossWrite := map[string]any{"data": map[string]any{"value": "FAKE-cross-tenant-write"}} + + assertStatus(tokA, "GET", aData, nil, 200) + assertStatus(tokB, "GET", bData, nil, 200) + assertStatus(tokA, "GET", "auth/token/lookup-self", nil, 200) + + deny(tokA, "GET", bData, nil) + deny(tokB, "GET", aData, nil) + deny(tokAW, "GET", bData, nil) + deny(tokBW, "GET", aData, nil) + deny(tokAW, "POST", bData, crossWrite) + deny(tokBW, "POST", aData, crossWrite) + deny(tokAW, "POST", c.Cfg.KVDataPath("tenant-b", "newly-planted-secret"), map[string]any{ + "data": map[string]any{"value": "FAKE-planted"}, + }) + deny(tokAW, "DELETE", bData, nil) + deny(tokAW, "POST", c.Cfg.KVMount+"/destroy/"+c.Cfg.TenantPrefix+"/tenant-b/fixture", map[string]any{"versions": []int{1}}) + deny(tokAW, "DELETE", bMeta, nil) + deny(tokA, "GET", bMeta, nil) + deny(tokB, "GET", aMeta, nil) + deny(tokA, "GET", c.Cfg.KVMount+"/metadata/"+c.Cfg.TenantPrefix+"?list=true", nil) + deny(tokB, "GET", c.Cfg.KVMount+"/metadata/"+c.Cfg.TenantPrefix+"?list=true", nil) + deny(tokA, "GET", c.Cfg.KVMetaPath("tenant-b", "")+"?list=true", nil) + + deny(tokA, "GET", c.Cfg.KVMount+"/data/"+c.Cfg.TenantPrefix, nil) + deny(tokA, "GET", c.Cfg.KVMount+"/data", nil) + deny(tokA, "GET", c.Cfg.KVMount+"/metadata/"+c.Cfg.TenantPrefix, nil) + deny(tokAW, "POST", c.Cfg.KVMount+"/data/"+c.Cfg.TenantPrefix+"/shared-secret", map[string]any{ + "data": map[string]any{"value": "FAKE-planted-at-parent"}, + }) + deny(tokA, "GET", c.Cfg.KVMount+"/data/platform/root-credentials", nil) + deny(tokAW, "POST", c.Cfg.KVMount+"/data/platform/root-credentials", map[string]any{ + "data": map[string]any{"value": "FAKE-planted-sibling"}, + }) + deny(tokA, "GET", c.Cfg.KVDataPath("tenant-a-extended", "secret"), nil) + deny(tokA, "GET", c.Cfg.KVDataPath("tenant-ax", "secret"), nil) + deny(tokA, "GET", c.Cfg.KVMount+"/data/"+c.Cfg.TenantPrefix+"/*", nil) + + deny(tokA, "GET", "sys/mounts", nil) + deny(tokA, "GET", "sys/auth", nil) + deny(tokAW, "POST", "sys/mounts/rogue", map[string]any{"type": "kv", "options": map[string]any{"version": "2"}}) + deny(tokAW, "DELETE", "sys/mounts/"+c.Cfg.KVMount, nil) + deny(tokA, "GET", "sys/audit", nil) + deny(tokA, "GET", "sys/policies/acl?list=true", nil) + deny(tokA, "GET", "sys/policies/acl/"+c.Cfg.TenantPolicy("reader", "tenant-b"), nil) + deny(tokAW, "PUT", "sys/policies/acl/"+c.Cfg.TenantPolicy("reader", "tenant-a"), map[string]any{ + "policy": `path "kv/data/*" { capabilities = ["read", "list"] }`, + }) + deny(tokA, "GET", "auth/"+c.Cfg.AuthMount+"/role?list=true", nil) + deny(tokA, "GET", "auth/"+c.Cfg.AuthMount+"/role/"+c.Cfg.TenantRole("reader", "tenant-b")+"/role-id", nil) + deny(tokAW, "POST", "auth/"+c.Cfg.AuthMount+"/role/"+c.Cfg.TenantRole("reader", "tenant-b")+"/secret-id", map[string]any{}) + deny(tokAW, "POST", "auth/token/create", map[string]any{"policies": []string{"operator"}}) + deny(tokA, "GET", "auth/token/accessors?list=true", nil) + + deny(tokProv, "GET", aData, nil) + deny(tokProv, "GET", aMeta, nil) + deny(tokProv, "POST", aData, map[string]any{"data": map[string]any{"value": "FAKE-written-by-provisioning"}}) + deny(tokProv, "PUT", "sys/policies/acl/provisioning", map[string]any{ + "policy": `path "kv/data/*" { capabilities = ["read"] }`, + }) + assertStatus(tokProv, "GET", "auth/"+c.Cfg.AuthMount+"/role/"+c.Cfg.TenantRole("reader", "tenant-a")+"/role-id", nil, 200) + + for _, traversal := range []string{ + fmt.Sprintf("%s/data/%s/tenant-a/../tenant-b/fixture", c.Cfg.KVMount, c.Cfg.TenantPrefix), + fmt.Sprintf("%s/data/%s/tenant-a/..%%2ftenant-b/fixture", c.Cfg.KVMount, c.Cfg.TenantPrefix), + } { + cl := c.WithToken(tokA) + r, err := cl.Do("GET", traversal, nil) + if err != nil && r.Status == 0 { + t.Fatalf("traversal %s: %v", traversal, err) + } + if r.Status >= 200 && r.Status < 300 { + t.Fatalf("traversal granted HTTP %d: %s", r.Status, traversal) + } + } + + if err := c.OffboardTenant("tenant-a", false); err != nil { + t.Fatal(err) + } + if _, err := c.LoginAppRole(c.Cfg.TenantRole("reader", "tenant-a")); err == nil { + t.Fatal("expected AppRole login to fail after offboard") + } +} diff --git a/internal/vaultcluster/keystore.go b/internal/vaultcluster/keystore.go new file mode 100644 index 0000000..2fbddf3 --- /dev/null +++ b/internal/vaultcluster/keystore.go @@ -0,0 +1,63 @@ +package vaultcluster + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/vault/api" +) + +type KeyStore interface { + SaveInit(resp *api.InitResponse) error + LoadInit() (*api.InitResponse, error) + SaveToken(name, token string) error + LoadToken(name string) (string, error) +} + +type FileKeyStore struct { + Dir string +} + +func (s FileKeyStore) SaveInit(resp *api.InitResponse) error { + b, err := json.Marshal(resp) + if err != nil { + return err + } + return s.write(filepath.Join(s.Dir, "vault-init.json"), b) +} + +func (s FileKeyStore) LoadInit() (*api.InitResponse, error) { + raw, err := os.ReadFile(filepath.Join(s.Dir, "vault-init.json")) + if err != nil { + return nil, err + } + var resp api.InitResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (s FileKeyStore) SaveToken(name, token string) error { + return s.write(filepath.Join(s.Dir, name+".token"), []byte(token)) +} + +func (s FileKeyStore) LoadToken(name string) (string, error) { + b, err := os.ReadFile(filepath.Join(s.Dir, name+".token")) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func (s FileKeyStore) write(path string, b []byte) error { + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return err + } + if err := os.WriteFile(path, b, 0o600); err != nil { + return err + } + return chownToDirOwner(s.Dir, path) +} diff --git a/internal/vaultcluster/keystore_unix.go b/internal/vaultcluster/keystore_unix.go new file mode 100644 index 0000000..a0c70d4 --- /dev/null +++ b/internal/vaultcluster/keystore_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package vaultcluster + +import ( + "os" + "strconv" + "syscall" +) + +func chownToDirOwner(dir, path string) error { + if os.Getuid() != 0 { + return nil + } + uid, gid := 0, 0 + if fi, err := os.Stat(dir); err == nil { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + uid = int(st.Uid) + gid = int(st.Gid) + } + } + if v := os.Getenv("BOOTSTRAP_UID"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + uid = n + } + } + if v := os.Getenv("BOOTSTRAP_GID"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + gid = n + } + } + _ = os.Chown(dir, uid, gid) + return os.Chown(path, uid, gid) +} diff --git a/internal/vaultcluster/keystore_windows.go b/internal/vaultcluster/keystore_windows.go new file mode 100644 index 0000000..faf9d71 --- /dev/null +++ b/internal/vaultcluster/keystore_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package vaultcluster + +// Ownership fixups only apply inside the Linux bootstrap container. +func chownToDirOwner(dir, path string) error { + return nil +} diff --git a/internal/vaultcluster/lint.go b/internal/vaultcluster/lint.go new file mode 100644 index 0000000..64560dc --- /dev/null +++ b/internal/vaultcluster/lint.go @@ -0,0 +1,140 @@ +package vaultcluster + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + pathRE = regexp.MustCompile(`path\s+"([^"]+)"`) + capsRE = regexp.MustCompile(`capabilities\s*=\s*\[([^\]]*)\]`) +) + +var kvV2Segments = map[string]struct{}{ + "data": {}, "metadata": {}, "delete": {}, "undelete": {}, + "destroy": {}, "config": {}, "subkeys": {}, +} + +var validCaps = map[string]struct{}{ + "create": {}, "read": {}, "update": {}, "delete": {}, "list": {}, + "patch": {}, "sudo": {}, "deny": {}, "recover": {}, "subscribe": {}, +} + +type Finding struct { + Policy string + Msg string +} + +func (f Finding) String() string { + return f.Policy + ": " + f.Msg +} + +func LintPolicy(policyName, src string, cfg Config) []Finding { + type pair struct{ path, caps string } + var pairs []pair + lines := strings.Split(src, "\n") + current := "" + for _, line := range lines { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "#") { + continue + } + if m := pathRE.FindStringSubmatch(line); m != nil { + current = m[1] + continue + } + if m := capsRE.FindStringSubmatch(line); m != nil && current != "" { + caps := strings.ReplaceAll(m[1], `"`, "") + caps = strings.ReplaceAll(caps, " ", "") + pairs = append(pairs, pair{current, caps}) + current = "" + } + } + + var findings []Finding + add := func(msg string) { + findings = append(findings, Finding{Policy: policyName, Msg: msg}) + } + if len(pairs) == 0 { + add("no path rules found") + return findings + } + + seen := map[string]struct{}{} + for _, p := range pairs { + if p.path == "*" || p.path == "/*" { + if p.caps != "deny" { + add(fmt.Sprintf("path %q grants [%s] over the entire Vault API", p.path, p.caps)) + } + } + if p.caps == "" { + add(fmt.Sprintf("path %q has an empty capabilities list", p.path)) + } + for _, c := range strings.Split(p.caps, ",") { + if c == "" { + continue + } + if _, ok := validCaps[c]; !ok { + add(fmt.Sprintf("path %q has unknown capability %q", p.path, c)) + } + } + if strings.Contains(","+p.caps+",", ",deny,") && p.caps != "deny" { + add(fmt.Sprintf("path %q mixes deny with [%s]", p.path, p.caps)) + } + if strings.Contains(","+p.caps+",", ",sudo,") { + if policyName != "operator" && policyName != "admin" { + add(fmt.Sprintf("path %q grants sudo", p.path)) + } + } + + if strings.HasPrefix(p.path, cfg.KVMount+"/") { + rest := strings.TrimPrefix(p.path, cfg.KVMount+"/") + second, _, _ := strings.Cut(rest, "/") + ok := p.caps == "deny" || second == "*" + if _, hit := kvV2Segments[second]; hit { + ok = true + } + if !ok { + add(fmt.Sprintf("path %q is not a valid KV v2 path", p.path)) + } + } + + if strings.HasSuffix(p.path, "/"+cfg.TenantPrefix+"/*") || strings.HasSuffix(p.path, "/"+cfg.TenantPrefix+"/") { + if strings.HasPrefix(p.path, cfg.KVMount+"/") && p.caps != "deny" { + segs := strings.Split(p.path, "/") + if len(segs) == 4 && segs[2] == cfg.TenantPrefix && segs[3] == "*" && p.caps != "deny" { + add(fmt.Sprintf("path %q grants [%s] across all tenants", p.path, p.caps)) + } + } + } + + switch p.path { + case cfg.KVMount + "/*", cfg.KVMount + "/data/*", cfg.KVMount + "/metadata/*": + if p.caps != "deny" { + add(fmt.Sprintf("path %q grants [%s] over the entire KV mount", p.path, p.caps)) + } + } + if p.path == cfg.DatabaseMount+"/creds/*" && p.caps != "deny" { + add(fmt.Sprintf("path %q grants [%s] on every tenant's database credentials", p.path, p.caps)) + } + if _, ok := seen[p.path]; ok { + add(fmt.Sprintf("path %q is declared more than once", p.path)) + } + seen[p.path] = struct{}{} + } + return findings +} + +func LintOrError(policyName, src string, cfg Config) error { + fs := LintPolicy(policyName, src, cfg) + if len(fs) == 0 { + return nil + } + var b strings.Builder + fmt.Fprintf(&b, "policy lint failed (%d):", len(fs)) + for _, f := range fs { + fmt.Fprintf(&b, "\n %s", f) + } + return fmt.Errorf("%s", b.String()) +} diff --git a/internal/vaultcluster/lint_test.go b/internal/vaultcluster/lint_test.go new file mode 100644 index 0000000..eadb514 --- /dev/null +++ b/internal/vaultcluster/lint_test.go @@ -0,0 +1,58 @@ +package vaultcluster + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLintFixtures(t *testing.T) { + cfg := Config{KVMount: "kv", TenantPrefix: "customers", DatabaseMount: "database", AuthMount: "approle"} + root := filepath.Join("testdata", "lint") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".hcl") { + continue + } + b, err := os.ReadFile(filepath.Join(root, e.Name())) + if err != nil { + t.Fatal(err) + } + name := strings.TrimSuffix(e.Name(), ".hcl") + findings := LintPolicy(name, string(b), cfg) + wantBad := strings.HasPrefix(e.Name(), "BAD-") + if wantBad && len(findings) == 0 { + t.Errorf("%s: expected findings, got none", e.Name()) + } + if !wantBad && len(findings) > 0 { + t.Errorf("%s: unexpected findings: %v", e.Name(), findings) + } + } +} + +func TestRenderAndLintPlatformPolicies(t *testing.T) { + cfg := Config{KVMount: "kv", TenantPrefix: "customers", DatabaseMount: "database", AuthMount: "approle"} + for _, name := range []string{"provisioning", "operator"} { + hcl, err := RenderPolicy(name, "", cfg) + if err != nil { + t.Fatal(err) + } + if err := LintOrError(name, hcl, cfg); err != nil { + t.Fatal(err) + } + } + for _, tmpl := range []string{"tenant-reader", "tenant-writer", "tenant-database"} { + hcl, err := RenderPolicy(tmpl, "tenant-a", cfg) + if err != nil { + t.Fatal(err) + } + name := "tenant-tenant-a-" + strings.TrimPrefix(tmpl, "tenant-") + if err := LintOrError(name, hcl, cfg); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/vaultcluster/policies/embed.go b/internal/vaultcluster/policies/embed.go new file mode 100644 index 0000000..ebb3fc0 --- /dev/null +++ b/internal/vaultcluster/policies/embed.go @@ -0,0 +1,6 @@ +package policies + +import "embed" + +//go:embed templates/*.hcl.tpl +var Templates embed.FS diff --git a/config/policies/templates/operator.hcl.tpl b/internal/vaultcluster/policies/templates/operator.hcl.tpl similarity index 78% rename from config/policies/templates/operator.hcl.tpl rename to internal/vaultcluster/policies/templates/operator.hcl.tpl index 329205d..09520e6 100644 --- a/config/policies/templates/operator.hcl.tpl +++ b/internal/vaultcluster/policies/templates/operator.hcl.tpl @@ -15,6 +15,10 @@ path "sys/mounts" { capabilities = ["read", "sudo"] } +path "sys/mounts/{{.DatabaseMount}}" { + capabilities = ["create", "update", "sudo"] +} + path "sys/mounts/*" { capabilities = ["read"] } @@ -35,22 +39,26 @@ path "sys/audit" { capabilities = ["read", "sudo"] } -path "auth/@@AUTH_MOUNT@@/role" { +path "auth/{{.AuthMount}}/role" { capabilities = ["list"] } -path "auth/@@AUTH_MOUNT@@/role/*" { +path "auth/{{.AuthMount}}/role/*" { capabilities = ["read"] } -path "@@DATABASE_MOUNT@@/roles" { +path "{{.DatabaseMount}}/roles" { capabilities = ["list"] } -path "@@DATABASE_MOUNT@@/roles/*" { +path "{{.DatabaseMount}}/roles/*" { capabilities = ["read"] } +path "{{.DatabaseMount}}/config/*" { + capabilities = ["create", "update", "read"] +} + path "sys/leases/lookup" { capabilities = ["update"] } @@ -87,15 +95,15 @@ path "auth/token/revoke-self" { capabilities = ["update"] } -path "@@KV_MOUNT@@/data/*" { +path "{{.KVMount}}/data/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/metadata/*" { +path "{{.KVMount}}/metadata/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/creds/*" { +path "{{.DatabaseMount}}/creds/*" { capabilities = ["deny"] } diff --git a/config/policies/templates/provisioning.hcl.tpl b/internal/vaultcluster/policies/templates/provisioning.hcl.tpl similarity index 84% rename from config/policies/templates/provisioning.hcl.tpl rename to internal/vaultcluster/policies/templates/provisioning.hcl.tpl index 7eade69..de620cb 100644 --- a/config/policies/templates/provisioning.hcl.tpl +++ b/internal/vaultcluster/policies/templates/provisioning.hcl.tpl @@ -7,15 +7,15 @@ path "sys/policies/acl" { capabilities = ["list"] } -path "auth/@@AUTH_MOUNT@@/role/tenant-*" { +path "auth/{{.AuthMount}}/role/tenant-*" { capabilities = ["create", "read", "update", "delete", "list"] } -path "auth/@@AUTH_MOUNT@@/role" { +path "auth/{{.AuthMount}}/role" { capabilities = ["list"] } -path "@@DATABASE_MOUNT@@/roles/tenant-*" { +path "{{.DatabaseMount}}/roles/tenant-*" { capabilities = ["create", "read", "update", "delete", "list"] } @@ -35,7 +35,7 @@ path "auth/token/renew-self" { capabilities = ["update"] } -path "@@KV_MOUNT@@/*" { +path "{{.KVMount}}/*" { capabilities = ["deny"] } @@ -51,7 +51,7 @@ path "sys/audit-hash/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/creds/*" { +path "{{.DatabaseMount}}/creds/*" { capabilities = ["deny"] } diff --git a/config/policies/templates/tenant-database.hcl.tpl b/internal/vaultcluster/policies/templates/tenant-database.hcl.tpl similarity index 56% rename from config/policies/templates/tenant-database.hcl.tpl rename to internal/vaultcluster/policies/templates/tenant-database.hcl.tpl index 1dc60d0..e668a56 100644 --- a/config/policies/templates/tenant-database.hcl.tpl +++ b/internal/vaultcluster/policies/templates/tenant-database.hcl.tpl @@ -1,9 +1,9 @@ # Dynamic DB creds for this tenant only. Additive; does not widen KV access. -path "@@DATABASE_MOUNT@@/creds/tenant-@@TENANT_ID@@-*" { +path "{{.DatabaseMount}}/creds/tenant-{{.TenantID}}-*" { capabilities = ["read"] } -path "@@DATABASE_MOUNT@@/roles/tenant-@@TENANT_ID@@-*" { +path "{{.DatabaseMount}}/roles/tenant-{{.TenantID}}-*" { capabilities = ["read"] } @@ -15,22 +15,22 @@ path "sys/leases/revoke" { capabilities = ["update"] } -path "@@DATABASE_MOUNT@@/creds/*" { +path "{{.DatabaseMount}}/creds/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/roles/*" { +path "{{.DatabaseMount}}/roles/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/config/*" { +path "{{.DatabaseMount}}/config/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/static-creds/*" { +path "{{.DatabaseMount}}/static-creds/*" { capabilities = ["deny"] } -path "@@DATABASE_MOUNT@@/rotate-root/*" { +path "{{.DatabaseMount}}/rotate-root/*" { capabilities = ["deny"] } diff --git a/config/policies/templates/tenant-reader.hcl.tpl b/internal/vaultcluster/policies/templates/tenant-reader.hcl.tpl similarity index 59% rename from config/policies/templates/tenant-reader.hcl.tpl rename to internal/vaultcluster/policies/templates/tenant-reader.hcl.tpl index 3776d0d..b204317 100644 --- a/config/policies/templates/tenant-reader.hcl.tpl +++ b/internal/vaultcluster/policies/templates/tenant-reader.hcl.tpl @@ -1,29 +1,29 @@ # Read-only on one tenant. KV v2 needs data/ and metadata/; kv/{prefix}/{id} matches nothing. -path "@@KV_MOUNT@@/data/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/data/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["read"] } -path "@@KV_MOUNT@@/metadata/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/metadata/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["read", "list"] } -path "@@KV_MOUNT@@/data/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/data/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/metadata/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/metadata/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/delete/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/delete/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/undelete/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/undelete/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/destroy/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/destroy/{{.TenantPrefix}}/*" { capabilities = ["deny"] } @@ -43,7 +43,7 @@ path "auth/token/create*" { capabilities = ["deny"] } -path "auth/@@AUTH_MOUNT@@/role/*" { +path "auth/{{.AuthMount}}/role/*" { capabilities = ["deny"] } @@ -51,6 +51,6 @@ path "identity/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/config" { +path "{{.KVMount}}/config" { capabilities = ["deny"] } diff --git a/config/policies/templates/tenant-writer.hcl.tpl b/internal/vaultcluster/policies/templates/tenant-writer.hcl.tpl similarity index 55% rename from config/policies/templates/tenant-writer.hcl.tpl rename to internal/vaultcluster/policies/templates/tenant-writer.hcl.tpl index e429b41..c39c5ab 100644 --- a/config/policies/templates/tenant-writer.hcl.tpl +++ b/internal/vaultcluster/policies/templates/tenant-writer.hcl.tpl @@ -1,41 +1,41 @@ # Full KV lifecycle on one tenant. Name all five KV v2 path families. -path "@@KV_MOUNT@@/data/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/data/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] } -path "@@KV_MOUNT@@/metadata/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/metadata/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["create", "read", "update", "delete", "list"] } -path "@@KV_MOUNT@@/delete/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/delete/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["update"] } -path "@@KV_MOUNT@@/undelete/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/undelete/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["update"] } -path "@@KV_MOUNT@@/destroy/@@TENANT_PREFIX@@/@@TENANT_ID@@/*" { +path "{{.KVMount}}/destroy/{{.TenantPrefix}}/{{.TenantID}}/*" { capabilities = ["update"] } -path "@@KV_MOUNT@@/data/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/data/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/metadata/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/metadata/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/delete/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/delete/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/undelete/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/undelete/{{.TenantPrefix}}/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/destroy/@@TENANT_PREFIX@@/*" { +path "{{.KVMount}}/destroy/{{.TenantPrefix}}/*" { capabilities = ["deny"] } @@ -55,7 +55,7 @@ path "auth/token/create*" { capabilities = ["deny"] } -path "auth/@@AUTH_MOUNT@@/role/*" { +path "auth/{{.AuthMount}}/role/*" { capabilities = ["deny"] } @@ -63,6 +63,6 @@ path "identity/*" { capabilities = ["deny"] } -path "@@KV_MOUNT@@/config" { +path "{{.KVMount}}/config" { capabilities = ["deny"] } diff --git a/internal/vaultcluster/policy.go b/internal/vaultcluster/policy.go new file mode 100644 index 0000000..9f22377 --- /dev/null +++ b/internal/vaultcluster/policy.go @@ -0,0 +1,38 @@ +package vaultcluster + +import ( + "fmt" + "strings" + "text/template" + + "github.com/nullstone-modules/vault-cluster/internal/vaultcluster/policies" +) + +func RenderPolicy(templateName, tenantID string, cfg Config) (string, error) { + if tenantID != "" { + if err := ValidateTenantID(tenantID); err != nil { + return "", err + } + } + name := templateName + ".hcl.tpl" + raw, err := policies.Templates.ReadFile("templates/" + name) + if err != nil { + return "", fmt.Errorf("no such template %q: %w", templateName, err) + } + tpl, err := template.New(name).Option("missingkey=error").Parse(string(raw)) + if err != nil { + return "", fmt.Errorf("parse template %q: %w", templateName, err) + } + data := struct { + TenantID string + KVMount string + TenantPrefix string + DatabaseMount string + AuthMount string + }{tenantID, cfg.KVMount, cfg.TenantPrefix, cfg.DatabaseMount, cfg.AuthMount} + var b strings.Builder + if err := tpl.Execute(&b, data); err != nil { + return "", fmt.Errorf("render template %q: %w", templateName, err) + } + return b.String(), nil +} diff --git a/internal/vaultcluster/snapshot.go b/internal/vaultcluster/snapshot.go new file mode 100644 index 0000000..29b986e --- /dev/null +++ b/internal/vaultcluster/snapshot.go @@ -0,0 +1,103 @@ +package vaultcluster + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func (c *Client) SnapshotTake(dir string) (string, error) { + req := c.API.NewRequest("GET", "/v1/sys/storage/raft/snapshot") + resp, err := c.API.RawRequest(req) + if err != nil { + return "", fmt.Errorf("snapshot failed: %w", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if len(b) == 0 { + return "", fmt.Errorf("snapshot is empty; refusing to keep it") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + stamp := time.Now().UTC().Format("20060102T150405Z") + file := filepath.Join(dir, "vault-"+stamp+".snap") + if err := os.WriteFile(file, b, 0o600); err != nil { + return "", err + } + sum := sha256.Sum256(b) + if err := os.WriteFile(file+".sha256", []byte(hex.EncodeToString(sum[:])+"\n"), 0o600); err != nil { + return "", err + } + _ = chownToDirOwner(dir, file) + _ = chownToDirOwner(dir, file+".sha256") + return file, nil +} + +func SnapshotVerify(file string) error { + b, err := os.ReadFile(file) + if err != nil { + return err + } + want, err := os.ReadFile(file + ".sha256") + if err != nil { + return fmt.Errorf("no checksum beside %s; integrity cannot be established", file) + } + sum := sha256.Sum256(b) + got := hex.EncodeToString(sum[:]) + if got != strings.TrimSpace(string(want)) { + return fmt.Errorf("checksum mismatch for %s; this snapshot is corrupt and must not be restored", file) + } + return nil +} + +func SnapshotList(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".snap") { + files = append(files, filepath.Join(dir, e.Name())) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(files))) + return files, nil +} + +func (c *Client) SnapshotRestore(file string) error { + if _, err := os.Stat(file + ".sha256"); err == nil { + if err := SnapshotVerify(file); err != nil { + return err + } + } + b, err := os.ReadFile(file) + if err != nil { + return err + } + req := c.API.NewRequest("POST", "/v1/sys/storage/raft/snapshot-force") + req.BodyBytes = b + resp, err := c.API.RawRequest(req) + if err != nil { + return fmt.Errorf("restore failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("restore failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/internal/vaultcluster/tenant.go b/internal/vaultcluster/tenant.go new file mode 100644 index 0000000..1c72697 --- /dev/null +++ b/internal/vaultcluster/tenant.go @@ -0,0 +1,227 @@ +package vaultcluster + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "strings" + + "github.com/hashicorp/vault/api" +) + +func (c *Client) CreateTenant(tenantID string, issueCreds bool) error { + if err := ValidateTenantID(tenantID); err != nil { + return err + } + readerP := c.Cfg.TenantPolicy("reader", tenantID) + writerP := c.Cfg.TenantPolicy("writer", tenantID) + dbP := c.Cfg.TenantPolicy("database", tenantID) + readerR := c.Cfg.TenantRole("reader", tenantID) + writerR := c.Cfg.TenantRole("writer", tenantID) + + apply := func(tmpl, name string) error { + hcl, err := RenderPolicy(tmpl, tenantID, c.Cfg) + if err != nil { + return err + } + if err := LintOrError(name, hcl, c.Cfg); err != nil { + return err + } + return c.API.Sys().PutPolicy(name, hcl) + } + if err := apply("tenant-reader", readerP); err != nil { + return err + } + if err := apply("tenant-writer", writerP); err != nil { + return err + } + if c.Cfg.EnableCredentials { + if err := apply("tenant-database", dbP); err != nil { + return err + } + } + + if err := c.writeAppRole(readerR, []string{readerP}); err != nil { + return err + } + writerPolicies := []string{writerP} + if c.Cfg.EnableCredentials { + writerPolicies = append(writerPolicies, dbP) + } + if err := c.writeAppRole(writerR, writerPolicies); err != nil { + return err + } + + if c.Cfg.EnableCredentials { + if err := c.writeDBRole(tenantID, "readonly", "app_readonly"); err != nil { + return err + } + if err := c.writeDBRole(tenantID, "readwrite", "app_readwrite"); err != nil { + return err + } + } + + if !issueCreds { + log.Printf("tenant %s onboarded (no credentials issued)", tenantID) + return nil + } + if err := c.printAppRoleCreds(readerR); err != nil { + return err + } + return c.printAppRoleCreds(writerR) +} + +func (c *Client) writeAppRole(role string, policies []string) error { + _, err := c.API.Logical().Write("auth/"+c.Cfg.AuthMount+"/role/"+role, map[string]any{ + "token_policies": policies, + "token_ttl": c.Cfg.TokenTTL, + "token_max_ttl": c.Cfg.TokenMaxTTL, + "token_type": "service", + "secret_id_ttl": "24h", + "secret_id_num_uses": 0, + "bind_secret_id": true, + }) + return err +} + +func (c *Client) writeDBRole(tenantID, suffix, group string) error { + role := fmt.Sprintf("tenant-%s-%s", tenantID, suffix) + stmt1 := `CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';` + stmt2 := fmt.Sprintf(`GRANT %s TO "{{name}}";`, group) + _, err := c.API.Logical().Write(c.Cfg.DatabaseMount+"/roles/"+role, map[string]any{ + "db_name": c.Cfg.DatabaseConnName, + "creation_statements": []string{stmt1, stmt2}, + "default_ttl": c.Cfg.DatabaseTTL, + "max_ttl": c.Cfg.DatabaseMaxTTL, + }) + return err +} + +func (c *Client) printAppRoleCreds(role string) error { + s, err := c.API.Logical().Read("auth/" + c.Cfg.AuthMount + "/role/" + role + "/role-id") + if err != nil { + return err + } + sec, err := c.API.Logical().Write("auth/"+c.Cfg.AuthMount+"/role/"+role+"/secret-id", map[string]any{}) + if err != nil { + return err + } + fmt.Printf("----------------------------------------------------------\n") + fmt.Printf("role %s\n", role) + fmt.Printf("role_id %s\n", s.Data["role_id"]) + fmt.Printf("secret_id %s\n", sec.Data["secret_id"]) + return nil +} + +func (c *Client) LoginAppRole(role string) (string, error) { + s, err := c.API.Logical().Read("auth/" + c.Cfg.AuthMount + "/role/" + role + "/role-id") + if err != nil { + return "", err + } + sec, err := c.API.Logical().Write("auth/"+c.Cfg.AuthMount+"/role/"+role+"/secret-id", map[string]any{}) + if err != nil { + return "", err + } + login, err := c.API.Logical().Write("auth/"+c.Cfg.AuthMount+"/login", map[string]any{ + "role_id": s.Data["role_id"], + "secret_id": sec.Data["secret_id"], + }) + if err != nil { + return "", err + } + if login == nil || login.Auth == nil { + return "", fmt.Errorf("approle login returned no auth") + } + return login.Auth.ClientToken, nil +} + +func (c *Client) OffboardTenant(tenantID string, purge bool) error { + if err := ValidateTenantID(tenantID); err != nil { + return err + } + for _, role := range []string{c.Cfg.TenantRole("reader", tenantID), c.Cfg.TenantRole("writer", tenantID)} { + if err := c.deleteMissingOK("auth/" + c.Cfg.AuthMount + "/role/" + role); err != nil { + return fmt.Errorf("delete role %s: %w", role, err) + } + } + if c.Cfg.EnableCredentials { + for _, suffix := range []string{"readonly", "readwrite"} { + dbRole := fmt.Sprintf("tenant-%s-%s", tenantID, suffix) + _ = c.API.Sys().RevokePrefix(c.Cfg.DatabaseMount + "/creds/" + dbRole) + if err := c.deleteMissingOK(c.Cfg.DatabaseMount + "/roles/" + dbRole); err != nil { + return fmt.Errorf("delete database role %s: %w", dbRole, err) + } + } + } + for _, name := range []string{ + c.Cfg.TenantPolicy("reader", tenantID), + c.Cfg.TenantPolicy("writer", tenantID), + c.Cfg.TenantPolicy("database", tenantID), + } { + if err := c.API.Sys().DeletePolicy(name); err != nil && !isNotFound(err) { + return fmt.Errorf("delete policy %s: %w", name, err) + } + } + if purge { + if err := c.purgeTenantSecrets(tenantID); err != nil { + return err + } + } + log.Printf("tenant %s offboarded", tenantID) + return nil +} + +func (c *Client) purgeTenantSecrets(tenantID string) error { + meta := c.Cfg.KVMetaPath(tenantID, "") + r, err := c.Do("GET", meta+"?list=true", nil) + if err != nil && r.Status == 0 { + return err + } + if r.Status == 403 { + return fmt.Errorf("permission denied listing %s (provisioning cannot purge tenant data)", meta) + } + if r.Status == 404 { + return nil + } + if r.Status < 200 || r.Status >= 300 { + return fmt.Errorf("list %s failed (HTTP %d)", meta, r.Status) + } + var wrap struct { + Data struct { + Keys []string `json:"keys"` + } `json:"data"` + } + if err := json.Unmarshal(r.Body, &wrap); err != nil { + return err + } + for _, key := range wrap.Data.Keys { + path := strings.TrimSuffix(meta+"/"+strings.TrimSuffix(key, "/"), "/") + if err := c.deleteMissingOK(path); err != nil { + return fmt.Errorf("destroy %s: %w", path, err) + } + } + return nil +} + +func (c *Client) deleteMissingOK(path string) error { + r, err := c.Do("DELETE", path, nil) + if err != nil && r.Status == 0 { + return err + } + if r.Status == 404 || (r.Status >= 200 && r.Status < 300) { + return nil + } + return fmt.Errorf("DELETE %s failed (HTTP %d): %s", path, r.Status, strings.TrimSpace(string(r.Body))) +} + +func isNotFound(err error) bool { + if err == nil { + return false + } + var re *api.ResponseError + if errors.As(err, &re) && re.StatusCode == 404 { + return true + } + return strings.Contains(err.Error(), "404") +} diff --git a/internal/vaultcluster/tenantid.go b/internal/vaultcluster/tenantid.go new file mode 100644 index 0000000..c5474e2 --- /dev/null +++ b/internal/vaultcluster/tenantid.go @@ -0,0 +1,58 @@ +package vaultcluster + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +var tenantIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$`) + +var reservedTenantIDs = map[string]struct{}{ + "sys": {}, "auth": {}, "identity": {}, "cubbyhole": {}, "root": {}, + "default": {}, "admin": {}, "data": {}, "metadata": {}, "delete": {}, + "undelete": {}, "destroy": {}, "config": {}, "subkeys": {}, "tenant": {}, + "customers": {}, +} + +func ValidateTenantID(id string) error { + if id == "" { + return fmt.Errorf("tenant ID is empty") + } + for _, r := range id { + if unicode.IsSpace(r) { + return fmt.Errorf("tenant ID contains whitespace") + } + if r > unicode.MaxASCII { + return fmt.Errorf("tenant ID contains non-ASCII characters") + } + } + switch { + case strings.Contains(id, "/"): + return fmt.Errorf("tenant ID contains a path separator") + case strings.Contains(id, ".."): + return fmt.Errorf("tenant ID contains '..'") + case strings.Contains(id, "*"): + return fmt.Errorf("tenant ID contains the ACL wildcard '*'") + case strings.Contains(id, "+"): + return fmt.Errorf("tenant ID contains '+'") + case strings.ContainsAny(id, "{}"): + return fmt.Errorf("tenant ID contains a brace") + case strings.Contains(id, `\`): + return fmt.Errorf("tenant ID contains a backslash") + case strings.ContainsAny(id, `"'`): + return fmt.Errorf("tenant ID contains a quote") + case strings.ContainsAny(id, "$`"): + return fmt.Errorf("tenant ID contains a shell metacharacter") + case strings.Contains(id, "%"): + return fmt.Errorf("tenant ID contains '%%'") + } + if !tenantIDPattern.MatchString(id) { + return fmt.Errorf("tenant ID %q does not match %s", id, tenantIDPattern) + } + if _, ok := reservedTenantIDs[id]; ok { + return fmt.Errorf("tenant ID %q is reserved", id) + } + return nil +} diff --git a/internal/vaultcluster/tenantid_test.go b/internal/vaultcluster/tenantid_test.go new file mode 100644 index 0000000..8b0eba4 --- /dev/null +++ b/internal/vaultcluster/tenantid_test.go @@ -0,0 +1,22 @@ +package vaultcluster + +import "testing" + +func TestValidateTenantID_accepts(t *testing.T) { + for _, id := range []string{"tenant-a", "tenant-b", "acme-corp"} { + if err := ValidateTenantID(id); err != nil { + t.Errorf("%s: %v", id, err) + } + } +} + +func TestValidateTenantID_rejects(t *testing.T) { + for _, id := range []string{ + "a/b", "../etc", "tenant-*", "TENANT", "ab", "sys", "data", + "a b", "a_b", "-x", "x-", "", + } { + if err := ValidateTenantID(id); err == nil { + t.Errorf("%q should have been rejected", id) + } + } +} diff --git a/tests/lint/fixtures/BAD-cross-tenant-wildcard.hcl b/internal/vaultcluster/testdata/lint/BAD-cross-tenant-wildcard.hcl similarity index 100% rename from tests/lint/fixtures/BAD-cross-tenant-wildcard.hcl rename to internal/vaultcluster/testdata/lint/BAD-cross-tenant-wildcard.hcl diff --git a/tests/lint/fixtures/BAD-duplicate-path.hcl b/internal/vaultcluster/testdata/lint/BAD-duplicate-path.hcl similarity index 100% rename from tests/lint/fixtures/BAD-duplicate-path.hcl rename to internal/vaultcluster/testdata/lint/BAD-duplicate-path.hcl diff --git a/tests/lint/fixtures/BAD-empty.hcl b/internal/vaultcluster/testdata/lint/BAD-empty.hcl similarity index 100% rename from tests/lint/fixtures/BAD-empty.hcl rename to internal/vaultcluster/testdata/lint/BAD-empty.hcl diff --git a/tests/lint/fixtures/BAD-kv-v1-path.hcl b/internal/vaultcluster/testdata/lint/BAD-kv-v1-path.hcl similarity index 100% rename from tests/lint/fixtures/BAD-kv-v1-path.hcl rename to internal/vaultcluster/testdata/lint/BAD-kv-v1-path.hcl diff --git a/tests/lint/fixtures/BAD-mixed-deny.hcl b/internal/vaultcluster/testdata/lint/BAD-mixed-deny.hcl similarity index 100% rename from tests/lint/fixtures/BAD-mixed-deny.hcl rename to internal/vaultcluster/testdata/lint/BAD-mixed-deny.hcl diff --git a/tests/lint/fixtures/BAD-sudo-in-tenant-policy.hcl b/internal/vaultcluster/testdata/lint/BAD-sudo-in-tenant-policy.hcl similarity index 100% rename from tests/lint/fixtures/BAD-sudo-in-tenant-policy.hcl rename to internal/vaultcluster/testdata/lint/BAD-sudo-in-tenant-policy.hcl diff --git a/tests/lint/fixtures/BAD-universal-wildcard.hcl b/internal/vaultcluster/testdata/lint/BAD-universal-wildcard.hcl similarity index 100% rename from tests/lint/fixtures/BAD-universal-wildcard.hcl rename to internal/vaultcluster/testdata/lint/BAD-universal-wildcard.hcl diff --git a/tests/lint/fixtures/GOOD-tenant-scoped.hcl b/internal/vaultcluster/testdata/lint/GOOD-tenant-scoped.hcl similarity index 100% rename from tests/lint/fixtures/GOOD-tenant-scoped.hcl rename to internal/vaultcluster/testdata/lint/GOOD-tenant-scoped.hcl diff --git a/local/.env.example b/local/.env.example index 8b8423d..7e402c6 100644 --- a/local/.env.example +++ b/local/.env.example @@ -1,44 +1,14 @@ -# Local Docker adapter configuration. +# Optional overrides for the local Compose stack. # -# Copy to .env and adjust if needed: cp .env.example .env -# .env is gitignored. This file configures a local test environment and uses -# fake values only; never put real credentials here. +# Every variable has a working default baked into compose.yml; no .env is +# required. To override, copy this file: cp .env.example .env +# Compose reads .env automatically. .env is gitignored. This configures a local +# test environment only; never put real credentials here. -# --- Pinned images ----------------------------------------------------------- -# Tag and digest are both pinned. The tag documents intent; the digest is what -# actually gets pulled, so a retagged upstream image cannot silently change the -# environment. Never use `latest`. -VAULT_IMAGE=hashicorp/vault:1.21.4@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569 -POSTGRES_IMAGE=postgres:16.15-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 - -# --- Vault ------------------------------------------------------------------- -# Published to loopback only. Binding to 0.0.0.0 on the host would expose an -# unsealed local Vault to the LAN. +# Host port Vault publishes on. Always bound to loopback in compose.yml. VAULT_HOST_PORT=8200 -VAULT_CLUSTER_NAME=vault-local # Unseal shares generated at init. 5/3 mirrors a realistic quorum so the unseal # flow exercised locally matches the flow used elsewhere. VAULT_INIT_KEY_SHARES=5 VAULT_INIT_KEY_THRESHOLD=3 - -# --- Dynamic database credentials -------------------------------------------- -# Default false. When false, PostgreSQL is never started and only the isolation -# capability is configured. -ENABLE_DYNAMIC_CREDENTIALS=false - -POSTGRES_HOST_PORT=5432 -POSTGRES_DB=appdb -POSTGRES_USER=postgres - -# Local-only bootstrap passwords for a container that listens on loopback and -# holds no real data. Fake values; never leave this machine. Regenerate them if -# you ever expose this environment. -# -# POSTGRES_PASSWORD - the PostgreSQL superuser, used only by init. -# VAULT_DB_ADMIN_PASSWORD - the CREATEROLE (not superuser) role Vault -# authenticates as to issue dynamic credentials. Read -# by both init.sh and the platform database -# configuration, so it lives here once. -POSTGRES_PASSWORD=local-dev-only-not-a-real-secret -VAULT_DB_ADMIN_PASSWORD=local-dev-only-vault-admin-not-a-real-secret diff --git a/local/bootstrap/bootstrap.sh b/local/bootstrap/bootstrap.sh deleted file mode 100755 index 379a8f2..0000000 --- a/local/bootstrap/bootstrap.sh +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bash -# ./bootstrap.sh [--keep-root] -set -euo pipefail - -# shellcheck source=lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" - -KEEP_ROOT=false -while [ $# -gt 0 ]; do - case "$1" in - --keep-root) KEEP_ROOT=true ;; - -h|--help) sed -n '2,2p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $1 (try --help)" ;; - esac - shift -done - -require_cmd curl jq -load_env -ensure_bootstrap_dir - -wait_for_vault 60 -wait_for_unsealed 90 - -# 1. Initialize (Compose sidecar normally already did this) -if vault_is_initialized; then - info "Vault is already initialized (skipping init)" - [ -f "${INIT_FILE}" ] || die \ - "Vault is initialized but ${INIT_FILE} is missing. The unseal shares for - this storage volume are gone, so it cannot be unsealed. Either restore - that file from wherever it was kept, or discard the data with: - ./reset.sh --yes" -else - info "initializing Vault (${VAULT_INIT_KEY_SHARES} shares, threshold ${VAULT_INIT_KEY_THRESHOLD})" - - # umask before the write, not chmod after: chmod leaves a window in which the - # file exists with default permissions, and unseal keys must never be - # world-readable for even an instant. - ( - umask 077 - curl -sf --max-time 30 \ - --request POST \ - --data "$(jq -nc \ - --argjson shares "${VAULT_INIT_KEY_SHARES}" \ - --argjson threshold "${VAULT_INIT_KEY_THRESHOLD}" \ - '{secret_shares: $shares, secret_threshold: $threshold}')" \ - "${VAULT_ADDR}/v1/sys/init" > "${INIT_FILE}" - ) || die "vault init failed" - - # Fail loudly rather than leaving a truncated key file that looks valid. - jq -e '.root_token and ((.keys_base64 // .unseal_keys_b64) | length > 0)' "${INIT_FILE}" >/dev/null 2>&1 \ - || { rm -f "${INIT_FILE}"; die "init response did not contain keys - refusing to continue"; } - - info "initialized. Unseal material written to ${INIT_FILE} (mode 600, not logged)" - warn "BACK THIS FILE UP. Without it this Vault cannot be unsealed." -fi - -# 2. Unseal -unseal_vault - -# Skip only when isolation is up and, if requested, the database engine is too. -# Isolation-then-credentials must not exit here or the DB engine never mounts. -if platform_appears_configured; then - if ! credentials_enabled || database_engine_mounted; then - info "platform already configured - skipping bootstrap configuration" - cat >&2 </dev/null 2>&1 || true - - local otp nonce attempt encoded="" http body - body="$(mktemp "${TMPDIR:-/tmp}/vault-genroot.XXXXXX")" - chmod 600 "${body}" - - # Let Vault mint the OTP. A host-generated OTP is rejected unless it matches - # Vault's exact encoding, and curl -f hid that 400. - http="$(curl -s -o "${body}" -w '%{http_code}' --max-time 10 \ - --request PUT --header 'Content-Type: application/json' --data '{}' \ - "${VAULT_ADDR}/v1/sys/generate-root/attempt")" - attempt="$(cat "${body}")" - [ "${http}" = "200" ] || { rm -f "${body}"; die "generate-root attempt failed (HTTP ${http}): $(printf '%s' "${attempt}" | jq -rc '.errors // .')"; } - nonce="$(printf '%s' "${attempt}" | jq -r '.nonce')" - otp="$(printf '%s' "${attempt}" | jq -r '.otp')" - [ -n "${nonce}" ] && [ "${nonce}" != "null" ] || die "generate-root did not return a nonce" - [ -n "${otp}" ] && [ "${otp}" != "null" ] || die "generate-root did not return an otp" - - local key - while IFS= read -r key; do - http="$(curl -s -o "${body}" -w '%{http_code}' --max-time 10 \ - --request PUT --header 'Content-Type: application/json' \ - --data "$(jq -nc --arg k "${key}" --arg n "${nonce}" '{key: $k, nonce: $n}')" \ - "${VAULT_ADDR}/v1/sys/generate-root/update")" - attempt="$(cat "${body}")" - [ "${http}" = "200" ] || die "generate-root update failed (HTTP ${http}): $(printf '%s' "${attempt}" | jq -rc '.errors // .')" - if [ "$(printf '%s' "${attempt}" | jq -r '.complete')" = "true" ]; then - encoded="$(printf '%s' "${attempt}" | jq -r '.encoded_root_token // .encoded_token')" - break - fi - done < <(jq -r "(.keys_base64 // .unseal_keys_b64)[:${VAULT_INIT_KEY_THRESHOLD}][]" "${INIT_FILE}") - rm -f "${body}" - - [ -n "${encoded}" ] && [ "${encoded}" != "null" ] \ - || die "generate-root did not produce an encoded token" - - # Decode with the same Vault binary that minted the OTP so padding/alphabet - # cannot drift across Python base64 variants. - compose exec -T vault vault operator generate-root \ - -decode="${encoded}" -otp="${otp}" | tr -d '\r\n' -} - -VAULT_TOKEN="$(jq -r '.root_token' "${INIT_FILE}")" -if ! token_is_usable "${VAULT_TOKEN}"; then - VAULT_TOKEN="$(regenerate_root_from_shares)" - token_is_usable "${VAULT_TOKEN}" || die "generated root token is not usable" -fi -export VAULT_TOKEN - -# /vault/logs is a separate volume from /vault/file (Raft storage), so audit -# growth cannot threaten the storage backend. -export AUDIT_LOG_PATH="/vault/logs/audit.log" - -if credentials_enabled; then - # Vault reaches PostgreSQL over the container network by service name; the - # host does not appear anywhere in this address. - export DATABASE_CONNECTION_URL="postgresql://{{username}}:{{password}}@postgres:5432/${POSTGRES_DB:-appdb}?sslmode=disable" - export DATABASE_USERNAME="vault_admin" - export DATABASE_PASSWORD="${VAULT_DB_ADMIN_PASSWORD:?VAULT_DB_ADMIN_PASSWORD must be set in .env}" -fi - -info "applying platform configuration" -"${REPO_ROOT}/scripts/configure-platform.sh" - -# 4. Issue working identities. -# Periodic tokens: renewable indefinitely while in use, dead shortly after they -# stop being renewed. A non-expiring token would be a permanent credential -# sitting on a laptop. -issue_token() { - local policy="$1" outfile="${BOOTSTRAP_DIR}/$2" token - # Orphan: these tokens must outlive the root token. Vault revokes every - # child when the parent is revoked, and step 5 retires root on purpose. - # Without orphan:true, bootstrap writes operator/provisioning tokens that - # are already dead by the time health.sh runs. - token="$(curl -sf --max-time 10 \ - --header "X-Vault-Token: ${VAULT_TOKEN}" \ - --request POST \ - --data "$(jq -nc --arg p "${policy}" \ - '{policies: [$p], period: "24h", renewable: true, display_name: $p, orphan: true, no_parent: true}')" \ - "${VAULT_ADDR}/v1/auth/token/create" | jq -r '.auth.client_token')" - - [ -n "${token}" ] && [ "${token}" != "null" ] || die "failed to issue ${policy} token" - - ( umask 077; printf '%s' "${token}" > "${outfile}" ) - info "issued ${policy} token -> ${outfile} (mode 600)" -} - -issue_token "provisioning" "provisioning.token" -issue_token "operator" "operator.token" - -# 5. Retire the root token. -# The initial root token is bootstrap material, not an operating credential. It -# is revoked here so it cannot drift into daily use or a shell history. It can -# be regenerated from a quorum of unseal shares, which is deliberately an -# auditable and inconvenient act. -if [ "${KEEP_ROOT}" = "true" ]; then - warn "keeping the root token active (--keep-root). Do not use this outside debugging." -else - info "revoking the initial root token" - curl -sf --max-time 10 \ - --header "X-Vault-Token: ${VAULT_TOKEN}" \ - --request POST \ - "${VAULT_ADDR}/v1/auth/token/revoke-self" >/dev/null \ - || warn "root token revocation failed - revoke it manually" - - # The init file still holds the (now revoked) root token string. Blank it so a - # stale value cannot be pasted into a shell later and fail confusingly. The - # unseal shares are retained: they are the recovery path. - ( umask 077 - jq '.root_token = "revoked-at-bootstrap"' "${INIT_FILE}" > "${INIT_FILE}.tmp" \ - && mv "${INIT_FILE}.tmp" "${INIT_FILE}" ) -fi - -cat >&2 <&2; } - -mkdir -p /bootstrap - -# Bind-mount owner on the host. Writing as root (user 0:0) would leave -# vault-init.json mode 600 owned by root, which GitHub Actions cannot jq. -bootstrap_uid=$(stat -c '%u' /bootstrap 2>/dev/null || printf '0') -bootstrap_gid=$(stat -c '%g' /bootstrap 2>/dev/null || printf '0') - -own_bootstrap() { - chown "${bootstrap_uid}:${bootstrap_gid}" /bootstrap "$@" 2>/dev/null || true -} - -chmod 700 /bootstrap 2>/dev/null || true -own_bootstrap - -wait_for_vault() { - n=0 - while [ "${n}" -lt 60 ]; do - ec=0 - vault status >/dev/null 2>&1 || ec=$? - if [ "${ec}" -eq 0 ] || [ "${ec}" -eq 2 ]; then - return 0 - fi - sleep 1 - n=$((n + 1)) - done - return 1 -} - -is_initialized() { - vault status 2>/dev/null | grep 'Initialized' | grep -q 'true' -} - -is_sealed() { - vault status 2>/dev/null | grep 'Sealed' | grep -q 'true' -} - -# Print Shamir shares, one per line. Supports HTTP-init (keys_base64) and -# CLI-init (unseal_keys_b64). Output must never be logged. -print_unseal_keys() { - _buf=$(tr -d '\n' < "$1") - case "${_buf}" in - *'"unseal_keys_b64"'*) _rest=${_buf#*\"unseal_keys_b64\"} ;; - *'"keys_base64"'*) _rest=${_buf#*\"keys_base64\"} ;; - *) return 1 ;; - esac - _rest=${_rest#*[} - _rest=${_rest%%]*} - _oldifs=${IFS} - IFS=, - # shellcheck disable=SC2086 - set -- ${_rest} - IFS=${_oldifs} - for _k in "$@"; do - _k=$(printf '%s' "${_k}" | tr -d ' "') - [ -n "${_k}" ] && printf '%s\n' "${_k}" - done -} - -do_init() { - tmp="${INIT_FILE}.tmp.$$" - umask 077 - if vault operator init \ - -key-shares="${SHARES}" \ - -key-threshold="${THRESHOLD}" \ - -format=json > "${tmp}"; then - mv "${tmp}" "${INIT_FILE}" - chmod 600 "${INIT_FILE}" - own_bootstrap "${INIT_FILE}" - log "initialized persistent Vault; unseal material written (not logged)" - log "BACK UP ${INIT_FILE} on the host. Without it this volume cannot be unsealed." - else - rm -f "${tmp}" - log "init did not run (already initialized, or Vault not ready)" - fi -} - -do_unseal() { - [ -f "${INIT_FILE}" ] || return 1 - n=0 - print_unseal_keys "${INIT_FILE}" | while IFS= read -r key; do - [ -n "${key}" ] || continue - n=$((n + 1)) - if [ "${n}" -le "${THRESHOLD}" ]; then - # Exit 2 while still sealed is expected until threshold shares are in. - vault operator unseal "${key}" >/dev/null || true - fi - done -} - -log "local Shamir unseal sidecar (not production KMS auto-unseal)" -log "VAULT_ADDR=${VAULT_ADDR}" - -while true; do - if ! wait_for_vault; then - log "Vault not responding; retrying" - sleep 5 - continue - fi - - if ! is_initialized; then - log "Vault is uninitialized; running operator init" - do_init - sleep 1 - continue - fi - - if is_sealed; then - if [ ! -f "${INIT_FILE}" ]; then - log "sealed, but unseal material is missing from ${INIT_FILE}" - log "restore the host .bootstrap/vault-init.json, or reset the volume" - sleep 5 - continue - fi - log "Vault is sealed; submitting local Shamir shares" - if do_unseal && ! is_sealed; then - log "unsealed" - else - # Raft can reseal briefly after init while a leader is elected. - sleep 2 - if is_sealed; then - do_unseal || true - fi - if is_sealed; then - log "still sealed; will retry" - else - log "unsealed" - fi - fi - fi - - sleep 5 -done diff --git a/local/bootstrap/health.sh b/local/bootstrap/health.sh deleted file mode 100755 index df5b028..0000000 --- a/local/bootstrap/health.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# Health of the local Vault. Prints a reason on failure. -set -euo pipefail - -# shellcheck source=lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" - -require_cmd curl jq -load_env - -FAILED=0 - -check() { - local label="$1" status="$2" detail="${3:-}" - case "${status}" in - ok) printf ' [ ok ] %-28s %s\n' "${label}" "${detail}" ;; - warn) printf ' [warn] %-28s %s\n' "${label}" "${detail}" ;; - *) printf ' [FAIL] %-28s %s\n' "${label}" "${detail}"; FAILED=1 ;; - esac -} - -printf '\nLocal Vault environment: %s\n\n' "${VAULT_ADDR}" - -# --- Containers --------------------------------------------------------------- -if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - running="$(compose ps --status running --format '{{.Service}}' 2>/dev/null | tr '\n' ' ' | sed 's/ $//')" - if [ -n "${running}" ]; then - check "containers" ok "${running}" - else - check "containers" fail "none running - run ./setup.sh" - fi -else - check "docker" warn "unavailable - checking Vault over HTTP only" -fi - -# --- Vault -------------------------------------------------------------------- -code="$(vault_health_code)" -if [ "${code}" = "000" ]; then - check "vault reachable" fail "no response - run ./setup.sh" - printf '\n' - exit 1 -fi -check "vault reachable" ok "health ${code}" - -if vault_is_initialized; then - check "initialized" ok "" -else - check "initialized" fail "run ./setup.sh" -fi - -if vault_is_sealed; then - check "unsealed" fail "sealed - the compose unseal sidecar should recover; if this persists run ./setup.sh" -else - check "unsealed" ok "" -fi - -# --- Platform configuration ---------------------------------------------------- -# Uses the operator token when present. Absence is a real finding, not a -# reason to skip the check silently. -# -# Probe specific tune/audit paths rather than listing sys/mounts: a 403 or -# invalid token used to look like "kv not found", and `{errors:[...]}` used -# to look like an enabled audit device because it is a non-empty JSON object. -KV_MOUNT="${KV_MOUNT:-kv}" -DATABASE_MOUNT="${DATABASE_MOUNT:-database}" - -vault_probe() { - # Prints HTTP status on stdout; body in the file named by $1. - local body_file="$1" path="$2" - curl -s -o "${body_file}" -w '%{http_code}' --max-time 5 \ - --header "X-Vault-Token: ${tok}" \ - "${VAULT_ADDR}/v1/${path}" 2>/dev/null || printf '000' -} - -if [ -f "${BOOTSTRAP_DIR}/operator.token" ]; then - tok="$(cat "${BOOTSTRAP_DIR}/operator.token")" - probe_body="$(mktemp "${TMPDIR:-/tmp}/vault-health.XXXXXX")" - chmod 600 "${probe_body}" - trap 'rm -f "${probe_body}"' EXIT - - lookup_code="$(vault_probe "${probe_body}" "auth/token/lookup-self")" - if [ "${lookup_code}" != "200" ]; then - check "operator token" fail "invalid or expired (HTTP ${lookup_code}) - run ./setup.sh" - else - check "operator token" ok "" - - kv_code="$(vault_probe "${probe_body}" "sys/mounts/${KV_MOUNT}/tune")" - if [ "${kv_code}" = "200" ] && jq -e '.data.options.version == "2"' "${probe_body}" >/dev/null 2>&1; then - check "kv v2 mounted" ok "${KV_MOUNT}/" - elif [ "${kv_code}" = "404" ]; then - check "kv v2 mounted" fail "not found - run ./setup.sh" - else - check "kv v2 mounted" fail "HTTP ${kv_code} on sys/mounts/${KV_MOUNT}/tune - not a missing mount" - fi - - audit_code="$(vault_probe "${probe_body}" "sys/audit")" - if [ "${audit_code}" = "200" ] && jq -e '((.data // .) | to_entries | map(select(.key != "errors")) | length) > 0' "${probe_body}" >/dev/null 2>&1; then - check "audit device" ok "enabled" - elif [ "${audit_code}" = "403" ]; then - check "audit device" fail "operator cannot read sys/audit" - else - # Vault refuses every request when no audit device can write, so this is a - # precursor to a total outage rather than a cosmetic gap. - check "audit device" fail "none enabled (HTTP ${audit_code})" - fi - - db_code="$(vault_probe "${probe_body}" "sys/mounts/${DATABASE_MOUNT}/tune")" - if [ "${db_code}" = "200" ]; then - check "database engine" ok "${DATABASE_MOUNT}/" - elif credentials_enabled; then - check "database engine" fail "enabled in .env but not mounted (HTTP ${db_code})" - else - check "database engine" ok "disabled (isolation only)" - fi - fi -else - check "operator token" warn "not found - run ./setup.sh for full checks" -fi - -printf '\n' -[ "${FAILED}" -eq 0 ] || { printf 'Environment is NOT healthy.\n\n'; exit 1; } -printf 'Environment is healthy.\n\n' diff --git a/local/bootstrap/lib.sh b/local/bootstrap/lib.sh deleted file mode 100755 index cf3e379..0000000 --- a/local/bootstrap/lib.sh +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for the local Docker target. -# -# Sourced, not executed. Callers set their own `set -euo pipefail`. -# -# Everything Docker-specific in this repository lives under local/. A helper -# needed inside config/, scripts/, or tests/ is a signal the logic belongs -# there instead, or should be expressed against VAULT_ADDR rather than against -# a container. - -# shellcheck shell=bash - -ADAPTER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -REPO_ROOT="$(cd -- "${ADAPTER_DIR}/.." && pwd)" -COMPOSE_FILE="${ADAPTER_DIR}/docker-compose.yml" -ENV_FILE="${ADAPTER_DIR}/.env" -ENV_EXAMPLE="${ADAPTER_DIR}/.env.example" -BOOTSTRAP_DIR="${ADAPTER_DIR}/.bootstrap" -INIT_FILE="${BOOTSTRAP_DIR}/vault-init.json" - -export ADAPTER_DIR REPO_ROOT COMPOSE_FILE ENV_FILE BOOTSTRAP_DIR INIT_FILE - -# --- Output ------------------------------------------------------------------ -# Diagnostics go to stderr so a script's stdout stays machine-readable. - -_ts() { date -u '+%H:%M:%S'; } - -info() { printf '[%s] %s\n' "$(_ts)" "$*" >&2; } -warn() { printf '[%s] WARN: %s\n' "$(_ts)" "$*" >&2; } -die() { printf '[%s] ERROR: %s\n' "$(_ts)" "$*" >&2; exit 1; } - -# --- Preconditions ----------------------------------------------------------- - -require_cmd() { - local missing=0 c - for c in "$@"; do - command -v "$c" >/dev/null 2>&1 || { warn "required command not found: $c"; missing=1; } - done - [ "$missing" -eq 0 ] || die "install the missing dependencies listed above, then retry" -} - -require_docker() { - require_cmd docker - docker compose version >/dev/null 2>&1 \ - || die "Docker Compose v2 is required (got: $(docker --version 2>/dev/null || echo 'no docker'))" - docker info >/dev/null 2>&1 \ - || die "the Docker daemon is not running - start Docker Desktop and retry" -} - -# --- Configuration ----------------------------------------------------------- - -load_env() { - if [ ! -f "${ENV_FILE}" ]; then - info "no .env found, creating one from .env.example" - cp "${ENV_EXAMPLE}" "${ENV_FILE}" - fi - - # ./setup.sh --with-credentials sets this before load_env; .env defaults to false. - local creds_from_cli="${ENABLE_DYNAMIC_CREDENTIALS:-}" - - set -a - # shellcheck disable=SC1090 - . "${ENV_FILE}" - set +a - - if [ "${creds_from_cli}" = "true" ]; then - ENABLE_DYNAMIC_CREDENTIALS=true - fi - - : "${VAULT_HOST_PORT:=8200}" - : "${ENABLE_DYNAMIC_CREDENTIALS:=false}" - : "${VAULT_INIT_KEY_SHARES:=5}" - : "${VAULT_INIT_KEY_THRESHOLD:=3}" - - VAULT_ADDR="http://127.0.0.1:${VAULT_HOST_PORT}" - export VAULT_ADDR ENABLE_DYNAMIC_CREDENTIALS -} - -# True when dynamic database credentials are enabled. -credentials_enabled() { - [ "${ENABLE_DYNAMIC_CREDENTIALS:-false}" = "true" ] -} - -# Compose profiles select which services exist at all. With credentials -# disabled, Vault runs alone; PostgreSQL is not started, not just unused. -compose() { - # Bash 3.2 (macOS /bin/bash) treats an empty "${array[@]}" as unbound under - # `set -u`, which is why up.sh died on `compose pull` with credentials off. - if credentials_enabled; then - docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" --profile credentials "$@" - else - docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" "$@" - fi -} - -# --- Vault reachability ------------------------------------------------------- -# Uses the HTTP API rather than the Vault CLI, so the CLI is not a prerequisite -# for any target or platform script. - -# Health status codes carry meaning: 200 unsealed/active, 501 uninitialized, -# 503 sealed. All three mean "Vault is answering", which is what waiting is for. -vault_health_code() { - curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ - "${VAULT_ADDR}/v1/sys/health?standbyok=true" 2>/dev/null || echo "000" -} - -wait_for_vault() { - local timeout="${1:-60}" waited=0 code - info "waiting for Vault at ${VAULT_ADDR} (timeout ${timeout}s)" - while [ "${waited}" -lt "${timeout}" ]; do - code="$(vault_health_code)" - case "${code}" in - 200|429|472|473|501|503) - info "Vault is responding (health ${code})" - return 0 - ;; - esac - sleep 1 - waited=$((waited + 1)) - done - die "Vault did not respond within ${timeout}s (last health code: ${code:-none})" -} - -# The Compose unseal sidecar owns init+unseal. Host scripts wait for it so -# they do not race operator init. -wait_for_unsealed() { - local timeout="${1:-90}" waited=0 - info "waiting for Vault to be initialized and unsealed (timeout ${timeout}s)" - while [ "${waited}" -lt "${timeout}" ]; do - if vault_is_initialized && ! vault_is_sealed; then - info "Vault is initialized and unsealed" - return 0 - fi - sleep 1 - waited=$((waited + 1)) - done - die "Vault was not unsealed within ${timeout}s. Is the compose unseal sidecar running?" -} - -vault_is_initialized() { - [ "$(curl -s --max-time 3 "${VAULT_ADDR}/v1/sys/init" | jq -r '.initialized // false')" = "true" ] -} - -vault_is_sealed() { - # Do not use `// true`: jq's // also replaces JSON false, so an unsealed - # Vault would still look sealed. - [ "$(curl -s --max-time 3 "${VAULT_ADDR}/v1/sys/seal-status" | jq -r '.sealed')" = "true" ] -} - -# --- Bootstrap material ------------------------------------------------------- -# Unseal shares and the initial root token. Restrictive permissions are applied -# to the directory as well as the files, because a readable directory leaks -# filenames and an over-permissive one lets a later write land unprotected. - -ensure_bootstrap_dir() { - mkdir -p "${BOOTSTRAP_DIR}" - chmod 700 "${BOOTSTRAP_DIR}" -} - -read_bootstrap_token() { - local name="$1" - local file="${BOOTSTRAP_DIR}/${name}" - [ -f "${file}" ] || die "missing ${file} - run ./setup.sh first" - cat "${file}" -} - -# True when a token string is accepted by lookup-self. Never prints the token. -token_is_usable() { - local t="${1:-}" - [ -n "${t}" ] && [ "${t}" != "null" ] && [ "${t}" != "revoked-at-bootstrap" ] || return 1 - [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ - --header "X-Vault-Token: ${t}" \ - "${VAULT_ADDR}/v1/auth/token/lookup-self" 2>/dev/null || echo 000)" = "200" ] -} - -operator_token_usable() { - [ -f "${BOOTSTRAP_DIR}/operator.token" ] || return 1 - token_is_usable "$(cat "${BOOTSTRAP_DIR}/operator.token")" -} - -provisioning_token_usable() { - [ -f "${BOOTSTRAP_DIR}/provisioning.token" ] || return 1 - token_is_usable "$(cat "${BOOTSTRAP_DIR}/provisioning.token")" -} - -# Operator can read mount tune; success means the platform has been configured. -platform_appears_configured() { - operator_token_usable || return 1 - local tok code - tok="$(cat "${BOOTSTRAP_DIR}/operator.token")" - code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ - --header "X-Vault-Token: ${tok}" \ - "${VAULT_ADDR}/v1/sys/mounts/${KV_MOUNT:-kv}/tune" 2>/dev/null || echo 000)" - [ "${code}" = "200" ] -} - -database_engine_mounted() { - operator_token_usable || return 1 - local tok code - tok="$(cat "${BOOTSTRAP_DIR}/operator.token")" - code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ - --header "X-Vault-Token: ${tok}" \ - "${VAULT_ADDR}/v1/sys/mounts/${DATABASE_MOUNT:-database}/tune" 2>/dev/null || echo 000)" - [ "${code}" = "200" ] -} - -# Submit Shamir shares from INIT_FILE. Local development automation only — -# this is not production KMS auto-unseal. Shares are never printed. -unseal_vault() { - [ -f "${INIT_FILE}" ] || die \ - "cannot unseal: ${INIT_FILE} is missing. Restore it, or discard the - volume with ./reset.sh --yes" - - if ! vault_is_sealed; then - info "Vault is already unsealed" - return 0 - fi - - info "unsealing with local Shamir shares (not production KMS auto-unseal)" - while IFS= read -r key; do - [ -n "${key}" ] || continue - curl -sf --max-time 10 --request POST \ - --data "$(jq -nc --arg k "${key}" '{key: $k}')" \ - "${VAULT_ADDR}/v1/sys/unseal" >/dev/null || die "unseal step failed" - vault_is_sealed || break - done < <(jq -r "(.keys_base64 // .unseal_keys_b64)[:${VAULT_INIT_KEY_THRESHOLD}][]" "${INIT_FILE}") - - # Raft reseals briefly after init while it elects a leader. - local waited=0 - while vault_is_sealed && [ "${waited}" -lt 20 ]; do - sleep 1 - waited=$((waited + 1)) - done - - vault_is_sealed && die "Vault is still sealed after applying local unseal shares" - info "unsealed" -} diff --git a/local/bootstrap/snapshot.sh b/local/bootstrap/snapshot.sh deleted file mode 100755 index 024f1e5..0000000 --- a/local/bootstrap/snapshot.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# ./snapshot.sh take|list|verify |restore --yes -set -euo pipefail - -# shellcheck source=lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" - -require_cmd curl jq shasum -load_env - -BACKUP_DIR="${BOOTSTRAP_DIR}/backups" - -operator_token() { - read_bootstrap_token "operator.token" -} - -cmd_take() { - ensure_bootstrap_dir - mkdir -p "${BACKUP_DIR}"; chmod 700 "${BACKUP_DIR}" - - vault_is_sealed && die "Vault is sealed - unseal it before taking a snapshot" - - local stamp file - stamp="$(date -u '+%Y%m%dT%H%M%SZ')" - file="${BACKUP_DIR}/vault-${stamp}.snap" - - info "taking Raft snapshot" - # A snapshot contains every secret in the cluster. It is created under a - # restrictive umask for the same reason the unseal keys are. - ( - umask 077 - curl -sf --max-time 120 \ - --header "X-Vault-Token: $(operator_token)" \ - "${VAULT_ADDR}/v1/sys/storage/raft/snapshot" -o "${file}" - ) || die "snapshot failed" - - [ -s "${file}" ] || { rm -f "${file}"; die "snapshot file is empty - refusing to keep it"; } - - # Written at creation time, so corruption in storage or transfer is - # detectable later rather than at restore time. - shasum -a 256 "${file}" | awk '{print $1}' > "${file}.sha256" - chmod 600 "${file}.sha256" - - info "snapshot written: ${file}" - info " size $(wc -c < "${file}" | tr -d ' ') bytes" - info " sha256 $(cat "${file}.sha256")" - warn "this file contains EVERY secret in the cluster - treat it as one" -} - -cmd_list() { - [ -d "${BACKUP_DIR}" ] || { info "no snapshots under ${BACKUP_DIR}"; return 0; } - printf '\nSnapshots in %s:\n\n' "${BACKUP_DIR}" - ls -1t "${BACKUP_DIR}"/*.snap 2>/dev/null | while IFS= read -r f; do - printf ' %-44s %10s bytes\n' "$(basename "${f}")" "$(wc -c < "${f}" | tr -d ' ')" - done - printf '\n' -} - -cmd_verify() { - local file="${1:?usage: snapshot.sh verify }" - [ -f "${file}" ] || die "no such snapshot: ${file}" - [ -f "${file}.sha256" ] || die "no checksum beside ${file} - integrity cannot be established" - - local expected actual - expected="$(cat "${file}.sha256")" - actual="$(shasum -a 256 "${file}" | awk '{print $1}')" - - [ "${expected}" = "${actual}" ] || die "CHECKSUM MISMATCH - this snapshot is corrupt and must not be restored - expected ${expected} - actual ${actual}" - - info "checksum OK: ${file}" - - # Integrity is not usability. Confirming the file is a real snapshot needs a - # restore into a throwaway cluster, which is a documented drill rather than - # something this script does implicitly. - warn "checksum only proves the file is unchanged, not that it restores." - warn "See runbooks/backup-restore.md for the restore drill." -} - -cmd_restore() { - local file="${1:-}" confirmed="${2:-}" - [ -n "${file}" ] || die "usage: snapshot.sh restore --yes" - [ -f "${file}" ] || die "no such snapshot: ${file}" - - if [ "${confirmed}" != "--yes" ]; then - cat >&2 </dev/null \ - || die "restore failed" - - info "restore submitted; Vault will seal" - info "unseal with the key shares that were current when this snapshot was taken:" - info " ../setup.sh" -} - -case "${1:-}" in - take) cmd_take ;; - list) cmd_list ;; - verify) shift; cmd_verify "${1:-}" ;; - restore) shift; cmd_restore "${1:-}" "${2:-}" ;; - -h|--help|"") sed -n '2,2p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown subcommand: $1 (take, list, verify, restore)" ;; -esac diff --git a/local/bootstrap/up.sh b/local/bootstrap/up.sh deleted file mode 100755 index c171f50..0000000 --- a/local/bootstrap/up.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# ./up.sh [--with-credentials] -set -euo pipefail - -# shellcheck source=lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" - -WITH_CREDENTIALS=false -while [ $# -gt 0 ]; do - case "$1" in - --with-credentials) WITH_CREDENTIALS=true ;; - -h|--help) sed -n '2,2p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $1 (try --help)" ;; - esac - shift -done - -require_docker -require_cmd curl jq -load_env - -# A command-line flag is a per-run override of the .env default, so enabling -# dynamic credentials for one run does not silently become the new default. -if [ "${WITH_CREDENTIALS}" = "true" ]; then - ENABLE_DYNAMIC_CREDENTIALS=true - export ENABLE_DYNAMIC_CREDENTIALS -fi - -if credentials_enabled; then - info "capabilities: isolation + dynamic database credentials" -else - info "capability: isolation only - PostgreSQL will not be started" -fi - -info "pulling pinned images (digest-pinned, so this is a no-op once cached)" -compose pull --quiet 2>/dev/null || warn "pull failed or offline - using locally cached images" - -# Bind-mount target must exist before Compose creates it as root. -ensure_bootstrap_dir - -info "starting services (Vault + Shamir unseal sidecar)" -compose up -d --wait - -wait_for_vault 60 -wait_for_unsealed 90 - -info "VAULT_ADDR=${VAULT_ADDR}" diff --git a/local/compose.yml b/local/compose.yml new file mode 100644 index 0000000..b0b2e23 --- /dev/null +++ b/local/compose.yml @@ -0,0 +1,60 @@ +name: vault-cluster + +services: + vault: + image: hashicorp/vault:2.0@sha256:5be49781ecf78bfe775c5309c6a4d9f4e9e040b6c885c99eb2b12fb69855e1a2 + restart: unless-stopped + command: ["vault", "server", "-config=/vault/config/config.hcl"] + cap_add: + - IPC_LOCK + environment: + SKIP_CHOWN: "true" + SKIP_SETCAP: "true" + ports: + # Loopback only. Binding 0.0.0.0 would expose an unsealed plaintext Vault + # to the LAN. + - "127.0.0.1:${VAULT_HOST_PORT:-8200}:8200" + volumes: + - ./vault/config.hcl:/vault/config/config.hcl:ro + - vault-data:/vault/file + - vault-audit:/vault/logs + healthcheck: + # Sealed and uninitialized still count as healthy so the one-shot + # bootstrap can start and perform init/unseal. + test: >- + wget --quiet --tries=1 --spider + 'http://127.0.0.1:8200/v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200' + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + networks: + - vault-cluster + + bootstrap: + build: + context: .. + dockerfile: Dockerfile + restart: "no" + user: "0:0" + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: "http://vault:8200" + BOOTSTRAP_DIR: /bootstrap + AUDIT_LOG_PATH: /vault/logs/audit.log + VAULT_INIT_KEY_SHARES: "${VAULT_INIT_KEY_SHARES:-5}" + VAULT_INIT_KEY_THRESHOLD: "${VAULT_INIT_KEY_THRESHOLD:-3}" + volumes: + - "${BOOTSTRAP_HOST_DIR:-./.bootstrap}:/bootstrap" + command: ["bootstrap", "local"] + networks: + - vault-cluster + +volumes: + vault-data: + vault-audit: + +networks: + vault-cluster: diff --git a/local/docker-compose.yml b/local/docker-compose.yml deleted file mode 100644 index 512c15b..0000000 --- a/local/docker-compose.yml +++ /dev/null @@ -1,145 +0,0 @@ -# Local Docker target. No Vault -dev. Host bind is 127.0.0.1 only. - -name: vault-cluster - -services: - vault: - image: ${VAULT_IMAGE:?VAULT_IMAGE must be set - copy .env.example to .env} - container_name: vault-cluster-vault - restart: unless-stopped - - command: ["vault", "server", "-config=/vault/config/config.hcl"] - - # IPC_LOCK lets Vault mlock its memory so key material cannot be paged to - # disk. Preferred over disable_mlock. - cap_add: - - IPC_LOCK - - environment: - VAULT_ADDR: "http://127.0.0.1:8200" - VAULT_CLUSTER_NAME: "${VAULT_CLUSTER_NAME:-vault-local}" - VAULT_LOG_LEVEL: "info" - VAULT_LOG_FORMAT: "json" - SKIP_CHOWN: "true" - SKIP_SETCAP: "true" - - # Loopback only. Publishing on 0.0.0.0 would expose an unsealed local Vault - # to the network. - ports: - - "127.0.0.1:${VAULT_HOST_PORT:-8200}:8200" - - volumes: - - ./vault/config.hcl:/vault/config/config.hcl:ro - # Raft storage and audit logs live on separate volumes. Audit devices - # write on every request, and Vault fails closed when the audit device - # cannot write, so unbounded audit growth on the storage volume would - # turn a disk-space problem into a full outage plus possible storage - # corruption. - - vault-data:/vault/file - - vault-audit:/vault/logs - - # Healthy means "responding", not "unsealed". A freshly started Vault is - # sealed and uninitialized by design, which is a valid pre-bootstrap state. - healthcheck: - test: - - CMD - - wget - - --quiet - - --tries=1 - - --spider - - http://127.0.0.1:8200/v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200 - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - - networks: - - vault-cluster - - # Local Shamir init + unseal. First start initializes a persistent Vault; - # every later start and every `restart vault` unseals it. This is laptop DX, - # not production KMS auto-unseal. Keys are bind-mounted from host - # `.bootstrap/` (gitignored, mode 700/600) and are never logged. - unseal: - image: ${VAULT_IMAGE:?VAULT_IMAGE must be set - copy .env.example to .env} - container_name: vault-cluster-unseal - restart: unless-stopped - # Root only so the sidecar can write host `.bootstrap/` regardless of the - # container uid. Local development only; do not copy this to a shared env. - user: "0:0" - depends_on: - vault: - condition: service_healthy - environment: - VAULT_ADDR: "http://vault:8200" - INIT_FILE: /bootstrap/vault-init.json - VAULT_INIT_KEY_SHARES: "${VAULT_INIT_KEY_SHARES:-5}" - VAULT_INIT_KEY_THRESHOLD: "${VAULT_INIT_KEY_THRESHOLD:-3}" - volumes: - - ./.bootstrap:/bootstrap - - ./bootstrap/compose-unseal.sh:/usr/local/bin/compose-unseal.sh:ro - entrypoint: ["/bin/sh", "/usr/local/bin/compose-unseal.sh"] - healthcheck: - # vault status exits 0 only when unsealed. compose up --wait uses this - # so a first-time start blocks until init+unseal have finished. - test: ["CMD-SHELL", "VAULT_ADDR=http://vault:8200 /bin/vault status"] - interval: 5s - timeout: 5s - retries: 24 - start_period: 20s - networks: - - vault-cluster - - # Only required for dynamic database credentials. Behind a Compose profile, - # so `docker compose up` starts Vault alone and the isolation capability has - # no database dependency. - # - # Start with: docker compose --profile credentials up -d - postgres: - image: ${POSTGRES_IMAGE:?POSTGRES_IMAGE must be set - copy .env.example to .env} - container_name: vault-cluster-postgres - restart: unless-stopped - profiles: ["credentials"] - - environment: - POSTGRES_DB: "${POSTGRES_DB:-appdb}" - POSTGRES_USER: "${POSTGRES_USER:-postgres}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}" - # Consumed by init.sh to create the role Vault authenticates as. Platform - # config reads the same variable, keeping a single source of truth. - VAULT_DB_ADMIN_PASSWORD: "${VAULT_DB_ADMIN_PASSWORD:?VAULT_DB_ADMIN_PASSWORD must be set}" - # Deterministic locale so credential and grant assertions do not vary by - # host configuration. - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" - - ports: - - "127.0.0.1:${POSTGRES_HOST_PORT:-5432}:5432" - - volumes: - - postgres-data:/var/lib/postgresql/data - # Runs once, on an empty data directory only. - - ./postgres/init.sh:/docker-entrypoint-initdb.d/10-init.sh:ro - - healthcheck: - test: ["CMD-SHELL", "pg_isready -U \"${POSTGRES_USER:-postgres}\" -d \"${POSTGRES_DB:-appdb}\""] - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - - networks: - - vault-cluster - -volumes: - # Named volumes, not bind mounts: runtime data must never land in the working - # tree where a careless `git add -A` could reach it. - vault-data: - name: vault-cluster-vault-data - vault-audit: - name: vault-cluster-vault-audit - postgres-data: - name: vault-cluster-postgres-data - -networks: - vault-cluster: - name: vault-cluster-network diff --git a/local/localcompose_test.go b/local/localcompose_test.go new file mode 100644 index 0000000..f01f70f --- /dev/null +++ b/local/localcompose_test.go @@ -0,0 +1,249 @@ +package local + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/nullstone-modules/vault-cluster/internal/vaultcluster" +) + +const localComposeFile = "compose.yml" + +func requireDocker(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("skipping docker") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("docker daemon is not running") + } +} + +func dockerOutput(t *testing.T, args ...string) string { + t.Helper() + out, err := exec.Command("docker", args...).Output() + if err != nil { + stderr := []byte(nil) + if ee, ok := err.(*exec.ExitError); ok { + stderr = ee.Stderr + } + t.Fatalf("docker %v: %v\n%s\n%s", args, err, out, stderr) + } + return strings.TrimSpace(string(out)) +} + +// TestLocalComposeStatic lints compose.yml without Docker: images are +// digest-pinned and never latest, no Vault dev mode, and every published port +// binds to loopback only. +func TestLocalComposeStatic(t *testing.T) { + b, err := os.ReadFile(localComposeFile) + if err != nil { + t.Fatal(err) + } + src := string(b) + + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "image:") { + if !strings.Contains(trimmed, "@sha256:") { + t.Errorf("image is not digest-pinned: %s", trimmed) + } + if strings.Contains(trimmed, ":latest") { + t.Errorf("image uses the latest tag: %s", trimmed) + } + } + } + + if strings.Contains(src, "VAULT_DEV_") || regexp.MustCompile(`["'\s]-dev["'\s,\]]`).MatchString(src) { + t.Error("dev-mode configuration found in compose.yml") + } + + // Port publications look like - "host:container"; volume mounts contain a + // path separator in the host part. + portRe := regexp.MustCompile(`-\s+"([^"/]+):\d+"`) + found := false + for _, m := range portRe.FindAllStringSubmatch(src, -1) { + found = true + if !strings.HasPrefix(m[1], "127.0.0.1:") { + t.Errorf("published port is not bound to loopback: %s", m[0]) + } + } + if !found { + t.Error("no published ports found; the loopback check matched nothing") + } +} + +// TestLocalComposeRuntime runs the real local stack in an isolated Compose +// project: health-gated one-shot bootstrap, Shamir over Raft, separated audit +// and Raft volumes, loopback-only exposure, and persistence across a restart +// (Vault reseals, the one-shot unseals, data and tokens survive). +func TestLocalComposeRuntime(t *testing.T) { + requireDocker(t) + composeFile, err := filepath.Abs(localComposeFile) + if err != nil { + t.Fatal(err) + } + + project := fmt.Sprintf("vault-rt-%d", time.Now().UnixNano()) + bootstrapDir := t.TempDir() + env := append(os.Environ(), + "BOOTSTRAP_HOST_DIR="+bootstrapDir, + "VAULT_HOST_PORT=0", // ephemeral host port; never collides with a dev stack + ) + compose := func(args ...string) *exec.Cmd { + cmd := exec.Command("docker", append([]string{"compose", "-p", project, "-f", composeFile}, args...)...) + cmd.Env = env + return cmd + } + composeOut := func(args ...string) string { + t.Helper() + out, err := compose(args...).Output() + if err != nil { + stderr := []byte(nil) + if ee, ok := err.(*exec.ExitError); ok { + stderr = ee.Stderr + } + t.Fatalf("docker compose %v: %v\n%s\n%s", args, err, out, stderr) + } + return strings.TrimSpace(string(out)) + } + t.Cleanup(func() { + if t.Failed() { + logs, _ := compose("logs").CombinedOutput() + t.Logf("compose logs:\n%s", logs) + } + _ = compose("down", "--volumes", "--remove-orphans", "--timeout", "5").Run() + }) + + composeOut("up", "-d", "--wait", "vault") + composeOut("run", "--build", "--rm", "--no-deps", "bootstrap") + + running := composeOut("ps", "--status", "running", "--format", "{{.Service}}") + if running != "vault" { + t.Fatalf("expected only vault to stay running (bootstrap is one-shot), got: %q", running) + } + + addr := "http://" + composeOut("port", "vault", "8200") + c, err := vaultcluster.New(vaultcluster.Config{Addr: addr, HTTPTimeout: 10 * time.Second}) + if err != nil { + t.Fatal(err) + } + + if res, err := c.Do("GET", "sys/health?standbyok=true", nil); err != nil || res.Status != 200 { + t.Fatalf("expected health 200 (initialized, unsealed, active): status=%d err=%v", res.Status, err) + } + st, err := c.API.Sys().SealStatus() + if err != nil { + t.Fatal(err) + } + if st.Type != "shamir" { + t.Errorf("seal type = %q, want shamir (real unseal flow must be exercised)", st.Type) + } + if st.StorageType != "raft" { + t.Errorf("storage type = %q, want raft", st.StorageType) + } + + cid := composeOut("ps", "-q", "vault") + mountName := func(dest string) string { + return dockerOutput(t, "inspect", "--format", + `{{range .Mounts}}{{if eq .Destination "`+dest+`"}}{{.Name}}{{end}}{{end}}`, cid) + } + raftVol, auditVol := mountName("/vault/file"), mountName("/vault/logs") + if raftVol == "" || auditVol == "" { + t.Errorf("raft and audit volumes must both be mounted: raft=%q audit=%q", raftVol, auditVol) + } else if raftVol == auditVol { + t.Errorf("audit shares volume %q with raft; audit growth can fill or corrupt Raft storage", raftVol) + } + dockerOutput(t, "exec", cid, "sh", "-c", "test -s /vault/logs/audit.log && test -e /vault/file/vault.db") + + hostIPs := dockerOutput(t, "inspect", "--format", + `{{range $p, $conf := .NetworkSettings.Ports}}{{range $conf}}{{.HostIp}} {{end}}{{end}}`, cid) + if strings.Contains(hostIPs, "0.0.0.0") || !strings.Contains(hostIPs, "127.0.0.1") { + t.Errorf("Vault port must be published to 127.0.0.1 only, got bindings: %q", hostIPs) + } + + // Persistence probe: onboard a tenant, write as its AppRole writer, + // restart Vault (reseals under Shamir), unseal via the one-shot, and + // verify the secret and the pre-restart token both survived. + tokenBytes, err := os.ReadFile(filepath.Join(bootstrapDir, "provisioning.token")) + if err != nil { + t.Fatalf("bootstrap did not write provisioning.token: %v", err) + } + provToken := strings.TrimSpace(string(tokenBytes)) + composeOut("run", "--rm", "--no-deps", "-e", "VAULT_TOKEN="+provToken, + "bootstrap", "tenants", "create", "tenant-a") + + prov := c.WithToken(provToken) + roleResp, err := prov.API.Logical().Read("auth/approle/role/tenant-tenant-a-writer/role-id") + if err != nil || roleResp == nil { + t.Fatalf("reading writer role-id: %v", err) + } + secResp, err := prov.API.Logical().Write("auth/approle/role/tenant-tenant-a-writer/secret-id", nil) + if err != nil || secResp == nil { + t.Fatalf("issuing writer secret-id: %v", err) + } + loginResp, err := c.API.Logical().Write("auth/approle/login", map[string]any{ + "role_id": roleResp.Data["role_id"], + "secret_id": secResp.Data["secret_id"], + }) + if err != nil || loginResp == nil || loginResp.Auth == nil { + t.Fatalf("AppRole login as tenant writer: %v", err) + } + writer := c.WithToken(loginResp.Auth.ClientToken) + + probePath := "kv/data/customers/tenant-a/runtime-persistence-probe" + probeValue := fmt.Sprintf("FAKE-persistence-probe-%d", time.Now().UnixNano()) + if _, err := writer.API.Logical().Write(probePath, map[string]any{ + "data": map[string]any{"probe": probeValue}, + }); err != nil { + t.Fatalf("writing probe secret: %v", err) + } + + composeOut("restart", "vault") + // The ephemeral host port changes on restart; re-resolve and rebuild clients. + addr = "http://" + composeOut("port", "vault", "8200") + c, err = vaultcluster.New(vaultcluster.Config{Addr: addr, HTTPTimeout: 10 * time.Second}) + if err != nil { + t.Fatal(err) + } + writer = c.WithToken(loginResp.Auth.ClientToken) + + sealedAfterRestart := false + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + if st, err := c.API.Sys().SealStatus(); err == nil && st != nil { + sealedAfterRestart = st.Sealed + break + } + time.Sleep(500 * time.Millisecond) + } + if !sealedAfterRestart { + t.Error("Vault should reseal after a process restart (expected for Shamir)") + } + + composeOut("run", "--rm", "--no-deps", "bootstrap") + if st, err := c.API.Sys().SealStatus(); err != nil || st.Sealed { + t.Fatalf("one-shot bootstrap should leave Vault unsealed: sealed=%v err=%v", st != nil && st.Sealed, err) + } + + read, err := writer.API.Logical().Read(probePath) + if err != nil || read == nil { + t.Fatalf("reading probe secret after restart: %v", err) + } + data, _ := read.Data["data"].(map[string]any) + if got, _ := data["probe"].(string); got != probeValue { + t.Errorf("probe secret did not survive the restart: got %q, want %q", got, probeValue) + } + if _, err := writer.API.Auth().Token().LookupSelf(); err != nil { + t.Errorf("pre-restart writer token no longer valid (token storage did not persist): %v", err) + } +} diff --git a/local/postgres/init.sh b/local/postgres/init.sh deleted file mode 100755 index c486028..0000000 --- a/local/postgres/init.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/sh -# Init schema and vault_admin (CREATEROLE). Password from the environment. -set -eu - -: "${POSTGRES_USER:?POSTGRES_USER must be set}" -: "${POSTGRES_DB:?POSTGRES_DB must be set}" -: "${VAULT_DB_ADMIN_PASSWORD:?VAULT_DB_ADMIN_PASSWORD must be set}" - -psql -v ON_ERROR_STOP=1 \ - -v vault_admin_password="${VAULT_DB_ADMIN_PASSWORD}" \ - -v dbname="${POSTGRES_DB}" \ - --username "${POSTGRES_USER}" \ - --dbname "${POSTGRES_DB}" <<'EOSQL' - --- Demo application schema. Fake data only. This exists so dynamic credential --- least-privilege can be verified against real objects: a readonly credential --- must be able to SELECT and must fail to INSERT. Asserting privileges against --- an empty schema proves nothing. -CREATE SCHEMA IF NOT EXISTS app; - -CREATE TABLE IF NOT EXISTS app.customers ( - id serial PRIMARY KEY, - name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS app.orders ( - id serial PRIMARY KEY, - customer_id integer NOT NULL REFERENCES app.customers(id), - amount_cents integer NOT NULL CHECK (amount_cents >= 0) -); - -INSERT INTO app.customers (name) -SELECT v FROM (VALUES ('Fake Customer One'), ('Fake Customer Two')) AS t(v) -WHERE NOT EXISTS (SELECT 1 FROM app.customers); - --- Privilege roles. NOLOGIN group roles. Vault's dynamic users are granted one --- of these, so the privilege definition lives in PostgreSQL and stays --- authoritative: Vault decides who gets access and for how long, PostgreSQL --- decides what that access is. -DO $$ BEGIN - CREATE ROLE app_readonly NOLOGIN; -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN - CREATE ROLE app_readwrite NOLOGIN; -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -GRANT USAGE ON SCHEMA app TO app_readonly, app_readwrite; - -GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_readonly; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_readwrite; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_readwrite; - --- Tables created later inherit the same grants, so a new table cannot silently --- become invisible to readers or unwritable by writers. -ALTER DEFAULT PRIVILEGES IN SCHEMA app - GRANT SELECT ON TABLES TO app_readonly; -ALTER DEFAULT PRIVILEGES IN SCHEMA app - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_readwrite; - --- Keep the public schema closed. On PostgreSQL 15+ this is already the --- default; stating it means a restored older dump cannot quietly reopen it. -REVOKE CREATE ON SCHEMA public FROM PUBLIC; - --- Vault's management role: CREATEROLE, not SUPERUSER. Vault needs to create, --- grant, and drop the temporary users it issues - nothing more. A superuser --- here would mean a compromised Vault database config is a compromised --- database, and would let dynamic credentials escalate past the privilege --- roles above. -DO $$ BEGIN - CREATE ROLE vault_admin WITH LOGIN CREATEROLE; -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -ALTER ROLE vault_admin WITH PASSWORD :'vault_admin_password'; - --- vault_admin must hold these roles WITH ADMIN OPTION to grant them onward to --- the users it creates. Without ADMIN OPTION, issuance fails at the GRANT step --- with a permission error that looks like a Vault problem but is not one. -GRANT app_readonly TO vault_admin WITH ADMIN OPTION; -GRANT app_readwrite TO vault_admin WITH ADMIN OPTION; - -GRANT CONNECT ON DATABASE :"dbname" TO app_readonly, app_readwrite; - -EOSQL - -echo "[init] PostgreSQL initialized: app schema, privilege roles, vault_admin" diff --git a/local/reset.sh b/local/reset.sh deleted file mode 100755 index 24deb23..0000000 --- a/local/reset.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# ./reset.sh --yes — destroy volumes and unseal keys. -set -euo pipefail - -# shellcheck source=bootstrap/lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/bootstrap/lib.sh" - -CONFIRMED=false -KEEP_BOOTSTRAP=false -while [ $# -gt 0 ]; do - case "$1" in - --yes) CONFIRMED=true ;; - --keep-bootstrap) KEEP_BOOTSTRAP=true ;; - -h|--help) sed -n '2,2p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $1 (try --help)" ;; - esac - shift -done - -if [ "${CONFIRMED}" != "true" ]; then - cat >&2 <<'EOF' -Refusing to run without explicit confirmation. - -This DESTROYS all local Vault data, audit logs, PostgreSQL data, and the -unseal keys. It cannot be undone. - -Re-run with: ./reset.sh --yes -EOF - exit 1 -fi - -require_docker -load_env - -info "destroying containers and volumes" -docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" --profile credentials down \ - --volumes --remove-orphans - -if [ "${KEEP_BOOTSTRAP}" = "true" ]; then - warn "keeping ${BOOTSTRAP_DIR} - note these keys no longer unseal anything" -elif [ -d "${BOOTSTRAP_DIR}" ]; then - # Stale unseal shares are worse than no shares: they invite an operator to - # believe recovery is possible when the data they unlock no longer exists. - info "removing stale bootstrap material" - rm -rf "${BOOTSTRAP_DIR}" -fi - -info "reset complete. Run ./setup.sh to start fresh." diff --git a/local/setup.sh b/local/setup.sh deleted file mode 100755 index e1cf801..0000000 --- a/local/setup.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# ./setup.sh [--with-credentials] -# Local Shamir init/unseal. Not production KMS auto-unseal. -set -euo pipefail - -# shellcheck source=bootstrap/lib.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/bootstrap/lib.sh" - -WITH_CREDENTIALS=false -while [ $# -gt 0 ]; do - case "$1" in - --with-credentials) WITH_CREDENTIALS=true ;; - -h|--help) sed -n '2,3p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $1 (try --help)" ;; - esac - shift -done - -require_docker -require_cmd curl jq -load_env - -if [ "${WITH_CREDENTIALS}" = "true" ]; then - ENABLE_DYNAMIC_CREDENTIALS=true - export ENABLE_DYNAMIC_CREDENTIALS - awk ' - BEGIN { found = 0 } - /^ENABLE_DYNAMIC_CREDENTIALS=/ { print "ENABLE_DYNAMIC_CREDENTIALS=true"; found = 1; next } - { print } - END { if (!found) print "ENABLE_DYNAMIC_CREDENTIALS=true" } - ' "${ENV_FILE}" > "${ENV_FILE}.tmp" && mv "${ENV_FILE}.tmp" "${ENV_FILE}" -fi - -info "local setup (Shamir unseal automation; not production KMS auto-unseal)" - -if [ "${WITH_CREDENTIALS}" = "true" ]; then - "${ADAPTER_DIR}/bootstrap/up.sh" --with-credentials -else - "${ADAPTER_DIR}/bootstrap/up.sh" -fi - -"${ADAPTER_DIR}/bootstrap/bootstrap.sh" - -ensure_synthetic_tenant() { - local id="$1" tok - provisioning_token_usable || die "provisioning token is unusable after bootstrap" - tok="$(cat "${BOOTSTRAP_DIR}/provisioning.token")" - # Idempotent. Re-run so --with-credentials can add database roles later. - VAULT_TOKEN="${tok}" "${REPO_ROOT}/scripts/tenants/create-tenant.sh" "${id}" --no-credentials -} - -ensure_synthetic_tenant tenant-a -ensure_synthetic_tenant tenant-b - -"${ADAPTER_DIR}/bootstrap/health.sh" - -cat >&2 </dev/null | grep -q '^vault$'; then - ok "vault container is running" -else - no "vault container is running" "run ./bootstrap/up.sh first" - printf '\n %d passed, %d failed\n\n' "${PASSED}" "${FAILED}" - exit 1 -fi - -HEALTH_STATE="$(docker inspect --format '{{.State.Health.Status}}' vault-cluster-vault 2>/dev/null || echo unknown)" -if [ "${HEALTH_STATE}" = "healthy" ]; then - ok "compose healthcheck reports healthy" -else - no "compose healthcheck reports healthy" "state was '${HEALTH_STATE}'" -fi - -if compose ps --status running --format '{{.Service}}' 2>/dev/null | grep -q '^unseal$'; then - ok "unseal sidecar is running" -else - no "unseal sidecar is running" "compose should start vault and unseal together" -fi - -if [ "$(vault_health_code)" = "200" ]; then - ok "Vault reports initialized and unsealed" -else - no "Vault reports initialized and unsealed" \ - "health code $(vault_health_code) - run ./setup.sh" -fi - -sec "Dev mode is not in use" - -# Dev mode is in-memory and starts unsealed, so it would make every persistence -# and unseal test below vacuous while appearing to pass. -if compose config 2>/dev/null | grep -qE '(-dev\b|VAULT_DEV_ROOT_TOKEN_ID)'; then - no "no dev-mode configuration present" "found dev-mode settings in the Compose configuration" -else - ok "no dev-mode configuration present" -fi - -SEAL_TYPE="$(curl -s "${VAULT_ADDR}/v1/sys/seal-status" | jq -r '.type // "unknown"')" -if [ "${SEAL_TYPE}" = "shamir" ]; then - ok "seal type is shamir (real unseal flow is exercised)" -else - no "seal type is shamir" "got '${SEAL_TYPE}'" -fi - -STORAGE_TYPE="$(curl -s "${VAULT_ADDR}/v1/sys/seal-status" | jq -r '.storage_type // "unknown"')" -if [ "${STORAGE_TYPE}" = "raft" ]; then - ok "storage backend is raft" -else - no "storage backend is raft" "got '${STORAGE_TYPE}'" -fi - -sec "Storage separation" - -# Audit and Raft must be on different volumes. Sharing one means unbounded -# audit growth fills the storage backend, and because Vault fails closed when -# audit cannot write, a disk-space problem becomes a total outage. -RAFT_VOL="$(docker inspect --format \ - '{{range .Mounts}}{{if eq .Destination "/vault/file"}}{{.Name}}{{end}}{{end}}' vault-cluster-vault 2>/dev/null)" -AUDIT_VOL="$(docker inspect --format \ - '{{range .Mounts}}{{if eq .Destination "/vault/logs"}}{{.Name}}{{end}}{{end}}' vault-cluster-vault 2>/dev/null)" - -if [ -n "${RAFT_VOL}" ] && [ -n "${AUDIT_VOL}" ]; then - ok "both raft and audit volumes are mounted (${RAFT_VOL}, ${AUDIT_VOL})" - if [ "${RAFT_VOL}" != "${AUDIT_VOL}" ]; then - ok "audit storage is a separate volume from raft storage" - else - no "audit storage is a separate volume from raft storage" \ - "both use '${RAFT_VOL}' - audit growth can corrupt or fill Raft storage" - fi -else - no "both raft and audit volumes are mounted" "raft='${RAFT_VOL}' audit='${AUDIT_VOL}'" -fi - -if docker exec vault-cluster-vault sh -c 'test -s /vault/logs/audit.log' 2>/dev/null; then - ok "audit log exists and is non-empty" -else - no "audit log exists and is non-empty" "no audit output at /vault/logs/audit.log" -fi - -if docker exec vault-cluster-vault sh -c 'ls /vault/file/vault.db' >/dev/null 2>&1; then - ok "raft storage is populated" -else - no "raft storage is populated" "no vault.db under /vault/file" -fi - -sec "Network exposure is loopback only" - -PORT_BINDING="$(docker inspect --format \ - '{{range $p, $conf := .NetworkSettings.Ports}}{{range $conf}}{{$p}}={{.HostIp}} {{end}}{{end}}' \ - vault-cluster-vault 2>/dev/null)" - -# TLS is disabled locally, so loopback binding is the compensating control. -# A 0.0.0.0 binding would expose an unsealed, plaintext Vault to the whole -# network. -if printf '%s' "${PORT_BINDING}" | grep -q '127.0.0.1'; then - ok "Vault port is published to 127.0.0.1 only" -else - no "Vault port is published to 127.0.0.1 only" \ - "binding was '${PORT_BINDING}' - a non-loopback binding exposes a plaintext Vault" -fi - -sec "Data persists across a restart" - -# The claim the local target exists to support. Dev mode would fail this, and so -# would a bind mount pointing somewhere ephemeral. -PROBE_PATH="${KV_MOUNT:-kv}/data/${TENANT_PREFIX:-customers}/tenant-a/runtime-persistence-probe" -PROBE_VALUE="FAKE-persistence-probe-$(date +%s)" -OPERATOR_TOKEN_FILE="${BOOTSTRAP_DIR}/provisioning.token" - -if [ ! -f "${OPERATOR_TOKEN_FILE}" ]; then - no "bootstrap tokens are present" "run ./setup.sh" -else - # Written with the tenant's own identity, since the provisioning identity is - # denied KV access by design. Reuses the conformance login helper rather - # than reimplementing AppRole login here. - export VAULT_TOKEN; VAULT_TOKEN="$(cat "${OPERATOR_TOKEN_FILE}")" - # shellcheck source=/dev/null - . "${REPO_ROOT}/tests/conformance/common/setup.sh" - - if WRITER_TOKEN="$(approle_login "tenant-tenant-a-writer" 2>/dev/null)"; then - if curl -s -o /dev/null -w '%{http_code}' \ - --header "X-Vault-Token: ${WRITER_TOKEN}" \ - --request POST \ - --data "$(jq -nc --arg v "${PROBE_VALUE}" '{data: {probe: $v}}')" \ - "${VAULT_ADDR}/v1/${PROBE_PATH}" | grep -q '^2'; then - ok "probe secret written before restart" - - info "restarting Vault (this takes a few seconds)" - compose restart vault >/dev/null 2>&1 - wait_for_vault 60 >/dev/null 2>&1 - - # Shamir reseals on process restart. The Compose sidecar must unseal - # without the host submitting keys. - waited=0 - while vault_is_sealed && [ "${waited}" -lt 45 ]; do - sleep 1 - waited=$((waited + 1)) - done - - if vault_is_sealed; then - no "Compose sidecar unsealed Vault after restart" \ - "still sealed after ${waited}s - unseal sidecar is not recovering" - else - ok "Compose sidecar unsealed Vault after restart" - - READ_VALUE="$(curl -s --header "X-Vault-Token: ${WRITER_TOKEN}" \ - "${VAULT_ADDR}/v1/${PROBE_PATH}" | jq -r '.data.data.probe // empty')" - - if [ "${READ_VALUE}" = "${PROBE_VALUE}" ]; then - ok "probe secret survived the restart intact" - else - no "probe secret survived the restart intact" \ - "expected '${PROBE_VALUE}', got '${READ_VALUE}' - storage is not persistent" - fi - - # Tokens are Raft state too; if this fails, storage is only partly - # persisting, which is harder to notice than losing everything. - if curl -s -o /dev/null -w '%{http_code}' \ - --header "X-Vault-Token: ${WRITER_TOKEN}" \ - "${VAULT_ADDR}/v1/auth/token/lookup-self" | grep -q '^2'; then - ok "pre-restart token is still valid (token storage persisted)" - else - no "pre-restart token is still valid" "the token did not survive the restart" - fi - fi - else - no "probe secret written before restart" "write was refused" - fi - else - no "tenant AppRole login for the persistence probe" \ - "has tenant-a been onboarded with create-tenant.sh?" - fi -fi - -sec "Compose configuration" - -if compose config 2>/dev/null | grep -qE 'image:.*@sha256:'; then - ok "images are digest-pinned" -else - no "images are digest-pinned" "a floating tag lets the environment change without a commit" -fi - -if compose config 2>/dev/null | grep -qE 'image:.*:latest'; then - no "no image uses the 'latest' tag" "found a ':latest' reference" -else - ok "no image uses the 'latest' tag" -fi - -printf '\n %d passed, %d failed\n\n' "${PASSED}" "${FAILED}" -[ "${FAILED}" -eq 0 ] || exit 1 diff --git a/local/vault/config.hcl b/local/vault/config.hcl index c62ea1a..1456ab4 100644 --- a/local/vault/config.hcl +++ b/local/vault/config.hcl @@ -1,5 +1,3 @@ -# Local Vault CE. Raft on /vault/file. No dev mode. TLS off; Compose binds 127.0.0.1. - cluster_name = "vault-local" ui = true diff --git a/scripts/configure-platform.sh b/scripts/configure-platform.sh deleted file mode 100755 index 1c56be1..0000000 --- a/scripts/configure-platform.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Configure an initialized Vault: audit, KV, AppRole, policies, optional DB. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -. "${SCRIPT_DIR}/lib/common.sh" -# shellcheck source=lib/vault.sh -. "${SCRIPT_DIR}/lib/vault.sh" - -require_cmd curl jq sed -platform_defaults -vault_require_env - -info "configuring platform at ${VAULT_ADDR}" -info " kv mount ${KV_MOUNT}/" -info " tenant prefix ${TENANT_PREFIX}/" -info " auth mount ${AUTH_MOUNT}/" -info " dynamic creds ${ENABLE_DYNAMIC_CREDENTIALS}" - -# 1. Audit first. -# Order matters: everything after this point is recorded. Enabling audit last -# would leave the creation of mounts, policies, and identities - the most -# security-relevant operations the platform ever performs - unrecorded. -"${CONFIG_DIR}/audit/file-device.sh" - -# 2. Isolation: KV v2 and AppRole. -"${CONFIG_DIR}/secret-engines/kv.sh" -"${CONFIG_DIR}/auth/approle.sh" - -# 3. Platform policies. -# Rendered from templates because mount names are configurable; a policy naming -# the wrong mount matches nothing and silently grants nothing. -apply_platform_policy() { - local name="$1" rendered - rendered="$("${SCRIPT_DIR}/tenants/render-policy.sh" "${name}" | tail -n 1)" - "${TESTS_DIR}/lint/policy-lint.sh" "${rendered}" \ - || die "policy '${name}' failed lint - refusing to apply it" - vault_write_policy "${name}" "${rendered}" - info "applied policy '${name}'" -} - -apply_platform_policy provisioning -apply_platform_policy operator - -# 4. Dynamic database credentials. No-op when disabled. -"${CONFIG_DIR}/secret-engines/database.sh" - -info "platform configuration complete" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh deleted file mode 100755 index f69b8db..0000000 --- a/scripts/lib/common.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# shellcheck shell=bash - -REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -CONFIG_DIR="${REPO_ROOT}/config" -TESTS_DIR="${REPO_ROOT}/tests" -POLICY_TEMPLATE_DIR="${CONFIG_DIR}/policies/templates" -RENDERED_POLICY_DIR="${CONFIG_DIR}/policies/rendered" - -export REPO_ROOT CONFIG_DIR TESTS_DIR POLICY_TEMPLATE_DIR RENDERED_POLICY_DIR - -info() { printf '[config] %s\n' "$*" >&2; } -warn() { printf '[config] WARN: %s\n' "$*" >&2; } -die() { printf '[config] ERROR: %s\n' "$*" >&2; exit 1; } - -require_cmd() { - local missing=0 c - for c in "$@"; do - command -v "$c" >/dev/null 2>&1 || { warn "required command not found: $c"; missing=1; } - done - [ "$missing" -eq 0 ] || die "install the missing dependencies listed above, then retry" -} - -platform_defaults() { - : "${KV_MOUNT:=kv}" - : "${TENANT_PREFIX:=customers}" - : "${DATABASE_MOUNT:=database}" - : "${AUTH_MOUNT:=approle}" - : "${DEFAULT_TOKEN_TTL:=1h}" - : "${MAX_TOKEN_TTL:=24h}" - : "${DATABASE_DEFAULT_TTL:=1h}" - : "${DATABASE_MAX_TTL:=24h}" - : "${ENABLE_AUDIT:=true}" - : "${ENABLE_DYNAMIC_CREDENTIALS:=false}" - export KV_MOUNT TENANT_PREFIX DATABASE_MOUNT AUTH_MOUNT \ - DEFAULT_TOKEN_TTL MAX_TOKEN_TTL DATABASE_DEFAULT_TTL DATABASE_MAX_TTL \ - ENABLE_AUDIT ENABLE_DYNAMIC_CREDENTIALS -} - -credentials_enabled() { - [ "${ENABLE_DYNAMIC_CREDENTIALS:-false}" = "true" ] -} - -tenant_policy_reader() { printf 'tenant-%s-reader' "$1"; } -tenant_policy_writer() { printf 'tenant-%s-writer' "$1"; } -tenant_policy_database() { printf 'tenant-%s-database' "$1"; } -tenant_role_reader() { printf 'tenant-%s-reader' "$1"; } -tenant_role_writer() { printf 'tenant-%s-writer' "$1"; } -tenant_kv_data_path() { printf '%s/data/%s/%s' "${KV_MOUNT}" "${TENANT_PREFIX}" "$1"; } -tenant_kv_meta_path() { printf '%s/metadata/%s/%s' "${KV_MOUNT}" "${TENANT_PREFIX}" "$1"; } diff --git a/scripts/lib/vault.sh b/scripts/lib/vault.sh deleted file mode 100755 index 7042196..0000000 --- a/scripts/lib/vault.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# HTTP helpers. Use status codes (403 vs 404); the Vault CLI collapses both. -# shellcheck shell=bash - -VAULT_HTTP_TIMEOUT="${VAULT_HTTP_TIMEOUT:-15}" - -_VAULT_BODY_FILE="" - -# Call directly, never via $() — a subshell trap would delete the parent's body file. -_vault_ensure_body_file() { - if [ -z "${_VAULT_BODY_FILE:-}" ] || [ ! -f "${_VAULT_BODY_FILE}" ]; then - _VAULT_BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/vault-resp.XXXXXX")" - chmod 600 "${_VAULT_BODY_FILE}" - # Skip cleanup in subshells so a pipeline cannot delete the parent's file. - if [ "${BASH_SUBSHELL:-0}" -eq 0 ]; then - trap '[ -n "${_VAULT_BODY_FILE:-}" ] && rm -f "${_VAULT_BODY_FILE}"' EXIT - fi - fi -} - -vault_require_env() { - [ -n "${VAULT_ADDR:-}" ] || die "VAULT_ADDR is not set" - [ -n "${VAULT_TOKEN:-}" ] || die "VAULT_TOKEN is not set" -} - -vault_request() { - local method="$1" path="$2" data="${3:-}" - local url="${VAULT_ADDR%/}/v1/${path#/}" - _vault_ensure_body_file - local body_file="${_VAULT_BODY_FILE}" - local args=( - --silent --show-error - --max-time "${VAULT_HTTP_TIMEOUT}" - --output "${body_file}" - --write-out '%{http_code}' - --request "${method}" - --header "X-Vault-Token: ${VAULT_TOKEN}" - ) - [ -n "${VAULT_NAMESPACE:-}" ] && args+=(--header "X-Vault-Namespace: ${VAULT_NAMESPACE}") - if [ -n "${data}" ]; then - args+=(--header 'Content-Type: application/json' --data "${data}") - fi - - VAULT_STATUS="$(curl "${args[@]}" "${url}" 2>/dev/null || printf '000')" - export VAULT_STATUS - - case "${VAULT_STATUS}" in - 2*) return 0 ;; - *) return 1 ;; - esac -} - -vault_body() { - _vault_ensure_body_file - cat "${_VAULT_BODY_FILE}" 2>/dev/null || printf '{}' -} - -vault_status_of() { - vault_request "$@" >/dev/null 2>&1 || true - printf '%s' "${VAULT_STATUS}" -} - -vault_get() { vault_request GET "$1"; } -vault_post() { vault_request POST "$1" "${2:-}"; } -vault_put() { vault_request PUT "$1" "${2:-}"; } -vault_delete() { vault_request DELETE "$1"; } -vault_list() { vault_request GET "$1?list=true"; } - -vault_must() { - local method="$1" path="$2" data="${3:-}" - if ! vault_request "${method}" "${path}" "${data}"; then - die "${method} ${path} failed (HTTP ${VAULT_STATUS}): $(vault_body | jq -rc '.errors // .' 2>/dev/null || vault_body)" - fi -} - -vault_mount_exists() { - vault_request GET "sys/mounts/$1/tune" >/dev/null 2>&1 -} - -vault_auth_exists() { - vault_request GET "sys/auth" >/dev/null 2>&1 || return 1 - vault_body | jq -e --arg m "$1/" '(.data // .) | has($m)' >/dev/null 2>&1 -} - -vault_audit_device_exists() { - vault_request GET "sys/audit" >/dev/null 2>&1 || return 1 - vault_body | jq -e --arg m "$1/" '(.data // .) | has($m)' >/dev/null 2>&1 -} - -vault_write_policy() { - local name="$1" file="$2" - [ -f "${file}" ] || die "policy file not found: ${file}" - vault_must PUT "sys/policies/acl/${name}" \ - "$(jq -n --rawfile p "${file}" '{policy: $p}')" -} - -vault_policy_exists() { - vault_request GET "sys/policies/acl/$1" >/dev/null 2>&1 -} diff --git a/scripts/tenants/create-tenant.sh b/scripts/tenants/create-tenant.sh deleted file mode 100755 index 7b2408f..0000000 --- a/scripts/tenants/create-tenant.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -# create-tenant.sh [--no-credentials] -# Prints role_id and secret_id once. Does not read tenant secrets. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../lib/common.sh -. "${SCRIPT_DIR}/../lib/common.sh" -# shellcheck source=../lib/vault.sh -. "${SCRIPT_DIR}/../lib/vault.sh" -# shellcheck source=../validation/validate-tenant-id.sh -. "${SCRIPT_DIR}/../validation/validate-tenant-id.sh" - -require_cmd curl jq sed -platform_defaults - -ISSUE_CREDENTIALS=true -TENANT_ID="" - -while [ $# -gt 0 ]; do - case "$1" in - --no-credentials) ISSUE_CREDENTIALS=false ;; - -h|--help) sed -n '2,3p' "${BASH_SOURCE[0]}"; exit 0 ;; - -*) die "unknown option: $1 (try --help)" ;; - *) [ -z "${TENANT_ID}" ] || die "only one tenant ID may be given"; TENANT_ID="$1" ;; - esac - shift -done - -[ -n "${TENANT_ID}" ] || die "usage: $(basename "$0") [--no-credentials]" - -validate_tenant_id "${TENANT_ID}" || die "refusing to onboard an invalid tenant ID" - -vault_require_env - -READER_POLICY="$(tenant_policy_reader "${TENANT_ID}")" -WRITER_POLICY="$(tenant_policy_writer "${TENANT_ID}")" -DB_POLICY="$(tenant_policy_database "${TENANT_ID}")" -READER_ROLE="$(tenant_role_reader "${TENANT_ID}")" -WRITER_ROLE="$(tenant_role_writer "${TENANT_ID}")" - -info "onboarding tenant '${TENANT_ID}'" -info " subtree $(tenant_kv_data_path "${TENANT_ID}")/" -info " policies ${READER_POLICY}, ${WRITER_POLICY}$(credentials_enabled && printf ', %s' "${DB_POLICY}")" - -# 1. Render, lint, apply. Lint before apply so a bad policy never goes live. -render_lint_apply() { - local template="$1" policy_name="$2" rendered - rendered="$("${SCRIPT_DIR}/render-policy.sh" "${template}" "${TENANT_ID}" "${policy_name}" | tail -n 1)" - "${TESTS_DIR}/lint/policy-lint.sh" "${rendered}" \ - || die "rendered policy '${policy_name}' failed lint - NOT applied" - vault_write_policy "${policy_name}" "${rendered}" - info " applied policy ${policy_name}" -} - -render_lint_apply tenant-reader "${READER_POLICY}" -render_lint_apply tenant-writer "${WRITER_POLICY}" -credentials_enabled && render_lint_apply tenant-database "${DB_POLICY}" - -# 2. AppRole roles. Writer also gets the database policy when credentials are on. -create_role() { - local role="$1"; shift - local policies_json; policies_json="$(printf '%s\n' "$@" | jq -R . | jq -sc .)" - - vault_must POST "auth/${AUTH_MOUNT}/role/${role}" "$(jq -nc \ - --argjson policies "${policies_json}" \ - --arg ttl "${DEFAULT_TOKEN_TTL}" \ - --arg max "${MAX_TOKEN_TTL}" \ - '{ - token_policies: $policies, - token_ttl: $ttl, - token_max_ttl: $max, - token_type: "service", - secret_id_ttl: "24h", - secret_id_num_uses: 0, - bind_secret_id: true - }')" - info " created role ${role} -> [$*]" -} - -create_role "${READER_ROLE}" "${READER_POLICY}" -if credentials_enabled; then - create_role "${WRITER_ROLE}" "${WRITER_POLICY}" "${DB_POLICY}" -else - create_role "${WRITER_ROLE}" "${WRITER_POLICY}" -fi - -# 3. Database roles. Grants live in PostgreSQL; Vault only issues membership. -create_database_role() { - local suffix="$1" group_role="$2" - local role="tenant-${TENANT_ID}-${suffix}" - - vault_must POST "${DATABASE_MOUNT}/roles/${role}" "$(jq -nc \ - --arg db "${DATABASE_CONNECTION_NAME:-app}" \ - --arg grp "${group_role}" \ - --arg ttl "${DATABASE_DEFAULT_TTL}" \ - --arg max "${DATABASE_MAX_TTL}" \ - '{ - db_name: $db, - creation_statements: [ - "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '"'"'{{password}}'"'"' VALID UNTIL '"'"'{{expiration}}'"'"';", - ("GRANT " + $grp + " TO \"{{name}}\";") - ], - default_ttl: $ttl, - max_ttl: $max - }')" - info " created database role ${role} (grants ${group_role})" -} - -if credentials_enabled; then - create_database_role readonly app_readonly - create_database_role readwrite app_readwrite -fi - -# 4. Issue credentials to stdout once. Not written to disk. -if [ "${ISSUE_CREDENTIALS}" != "true" ]; then - info "tenant '${TENANT_ID}' onboarded (no credentials issued)" - exit 0 -fi - -issue_approle_credentials() { - local role="$1" role_id secret_id - - vault_must GET "auth/${AUTH_MOUNT}/role/${role}/role-id" - role_id="$(vault_body | jq -r '.data.role_id')" - - vault_must POST "auth/${AUTH_MOUNT}/role/${role}/secret-id" '{}' - secret_id="$(vault_body | jq -r '.data.secret_id')" - - printf '%s\n' "----------------------------------------------------------" - printf 'role %s\n' "${role}" - printf 'role_id %s\n' "${role_id}" - printf 'secret_id %s\n' "${secret_id}" -} - -printf '\n' -printf 'Credentials for tenant %s - shown once, not stored:\n\n' "${TENANT_ID}" -issue_approle_credentials "${READER_ROLE}" -issue_approle_credentials "${WRITER_ROLE}" -printf '%s\n\n' "----------------------------------------------------------" - -info "tenant '${TENANT_ID}' onboarded" diff --git a/scripts/tenants/offboard-tenant.sh b/scripts/tenants/offboard-tenant.sh deleted file mode 100755 index 597c060..0000000 --- a/scripts/tenants/offboard-tenant.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -# offboard-tenant.sh --yes [--purge-secrets] -# Revokes access. Secrets stay unless --purge-secrets (needs break-glass). -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../lib/common.sh -. "${SCRIPT_DIR}/../lib/common.sh" -# shellcheck source=../lib/vault.sh -. "${SCRIPT_DIR}/../lib/vault.sh" -# shellcheck source=../validation/validate-tenant-id.sh -. "${SCRIPT_DIR}/../validation/validate-tenant-id.sh" - -require_cmd curl jq -platform_defaults - -CONFIRMED=false -PURGE_SECRETS=false -TENANT_ID="" - -while [ $# -gt 0 ]; do - case "$1" in - --yes) CONFIRMED=true ;; - --purge-secrets) PURGE_SECRETS=true ;; - -h|--help) sed -n '2,3p' "${BASH_SOURCE[0]}"; exit 0 ;; - -*) die "unknown option: $1 (try --help)" ;; - *) [ -z "${TENANT_ID}" ] || die "only one tenant ID may be given"; TENANT_ID="$1" ;; - esac - shift -done - -[ -n "${TENANT_ID}" ] || die "usage: $(basename "$0") --yes [--purge-secrets]" -validate_tenant_id "${TENANT_ID}" || die "refusing to act on an invalid tenant ID" - -if [ "${CONFIRMED}" != "true" ]; then - cat >&2 < [tenant-id] [policy-name] -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../lib/common.sh -. "${SCRIPT_DIR}/../lib/common.sh" -# shellcheck source=../validation/validate-tenant-id.sh -. "${SCRIPT_DIR}/../validation/validate-tenant-id.sh" - -platform_defaults - -[ $# -ge 1 ] || die "usage: $(basename "$0") [tenant-id] [policy-name]" - -TEMPLATE_NAME="$1" -TENANT_ID="${2:-}" -# The rendered file is named after the policy it will become, so the file on -# disk and the policy in Vault cannot drift apart. Callers that know the policy -# name pass it explicitly rather than having this script re-derive it. -POLICY_NAME="${3:-${TEMPLATE_NAME}}" -TEMPLATE_FILE="${POLICY_TEMPLATE_DIR}/${TEMPLATE_NAME}.hcl.tpl" - -[ -f "${TEMPLATE_FILE}" ] || die "no such template: ${TEMPLATE_FILE}" - -# Re-validated here even though create-tenant.sh already did. This script is -# the last point before an ID becomes an ACL path, and a validation that only -# runs on one code path is a validation that will eventually be bypassed. -if [ -n "${TENANT_ID}" ]; then - validate_tenant_id "${TENANT_ID}" || die "refusing to render a policy for an invalid tenant ID" -fi - -OUTPUT_FILE="${RENDERED_POLICY_DIR}/${POLICY_NAME}.hcl" - -mkdir -p "${RENDERED_POLICY_DIR}" - -# sed with a fixed, closed set of placeholders rather than envsubst or eval. -# envsubst is not installed by default on macOS, and eval on a policy template -# would execute whatever a template contains. -# -# The substituted values are safe by construction: TENANT_ID has passed -# validation, and the mount names come from the contract's own defaults. -sed \ - -e "s|@@TENANT_ID@@|${TENANT_ID}|g" \ - -e "s|@@KV_MOUNT@@|${KV_MOUNT}|g" \ - -e "s|@@TENANT_PREFIX@@|${TENANT_PREFIX}|g" \ - -e "s|@@DATABASE_MOUNT@@|${DATABASE_MOUNT}|g" \ - -e "s|@@AUTH_MOUNT@@|${AUTH_MOUNT}|g" \ - "${TEMPLATE_FILE}" > "${OUTPUT_FILE}.tmp" - -# A leftover placeholder means an unset variable. Applying such a policy would -# create a rule for a literal path containing "@@", which matches nothing and -# therefore grants nothing - a silent no-op. Fail instead. -if grep -q '@@[A-Z_]*@@' "${OUTPUT_FILE}.tmp"; then - local_leftover="$(grep -o '@@[A-Z_]*@@' "${OUTPUT_FILE}.tmp" | sort -u | tr '\n' ' ')" - rm -f "${OUTPUT_FILE}.tmp" - die "unsubstituted placeholders remain: ${local_leftover}- refusing to write a partial policy" -fi - -mv "${OUTPUT_FILE}.tmp" "${OUTPUT_FILE}" -chmod 600 "${OUTPUT_FILE}" - -info "rendered ${TEMPLATE_NAME}$([ -n "${TENANT_ID}" ] && printf ' for %s' "${TENANT_ID}") -> ${OUTPUT_FILE}" -printf '%s\n' "${OUTPUT_FILE}" diff --git a/scripts/validation/validate-tenant-id.sh b/scripts/validation/validate-tenant-id.sh deleted file mode 100755 index f095ec4..0000000 --- a/scripts/validation/validate-tenant-id.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# validate-tenant-id.sh — reject IDs that would break ACL paths. -set -euo pipefail - -# shellcheck source=../lib/common.sh -. "$(dirname -- "${BASH_SOURCE[0]}")/../lib/common.sh" - -TENANT_ID_PATTERN='^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$' - -TENANT_ID_RESERVED="sys auth identity cubbyhole root default admin data metadata delete undelete destroy config subkeys tenant customers" - -validate_tenant_id() { - local id="${1-}" - - [ -n "${id}" ] || { warn "tenant ID is empty"; return 1; } - - # Checked before anything else: a leading or trailing space is invisible in - # a terminal and would otherwise be reported as a pattern mismatch. - case "${id}" in - *[[:space:]]*) warn "tenant ID contains whitespace: '${id}'"; return 1 ;; - esac - - case "${id}" in - */*) warn "tenant ID contains a path separator '/': '${id}' - this would escape the tenant subtree"; return 1 ;; - *..*) warn "tenant ID contains '..': '${id}' - path traversal is not permitted"; return 1 ;; - *'*'*) warn "tenant ID contains the ACL wildcard '*': '${id}' - this would widen every generated policy"; return 1 ;; - *'+'*) warn "tenant ID contains '+': '${id}' - this is a Vault path segment wildcard"; return 1 ;; - *'{'*|*'}'*) warn "tenant ID contains a brace: '${id}' - this collides with Vault policy templating"; return 1 ;; - *'\'*) warn "tenant ID contains a backslash: '${id}'"; return 1 ;; - *'"'*|*"'"*) warn "tenant ID contains a quote: '${id}'"; return 1 ;; - *'$'*|*'`'*) warn "tenant ID contains a shell metacharacter: '${id}'"; return 1 ;; - *%*) warn "tenant ID contains '%': '${id}' - percent-encoding is not permitted"; return 1 ;; - esac - - # Non-ASCII: homoglyphs make two visually identical tenant IDs that are - # different paths, which is a review-time trap rather than a runtime error. - case "${id}" in - *[![:ascii:]]*) warn "tenant ID contains non-ASCII characters: '${id}'"; return 1 ;; - esac - - if ! printf '%s' "${id}" | grep -Eq "${TENANT_ID_PATTERN}"; then - warn "tenant ID '${id}' does not match ${TENANT_ID_PATTERN}" - warn " required: lowercase letters, digits, and hyphens; 3-32 characters;" - warn " must start and end with a letter or digit" - return 1 - fi - - local reserved - for reserved in ${TENANT_ID_RESERVED}; do - if [ "${id}" = "${reserved}" ]; then - warn "tenant ID '${id}' is reserved (collides with a Vault path segment)" - return 1 - fi - done - - return 0 -} - -# Only run the CLI behaviour when executed directly, so create-tenant.sh can -# source this file and call the function. -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then - [ $# -eq 1 ] || die "usage: $(basename "$0") " - validate_tenant_id "$1" || exit 1 - printf '%s\n' "$1" -fi diff --git a/setup.sh b/setup.sh deleted file mode 100755 index 76ab37c..0000000 --- a/setup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# One-command local developer environment. Delegates to local/setup.sh. -set -euo pipefail -exec "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/local/setup.sh" "$@" diff --git a/tests/conformance/common/assert.sh b/tests/conformance/common/assert.sh deleted file mode 100755 index ba41406..0000000 --- a/tests/conformance/common/assert.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# Assertion harness for conformance tests. -# -# Sourced, not executed. -# -# Runtime-independent: every assertion is expressed against VAULT_ADDR over -# HTTP. No container names, no docker, no host paths - so the same suite runs -# against Compose today and a cluster later without modification. -# -# The central design decision here is that a denial must be proven by HTTP 403, -# never by "the request failed". A test that accepts any failure as a denial -# passes against an empty Vault, against an unreachable Vault, and against a -# Vault where the path simply does not exist - none of which say anything about -# authorization. That distinction is the difference between an isolation suite -# and a suite that looks like one. - -# shellcheck shell=bash - -TESTS_RUN=0 -TESTS_PASSED=0 -TESTS_FAILED=0 - -suite() { - printf '\n %s\n' "$1" -} - -pass() { - TESTS_RUN=$((TESTS_RUN + 1)); TESTS_PASSED=$((TESTS_PASSED + 1)) - printf ' PASS %s\n' "$1" -} - -fail() { - TESTS_RUN=$((TESTS_RUN + 1)); TESTS_FAILED=$((TESTS_FAILED + 1)) - printf ' FAIL %s\n' "$1" - [ -n "${2:-}" ] && printf ' %s\n' "$2" - return 0 -} - -# --- Request helper ---------------------------------------------------------- -# Performs a request as a specific token without disturbing the caller's -# VAULT_TOKEN, and leaves the status in ASSERT_STATUS and the body in -# ASSERT_BODY. -as_token() { - local token="$1" method="$2" path="$3" data="${4:-}" - local saved="${VAULT_TOKEN:-}" - VAULT_TOKEN="${token}" - vault_request "${method}" "${path}" "${data}" >/dev/null 2>&1 || true - ASSERT_STATUS="${VAULT_STATUS}" - ASSERT_BODY="$(vault_body)" - VAULT_TOKEN="${saved}" -} - -# --- Core assertions --------------------------------------------------------- - -# assert_allowed TOKEN METHOD PATH BODY DESCRIPTION -assert_allowed() { - local token="$1" method="$2" path="$3" data="$4" desc="$5" - as_token "${token}" "${method}" "${path}" "${data}" - case "${ASSERT_STATUS}" in - 2*) pass "${desc}" ;; - 403) fail "${desc}" "expected success, got 403 permission denied on ${method} ${path}" ;; - *) fail "${desc}" "expected 2xx, got ${ASSERT_STATUS} on ${method} ${path}: $(printf '%s' "${ASSERT_BODY}" | head -c 200)" ;; - esac -} - -# assert_denied TOKEN METHOD PATH BODY DESCRIPTION -# -# Requires exactly 403. A 404 is reported as a distinct, louder failure: it -# usually means the test is pointing at a path that does not exist, so the -# "denial" it was about to record would have been meaningless. -assert_denied() { - local token="$1" method="$2" path="$3" data="$4" desc="$5" - as_token "${token}" "${method}" "${path}" "${data}" - case "${ASSERT_STATUS}" in - 403) pass "${desc}" ;; - 2*) fail "${desc}" "ACCESS WAS GRANTED (HTTP ${ASSERT_STATUS}) on ${method} ${path} - this is an isolation breach" ;; - 404) fail "${desc}" "got 404 not 403 on ${method} ${path} - the path may not exist, so this test proves nothing about authorization" ;; - *) fail "${desc}" "expected 403, got ${ASSERT_STATUS} on ${method} ${path}" ;; - esac -} - -assert_status() { - local expected="$1" actual="$2" desc="$3" - if [ "${actual}" = "${expected}" ]; then - pass "${desc}" - else - fail "${desc}" "expected HTTP ${expected}, got ${actual}" - fi -} - -assert_eq() { - local expected="$1" actual="$2" desc="$3" - if [ "${expected}" = "${actual}" ]; then - pass "${desc}" - else - fail "${desc}" "expected '${expected}', got '${actual}'" - fi -} - -assert_not_empty() { - local value="$1" desc="$2" - if [ -n "${value}" ] && [ "${value}" != "null" ]; then - pass "${desc}" - else - fail "${desc}" "value was empty or null" - fi -} - -assert_contains() { - local haystack="$1" needle="$2" desc="$3" - case "${haystack}" in - *"${needle}"*) pass "${desc}" ;; - *) fail "${desc}" "expected to find '${needle}'" ;; - esac -} - -assert_command_succeeds() { - local desc="$1"; shift - if "$@" >/dev/null 2>&1; then - pass "${desc}" - else - fail "${desc}" "command failed: $*" - fi -} - -assert_command_fails() { - local desc="$1"; shift - if "$@" >/dev/null 2>&1; then - fail "${desc}" "command unexpectedly SUCCEEDED: $*" - else - pass "${desc}" - fi -} - -# assert_not_granted TOKEN METHOD PATH BODY DESCRIPTION -# -# Passes on 403 or 404. Use only when success would be a breach, but Vault -# has no ACL 403 because the path is inside an allowed subtree and missing -# (404) or because the backend isolates by identity (cubbyhole). Still fails -# on 2xx: that is granted access. -assert_not_granted() { - local token="$1" method="$2" path="$3" data="$4" desc="$5" - as_token "${token}" "${method}" "${path}" "${data}" - case "${ASSERT_STATUS}" in - 403|404) pass "${desc}" ;; - 2*) fail "${desc}" "ACCESS WAS GRANTED (HTTP ${ASSERT_STATUS}) on ${method} ${path} - this is an isolation breach" ;; - *) fail "${desc}" "expected 403 or 404, got ${ASSERT_STATUS} on ${method} ${path}" ;; - esac -} - -finish() { - printf '\n %d run, %d passed, %d failed\n\n' \ - "${TESTS_RUN}" "${TESTS_PASSED}" "${TESTS_FAILED}" - [ "${TESTS_FAILED}" -eq 0 ] || exit 1 - exit 0 -} diff --git a/tests/conformance/common/database.sh b/tests/conformance/common/database.sh deleted file mode 100755 index a79459b..0000000 --- a/tests/conformance/common/database.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# PostgreSQL helpers for the dynamic-credential conformance tests. -# -# Sourced, not executed. -# -# Verifying a dynamic credential requires actually connecting with it. Vault -# reporting that it issued a credential is not evidence that the credential -# works, that it carries the intended privileges, or that revoking it takes -# effect - and each of those has failed independently in real deployments. -# -# The runtime coupling is confined to two variables. PSQL_CMD is how SQL gets -# executed and DB_TEST_HOST is where PostgreSQL is reachable from wherever that -# command runs; everything else here is generic. -# -# Environment: -# PSQL_CMD command that behaves like psql (default: psql) -# DB_TEST_HOST host PostgreSQL is reachable at (default: 127.0.0.1) -# DB_TEST_PORT port (default: 5432) -# DB_TEST_NAME database (default: appdb) -# DB_ADMIN_USER privileged user for residue scans (default: postgres) -# DB_ADMIN_PASSWORD - -# shellcheck shell=bash - -PSQL_CMD="${PSQL_CMD:-psql}" -DB_TEST_HOST="${DB_TEST_HOST:-127.0.0.1}" -DB_TEST_PORT="${DB_TEST_PORT:-5432}" -DB_TEST_NAME="${DB_TEST_NAME:-appdb}" -DB_ADMIN_USER="${DB_ADMIN_USER:-postgres}" -DB_ADMIN_PASSWORD="${DB_ADMIN_PASSWORD:-}" - -db_uri() { - local user="$1" pass="$2" - printf 'postgresql://%s:%s@%s:%s/%s?sslmode=disable' \ - "${user}" "${pass}" "${DB_TEST_HOST}" "${DB_TEST_PORT}" "${DB_TEST_NAME}" -} - -# db_query USER PASSWORD SQL -# Prints the result unaligned and untupled, so callers can compare directly. -db_query() { - local user="$1" pass="$2" sql="$3" - # shellcheck disable=SC2086 - ${PSQL_CMD} "$(db_uri "${user}" "${pass}")" -v ON_ERROR_STOP=1 -tAc "${sql}" 2>&1 -} - -db_query_succeeds() { - local user="$1" pass="$2" sql="$3" - # shellcheck disable=SC2086 - ${PSQL_CMD} "$(db_uri "${user}" "${pass}")" -v ON_ERROR_STOP=1 -tAc "${sql}" >/dev/null 2>&1 -} - -db_admin_query() { - db_query "${DB_ADMIN_USER}" "${DB_ADMIN_PASSWORD}" "$1" -} - -db_available() { - db_query_succeeds "${DB_ADMIN_USER}" "${DB_ADMIN_PASSWORD}" "SELECT 1" -} - -credentials_require_env() { - conformance_require_env - credentials_enabled || die \ - "ENABLE_DYNAMIC_CREDENTIALS is not true. - Running the credentials suite against a platform with the capability - disabled would report a pass for tests that never executed, so this is a - hard error rather than a skip." - - db_available || die \ - "cannot reach PostgreSQL as ${DB_ADMIN_USER}@${DB_TEST_HOST}:${DB_TEST_PORT}/${DB_TEST_NAME} - using PSQL_CMD='${PSQL_CMD}'. - These tests must connect with the credentials Vault issues; without a - working connection they could only assert that Vault returned a string." -} - -# --- Dynamic credentials ----------------------------------------------------- - -# issue_credential TOKEN ROLE -# Prints "usernamepasswordlease_idlease_duration" and returns nonzero on failure. -issue_credential() { - local token="$1" role="$2" - as_token "${token}" GET "${DATABASE_MOUNT}/creds/${role}" - case "${ASSERT_STATUS}" in - 2*) ;; - *) return 1 ;; - esac - printf '%s\t%s\t%s\t%s' \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r '.data.username')" \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r '.data.password')" \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r '.lease_id')" \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r '.lease_duration // 0')" -} - -# Tenant database policy can revoke its own lease; provisioning cannot. -# Callers must read four fields (user, password, lease_id, ttl). Bash assigns -# leftover columns to the last variable, so a 3-variable read corrupts lease_id. -revoke_lease() { - local token="$1" lease_id="$2" - [ -n "${lease_id}" ] && [ "${lease_id}" != "null" ] || return 1 - as_token "${token}" PUT "sys/leases/revoke" \ - "$(jq -nc --arg l "${lease_id}" '{lease_id: $l}')" - case "${ASSERT_STATUS}" in - 2*) return 0 ;; - *) return 1 ;; - esac -} - -wait_until_db_role_gone() { - local user="$1" attempts="${2:-15}" - local _ - for _ in $(seq 1 "${attempts}"); do - db_role_exists "${user}" || return 0 - sleep 1 - done - return 1 -} - -# Vault's PostgreSQL plugin prefixes every generated username with "v-", which -# is what makes orphan detection possible: any v-* role with no corresponding -# lease is residue. -VAULT_ROLE_PREFIX="v-" - -count_vault_db_roles() { - db_admin_query "SELECT count(*) FROM pg_roles WHERE rolname LIKE '${VAULT_ROLE_PREFIX}%';" \ - | tr -d '[:space:]' -} - -list_vault_db_roles() { - db_admin_query "SELECT rolname FROM pg_roles WHERE rolname LIKE '${VAULT_ROLE_PREFIX}%' ORDER BY rolname;" -} - -db_role_exists() { - local rolname="$1" result - result="$(db_admin_query "SELECT 1 FROM pg_roles WHERE rolname = '${rolname}';" | tr -d '[:space:]')" - [ "${result}" = "1" ] -} diff --git a/tests/conformance/common/setup.sh b/tests/conformance/common/setup.sh deleted file mode 100755 index c3e643d..0000000 --- a/tests/conformance/common/setup.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -# Conformance test setup: configuration, tenant login, and fixtures. -# -# Sourced, not executed. -# -# Reads only the environment variables named in the platform contract, so the -# same suite runs unchanged against any conforming Vault. - -# shellcheck shell=bash - -CONFORMANCE_COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -PLATFORM_SCRIPTS="$(cd -- "${CONFORMANCE_COMMON_DIR}/../../../scripts" && pwd)" - -# shellcheck source=../../../scripts/lib/common.sh -. "${PLATFORM_SCRIPTS}/lib/common.sh" -# shellcheck source=../../../scripts/lib/vault.sh -. "${PLATFORM_SCRIPTS}/lib/vault.sh" -# shellcheck source=assert.sh -. "${CONFORMANCE_COMMON_DIR}/assert.sh" - -platform_defaults -require_cmd curl jq - -TENANT_A="${TENANT_A:-tenant-a}" -TENANT_B="${TENANT_B:-tenant-b}" - -conformance_require_env() { - vault_require_env - [ -n "${TENANT_A}" ] || die "TENANT_A is not set" - [ -n "${TENANT_B}" ] || die "TENANT_B is not set" -} - -# --- Tenant authentication ---------------------------------------------------- -# -# The suite logs in as each tenant rather than being handed tokens, so it -# exercises the real credential path: role binding, secret-id issuance, and -# login. Tokens supplied from outside would skip exactly the part of the -# identity model most likely to be misconfigured. - -approle_login() { - local role="$1" role_id secret_id token - - vault_request GET "auth/${AUTH_MOUNT}/role/${role}/role-id" >/dev/null 2>&1 \ - || die "cannot read role-id for '${role}' (HTTP ${VAULT_STATUS}) - has the tenant been onboarded?" - role_id="$(vault_body | jq -r '.data.role_id')" - - vault_request POST "auth/${AUTH_MOUNT}/role/${role}/secret-id" '{}' >/dev/null 2>&1 \ - || die "cannot issue secret-id for '${role}' (HTTP ${VAULT_STATUS})" - secret_id="$(vault_body | jq -r '.data.secret_id')" - - # Login is unauthenticated, so the current token must not leak into it. - local saved="${VAULT_TOKEN}" - VAULT_TOKEN="" - vault_request POST "auth/${AUTH_MOUNT}/login" \ - "$(jq -nc --arg r "${role_id}" --arg s "${secret_id}" '{role_id: $r, secret_id: $s}')" >/dev/null 2>&1 \ - || { VAULT_TOKEN="${saved}"; die "AppRole login failed for '${role}' (HTTP ${VAULT_STATUS})"; } - token="$(vault_body | jq -r '.auth.client_token')" - VAULT_TOKEN="${saved}" - - [ -n "${token}" ] && [ "${token}" != "null" ] || die "login for '${role}' returned no token" - printf '%s' "${token}" -} - -# Populates TOKEN_A_READER, TOKEN_A_WRITER, TOKEN_B_READER, TOKEN_B_WRITER. -conformance_login_tenants() { - TOKEN_A_READER="$(approle_login "$(tenant_role_reader "${TENANT_A}")")" - TOKEN_A_WRITER="$(approle_login "$(tenant_role_writer "${TENANT_A}")")" - TOKEN_B_READER="$(approle_login "$(tenant_role_reader "${TENANT_B}")")" - TOKEN_B_WRITER="$(approle_login "$(tenant_role_writer "${TENANT_B}")")" - export TOKEN_A_READER TOKEN_A_WRITER TOKEN_B_READER TOKEN_B_WRITER -} - -# --- Path helpers ------------------------------------------------------------- - -kv_data() { printf '%s/data/%s/%s/%s' "${KV_MOUNT}" "${TENANT_PREFIX}" "$1" "${2:-}"; } -kv_meta() { printf '%s/metadata/%s/%s/%s' "${KV_MOUNT}" "${TENANT_PREFIX}" "$1" "${2:-}"; } - -# KV v2 wraps written values in a "data" envelope. Getting this wrong stores a -# literal {"data":...} nested one level too deep, which reads back fine and -# fails only when an application looks for its key. -kv_payload() { - jq -nc --arg k "$1" --arg v "$2" '{data: {($k): $v}}' -} - -# --- Fixtures ----------------------------------------------------------------- -# -# Fake values only, and clearly labelled as such. A test fixture that looks -# like a plausible credential eventually gets copied somewhere real. -FIXTURE_SECRET_NAME="${FIXTURE_SECRET_NAME:-app-config}" -FIXTURE_SECRET_KEY="api_key" -FIXTURE_VALUE_A="FAKE-not-a-real-credential-tenant-a" -FIXTURE_VALUE_B="FAKE-not-a-real-credential-tenant-b" - -# Writes one secret per tenant using that tenant's own writer identity, so the -# fixtures themselves prove the positive path before any denial is asserted. -conformance_seed_fixtures() { - as_token "${TOKEN_A_WRITER}" POST "$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "${FIXTURE_VALUE_A}")" - case "${ASSERT_STATUS}" in - 2*) ;; - *) die "could not seed fixture for ${TENANT_A} (HTTP ${ASSERT_STATUS}) - the writer policy is broken, so no isolation result below would be meaningful" ;; - esac - - as_token "${TOKEN_B_WRITER}" POST "$(kv_data "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "${FIXTURE_VALUE_B}")" - case "${ASSERT_STATUS}" in - 2*) ;; - *) die "could not seed fixture for ${TENANT_B} (HTTP ${ASSERT_STATUS})" ;; - esac -} diff --git a/tests/conformance/credentials/010-issue-and-connect.sh b/tests/conformance/credentials/010-issue-and-connect.sh deleted file mode 100755 index 5368b08..0000000 --- a/tests/conformance/credentials/010-issue-and-connect.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Issue a working PostgreSQL credential with a bounded TTL. -set -euo pipefail - -COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)" -# shellcheck source=../common/setup.sh -. "${COMMON_DIR}/setup.sh" -# shellcheck source=../common/database.sh -. "${COMMON_DIR}/database.sh" - -credentials_require_env -conformance_login_tenants - -ROLE_A_RO="tenant-${TENANT_A}-readonly" -ROLE_A_RW="tenant-${TENANT_A}-readwrite" - -suite "Credential issuance" - -CRED="$(issue_credential "${TOKEN_A_WRITER}" "${ROLE_A_RO}")" || { - fail "tenant can request a dynamic credential" "HTTP ${ASSERT_STATUS} from ${DATABASE_MOUNT}/creds/${ROLE_A_RO}" - finish -} -pass "tenant can request a dynamic credential" - -IFS=$'\t' read -r CRED_USER CRED_PASS CRED_LEASE LEASE_TTL <<< "${CRED}" - -assert_not_empty "${CRED_USER}" "issued credential has a username" -assert_not_empty "${CRED_PASS}" "issued credential has a password" -assert_not_empty "${CRED_LEASE}" "issued credential has a lease ID" - -# The prefix is what makes orphan detection possible later. If Vault's username -# template ever changes, the residue scan silently stops finding anything, so -# the assumption is asserted here rather than left implicit. -assert_contains "${CRED_USER}" "v-" \ - "username carries the Vault prefix that orphan detection relies on" - -suite "The credential actually works" - -# Vault reporting success is not evidence the credential works. A wrong -# connection URL, a failed GRANT, or a password-encoding mismatch all produce a -# perfectly well-formed response and a credential that cannot log in. -assert_command_succeeds "issued credential can connect to PostgreSQL" \ - db_query_succeeds "${CRED_USER}" "${CRED_PASS}" "SELECT 1" - -assert_eq "${CRED_USER}" \ - "$(db_query "${CRED_USER}" "${CRED_PASS}" "SELECT current_user;" | tr -d '[:space:]')" \ - "the connection authenticates as the issued user, not a shared one" - -assert_command_succeeds "the role really exists in PostgreSQL" \ - db_role_exists "${CRED_USER}" - -suite "Lifetime is bounded" - -if [ "${LEASE_TTL}" -gt 0 ] 2>/dev/null; then - pass "credential has a finite TTL (${LEASE_TTL}s)" -else - # A credential with no expiry is a static credential with extra steps, and - # defeats the entire reason for using dynamic secrets. - fail "credential has a finite TTL" "lease_duration was '${LEASE_TTL}' - the credential does not expire" -fi - -MAX_TTL_SECONDS="$(printf '%s' "${DATABASE_MAX_TTL}" | awk ' - /h$/ { gsub(/h/,""); print $0 * 3600; next } - /m$/ { gsub(/m/,""); print $0 * 60; next } - /s$/ { gsub(/s/,""); print $0 + 0; next } - { print $0 + 0 }')" - -if [ "${LEASE_TTL}" -le "${MAX_TTL_SECONDS}" ]; then - pass "TTL is within the configured maximum (${DATABASE_MAX_TTL})" -else - fail "TTL is within the configured maximum" "${LEASE_TTL}s exceeds ${MAX_TTL_SECONDS}s" -fi - -suite "Each request yields a distinct credential" - -CRED2="$(issue_credential "${TOKEN_A_WRITER}" "${ROLE_A_RO}")" -IFS=$'\t' read -r CRED2_USER CRED2_PASS CRED2_LEASE _ <<< "${CRED2}" - -# Reuse would make credentials untraceable to a specific request and would mean -# revoking one consumer's access revokes everyone's. -if [ "${CRED_USER}" != "${CRED2_USER}" ]; then - pass "a second request issues a different username" -else - fail "a second request issues a different username" "both requests returned ${CRED_USER}" -fi - -if [ "${CRED_PASS}" != "${CRED2_PASS}" ]; then - pass "a second request issues a different password" -else - fail "a second request issues a different password" "passwords are identical" -fi - -suite "Both privilege tiers are issuable" - -RW="$(issue_credential "${TOKEN_A_WRITER}" "${ROLE_A_RW}")" || { - fail "tenant can request the readwrite role" "HTTP ${ASSERT_STATUS}" - finish -} -pass "tenant can request the readwrite role" -IFS=$'\t' read -r RW_USER _ RW_LEASE _ <<< "${RW}" - -revoke_lease "${TOKEN_A_WRITER}" "${CRED_LEASE}" || true -revoke_lease "${TOKEN_A_WRITER}" "${CRED2_LEASE}" || true -revoke_lease "${TOKEN_A_WRITER}" "${RW_LEASE}" || true -wait_until_db_role_gone "${CRED_USER}" 15 || true -wait_until_db_role_gone "${CRED2_USER}" 15 || true -wait_until_db_role_gone "${RW_USER}" 15 || true - -finish diff --git a/tests/conformance/credentials/020-least-privilege.sh b/tests/conformance/credentials/020-least-privilege.sh deleted file mode 100755 index 89eec59..0000000 --- a/tests/conformance/credentials/020-least-privilege.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Readonly SELECT only. Readwrite DML, not DDL/superuser. -set -euo pipefail - -COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)" -# shellcheck source=../common/setup.sh -. "${COMMON_DIR}/setup.sh" -# shellcheck source=../common/database.sh -. "${COMMON_DIR}/database.sh" - -credentials_require_env -conformance_login_tenants - -RO="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readonly")" \ - || die "could not issue a readonly credential" -IFS=$'\t' read -r RO_USER RO_PASS RO_LEASE _ <<< "${RO}" - -RW="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readwrite")" \ - || die "could not issue a readwrite credential" -IFS=$'\t' read -r RW_USER RW_PASS RW_LEASE _ <<< "${RW}" - -suite "Readonly credential can read" - -assert_command_succeeds "readonly can SELECT from the application schema" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" "SELECT count(*) FROM app.customers;" - -# Confirms real rows come back rather than an empty result that an -# over-restrictive grant would also produce without erroring. -ROW_COUNT="$(db_query "${RO_USER}" "${RO_PASS}" "SELECT count(*) FROM app.customers;" | tr -d '[:space:]')" -if [ "${ROW_COUNT}" -ge 1 ] 2>/dev/null; then - pass "readonly actually returns rows (${ROW_COUNT})" -else - fail "readonly actually returns rows" "got '${ROW_COUNT}' - SELECT succeeded but returned nothing" -fi - -suite "Readonly credential cannot write" - -assert_command_fails "readonly cannot INSERT" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" \ - "INSERT INTO app.customers (name) VALUES ('should-not-exist');" - -assert_command_fails "readonly cannot UPDATE" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" \ - "UPDATE app.customers SET name = 'modified' WHERE id = 1;" - -assert_command_fails "readonly cannot DELETE" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" \ - "DELETE FROM app.customers WHERE id = 1;" - -assert_command_fails "readonly cannot CREATE TABLE" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" \ - "CREATE TABLE app.should_not_exist (id int);" - -assert_command_fails "readonly cannot DROP TABLE" \ - db_query_succeeds "${RO_USER}" "${RO_PASS}" "DROP TABLE app.orders;" - -suite "Readwrite credential can write, within limits" - -assert_command_succeeds "readwrite can INSERT" \ - db_query_succeeds "${RW_USER}" "${RW_PASS}" \ - "INSERT INTO app.customers (name) VALUES ('FAKE-conformance-row');" - -assert_command_succeeds "readwrite can DELETE its own row" \ - db_query_succeeds "${RW_USER}" "${RW_PASS}" \ - "DELETE FROM app.customers WHERE name = 'FAKE-conformance-row';" - -# Data access is not schema authority. A credential that can add or drop tables -# can change the application's contract, which is a different privilege from -# changing its rows. -assert_command_fails "readwrite cannot CREATE TABLE" \ - db_query_succeeds "${RW_USER}" "${RW_PASS}" \ - "CREATE TABLE app.should_not_exist (id int);" - -suite "Neither credential is privileged" - -for pair in "readonly:${RO_USER}:${RO_PASS}" "readwrite:${RW_USER}:${RW_PASS}"; do - label="${pair%%:*}"; rest="${pair#*:}"; user="${rest%%:*}"; pass="${rest#*:}" - - IS_SUPER="$(db_admin_query "SELECT rolsuper FROM pg_roles WHERE rolname = '${user}';" | tr -d '[:space:]')" - assert_eq "f" "${IS_SUPER}" "${label} credential is not a superuser" - - CAN_CREATE_ROLE="$(db_admin_query "SELECT rolcreaterole FROM pg_roles WHERE rolname = '${user}';" | tr -d '[:space:]')" - # CREATEROLE would let a dynamic credential mint a permanent user and outlive - # its own TTL entirely - the single change that would defeat expiry. - assert_eq "f" "${CAN_CREATE_ROLE}" "${label} credential cannot create roles" - - CAN_CREATE_DB="$(db_admin_query "SELECT rolcreatedb FROM pg_roles WHERE rolname = '${user}';" | tr -d '[:space:]')" - assert_eq "f" "${CAN_CREATE_DB}" "${label} credential cannot create databases" - - assert_command_fails "${label} cannot read pg_shadow (password hashes)" \ - db_query_succeeds "${user}" "${pass}" "SELECT * FROM pg_shadow;" -done - -suite "Credentials expire" - -for pair in "readonly:${RO_USER}" "readwrite:${RW_USER}"; do - label="${pair%%:*}"; user="${pair#*:}" - VALID_UNTIL="$(db_admin_query "SELECT rolvaliduntil FROM pg_roles WHERE rolname = '${user}';" | tr -d '[:space:]')" - # Belt and braces alongside Vault's lease: even if lease revocation failed - # entirely, PostgreSQL itself would refuse the login after this timestamp. - assert_not_empty "${VALID_UNTIL}" "${label} credential has a VALID UNTIL expiry in PostgreSQL" -done - -revoke_lease "${TOKEN_A_WRITER}" "${RO_LEASE}" || true -revoke_lease "${TOKEN_A_WRITER}" "${RW_LEASE}" || true -wait_until_db_role_gone "${RO_USER}" 15 || true -wait_until_db_role_gone "${RW_USER}" 15 || true - -finish diff --git a/tests/conformance/credentials/030-cross-tenant.sh b/tests/conformance/credentials/030-cross-tenant.sh deleted file mode 100755 index 3dc92c4..0000000 --- a/tests/conformance/credentials/030-cross-tenant.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# One tenant cannot request another tenant's database credentials. -set -euo pipefail - -COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)" -# shellcheck source=../common/setup.sh -. "${COMMON_DIR}/setup.sh" -# shellcheck source=../common/database.sh -. "${COMMON_DIR}/database.sh" - -credentials_require_env -conformance_login_tenants - -suite "A tenant can obtain its own credentials (control)" - -CRED_A="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readonly")" || { - fail "${TENANT_A} can request its own readonly credential" "HTTP ${ASSERT_STATUS}" - finish -} -pass "${TENANT_A} can request its own readonly credential" -IFS=$'\t' read -r USER_A _ LEASE_A _ <<< "${CRED_A}" - -CRED_B="$(issue_credential "${TOKEN_B_WRITER}" "tenant-${TENANT_B}-readonly")" || { - fail "${TENANT_B} can request its own readonly credential" "HTTP ${ASSERT_STATUS}" - finish -} -pass "${TENANT_B} can request its own readonly credential" -IFS=$'\t' read -r USER_B _ LEASE_B _ <<< "${CRED_B}" - -suite "Cross-tenant credential requests are denied" - -assert_denied "${TOKEN_A_WRITER}" GET "${DATABASE_MOUNT}/creds/tenant-${TENANT_B}-readonly" "" \ - "${TENANT_A} cannot request ${TENANT_B}'s readonly credential" - -assert_denied "${TOKEN_A_WRITER}" GET "${DATABASE_MOUNT}/creds/tenant-${TENANT_B}-readwrite" "" \ - "${TENANT_A} cannot request ${TENANT_B}'s readwrite credential" - -assert_denied "${TOKEN_B_WRITER}" GET "${DATABASE_MOUNT}/creds/tenant-${TENANT_A}-readwrite" "" \ - "${TENANT_B} cannot request ${TENANT_A}'s readwrite credential" - -assert_denied "${TOKEN_A_READER}" GET "${DATABASE_MOUNT}/creds/tenant-${TENANT_B}-readonly" "" \ - "${TENANT_A}'s reader identity cannot request ${TENANT_B}'s credential" - -suite "Engine administration is denied to tenants" - -# Creating a role is how a tenant would grant itself access to anything the -# vault_admin database user can reach, bypassing the per-tenant role boundary -# entirely. -assert_denied "${TOKEN_A_WRITER}" POST "${DATABASE_MOUNT}/roles/tenant-rogue" \ - '{"db_name":"app","creation_statements":["CREATE ROLE \"{{name}}\" SUPERUSER LOGIN PASSWORD '"'"'{{password}}'"'"';"]}' \ - "a tenant cannot define its own database role" - -assert_denied "${TOKEN_A_WRITER}" POST \ - "${DATABASE_MOUNT}/roles/tenant-${TENANT_A}-readonly" \ - '{"db_name":"app","creation_statements":["CREATE ROLE \"{{name}}\" SUPERUSER LOGIN PASSWORD '"'"'{{password}}'"'"';"]}' \ - "a tenant cannot redefine its own role to escalate privileges" - -assert_denied "${TOKEN_A_WRITER}" GET "${DATABASE_MOUNT}/config/app" "" \ - "a tenant cannot read the engine's connection configuration" - -assert_denied "${TOKEN_A_WRITER}" POST "${DATABASE_MOUNT}/rotate-root/app" '{}' \ - "a tenant cannot rotate the engine's root credential" - -assert_denied "${TOKEN_A_WRITER}" GET "${DATABASE_MOUNT}/roles?list=true" "" \ - "a tenant cannot enumerate all database roles" - -suite "Reader identities cannot obtain write credentials" - -# The reader AppRole deliberately does not carry the database policy. A -# read-only tenant identity able to mint a readwrite database credential would -# make the reader/writer distinction cosmetic. -assert_denied "${TOKEN_A_READER}" GET "${DATABASE_MOUNT}/creds/tenant-${TENANT_A}-readwrite" "" \ - "${TENANT_A}'s reader identity cannot request a readwrite credential" - -suite "Isolation is unchanged by dynamic credentials" - -# Re-asserted here because enabling the database engine adds policies to the -# writer role. Additive changes are exactly where a widened deny matrix would -# go unnoticed - the isolation suite runs without the database engine and so -# would never see it. -conformance_seed_fixtures - -assert_denied "${TOKEN_A_READER}" GET "$(kv_data "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" "" \ - "KV cross-tenant read is still denied with credentials enabled" - -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/metadata/${TENANT_PREFIX}?list=true" "" \ - "tenant enumeration is still denied with credentials enabled" - -assert_allowed "${TOKEN_A_READER}" GET "$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" "" \ - "same-tenant KV read still works with credentials enabled" - -revoke_lease "${TOKEN_A_WRITER}" "${LEASE_A}" || true -revoke_lease "${TOKEN_B_WRITER}" "${LEASE_B}" || true -wait_until_db_role_gone "${USER_A}" 15 || true -wait_until_db_role_gone "${USER_B}" 15 || true - -finish diff --git a/tests/conformance/credentials/040-revocation.sh b/tests/conformance/credentials/040-revocation.sh deleted file mode 100755 index fe190d3..0000000 --- a/tests/conformance/credentials/040-revocation.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# Explicit revoke and TTL expiry stop the credential and drop the DB role. -set -euo pipefail - -COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)" -# shellcheck source=../common/setup.sh -. "${COMMON_DIR}/setup.sh" -# shellcheck source=../common/database.sh -. "${COMMON_DIR}/database.sh" - -credentials_require_env -conformance_login_tenants - -PROVISIONING_TOKEN="${VAULT_TOKEN}" - -# Explicit revocation -suite "Explicit revocation" - -CRED="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readonly")" \ - || die "could not issue a credential to revoke" -IFS=$'\t' read -r CRED_USER CRED_PASS CRED_LEASE _ <<< "${CRED}" - -assert_command_succeeds "credential works before revocation" \ - db_query_succeeds "${CRED_USER}" "${CRED_PASS}" "SELECT 1" - -as_token "${TOKEN_A_WRITER}" PUT "sys/leases/revoke" \ - "$(jq -nc --arg l "${CRED_LEASE}" '{lease_id: $l}')" -case "${ASSERT_STATUS}" in - 2*) pass "tenant can revoke its own lease" ;; - *) fail "tenant can revoke its own lease" "HTTP ${ASSERT_STATUS}" ;; -esac - -# Revocation is asynchronous in Vault. A short bounded wait, then assert - -# rather than a fixed sleep long enough to always pass, which would hide a -# revocation path that is merely very slow. -revoked=false -for _ in 1 2 3 4 5 6 7 8 9 10; do - db_query_succeeds "${CRED_USER}" "${CRED_PASS}" "SELECT 1" || { revoked=true; break; } - sleep 1 -done - -if [ "${revoked}" = "true" ]; then - pass "revoked credential can no longer connect" -else - fail "revoked credential can no longer connect" \ - "user ${CRED_USER} still authenticates 10s after revocation - access outlives revocation" -fi - -if db_role_exists "${CRED_USER}"; then - fail "revoked credential's database role is dropped" \ - "role ${CRED_USER} still exists in pg_roles - this is an orphan" -else - pass "revoked credential's database role is dropped" -fi - -# TTL expiry -suite "TTL expiry" - -# A dedicated short-TTL role: waiting out the platform default of an hour is -# not viable in CI, and shortening the default just for tests would mean the -# tested configuration is not the shipped one. -EXPIRY_ROLE="tenant-${TENANT_A}-ttltest" -VAULT_TOKEN="${PROVISIONING_TOKEN}" - -vault_request POST "${DATABASE_MOUNT}/roles/${EXPIRY_ROLE}" "$(jq -nc \ - --arg db "${DATABASE_CONNECTION_NAME:-app}" \ - '{ - db_name: $db, - creation_statements: [ - "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '"'"'{{password}}'"'"' VALID UNTIL '"'"'{{expiration}}'"'"';", - "GRANT app_readonly TO \"{{name}}\";" - ], - default_ttl: "10s", - max_ttl: "20s" - }')" >/dev/null 2>&1 - -if [ "${VAULT_STATUS}" != "204" ] && [ "${VAULT_STATUS}" != "200" ]; then - fail "short-TTL test role created" "HTTP ${VAULT_STATUS} - cannot test expiry" - finish -fi -pass "short-TTL test role created" - -EXP_CRED="$(issue_credential "${TOKEN_A_WRITER}" "${EXPIRY_ROLE}")" || { - fail "short-TTL credential issued" "HTTP ${ASSERT_STATUS}" - finish -} -IFS=$'\t' read -r EXP_USER EXP_PASS _ _ <<< "${EXP_CRED}" - -assert_command_succeeds "short-TTL credential works immediately after issue" \ - db_query_succeeds "${EXP_USER}" "${EXP_PASS}" "SELECT 1" - -printf ' .... waiting for the 10s TTL to elapse\n' -expired=false -for _ in $(seq 1 30); do - sleep 1 - db_query_succeeds "${EXP_USER}" "${EXP_PASS}" "SELECT 1" || { expired=true; break; } -done - -if [ "${expired}" = "true" ]; then - pass "credential stops working after its TTL expires" -else - fail "credential stops working after its TTL expires" \ - "user ${EXP_USER} still authenticates 30s after a 10s TTL - credentials do not expire" -fi - -if db_role_exists "${EXP_USER}"; then - fail "expired credential's database role is dropped" \ - "role ${EXP_USER} remains in pg_roles after expiry - orphaned" -else - pass "expired credential's database role is dropped" -fi - -# Revoking one credential does not affect another -suite "Revocation is scoped to a single lease" - -KEEP="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readonly")" -IFS=$'\t' read -r KEEP_USER KEEP_PASS KEEP_LEASE _ <<< "${KEEP}" - -DROP="$(issue_credential "${TOKEN_A_WRITER}" "tenant-${TENANT_A}-readonly")" -IFS=$'\t' read -r DROP_USER DROP_PASS DROP_LEASE _ <<< "${DROP}" - -as_token "${TOKEN_A_WRITER}" PUT "sys/leases/revoke" \ - "$(jq -nc --arg l "${DROP_LEASE}" '{lease_id: $l}')" -sleep 2 - -# Over-broad revocation is as damaging as failed revocation: revoking one -# application's credential must not take down every other consumer of the same -# role. -assert_command_succeeds "an unrelated credential still works after another is revoked" \ - db_query_succeeds "${KEEP_USER}" "${KEEP_PASS}" "SELECT 1" - -assert_command_fails "the revoked credential is the one that stopped working" \ - db_query_succeeds "${DROP_USER}" "${DROP_PASS}" "SELECT 1" - -revoke_lease "${TOKEN_A_WRITER}" "${KEEP_LEASE}" || true -wait_until_db_role_gone "${KEEP_USER}" 15 || true - -VAULT_TOKEN="${PROVISIONING_TOKEN}" -vault_request DELETE "${DATABASE_MOUNT}/roles/${EXPIRY_ROLE}" >/dev/null 2>&1 || true - -finish diff --git a/tests/conformance/credentials/050-residue.sh b/tests/conformance/credentials/050-residue.sh deleted file mode 100755 index 6632018..0000000 --- a/tests/conformance/credentials/050-residue.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -# After revoke, no Vault-created DB roles remain. Scan is self-tested first. -set -euo pipefail - -COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)" -# shellcheck source=../common/setup.sh -. "${COMMON_DIR}/setup.sh" -# shellcheck source=../common/database.sh -. "${COMMON_DIR}/database.sh" - -credentials_require_env -conformance_login_tenants - -PROVISIONING_TOKEN="${VAULT_TOKEN}" - -# 1. Detector self-test - runs FIRST. -# -# If the scan cannot detect a planted orphan, every result below is -# meaningless, and finding that out after reporting "zero orphans" is the -# failure mode this ordering avoids. -suite "Residue detector self-test" - -PLANTED_ROLE="${VAULT_ROLE_PREFIX}selftest-planted-orphan" - -db_admin_query "DROP ROLE IF EXISTS \"${PLANTED_ROLE}\";" >/dev/null 2>&1 || true -BASELINE="$(count_vault_db_roles)" - -if db_admin_query "CREATE ROLE \"${PLANTED_ROLE}\" NOLOGIN;" >/dev/null 2>&1; then - pass "planted a deliberate orphan role" -else - fail "planted a deliberate orphan role" \ - "could not create ${PLANTED_ROLE} as ${DB_ADMIN_USER} - the self-test cannot run, so the scan below is unverified" - finish -fi - -AFTER_PLANT="$(count_vault_db_roles)" - -if [ "${AFTER_PLANT}" -gt "${BASELINE}" ]; then - pass "the scan detects a planted orphan (${BASELINE} -> ${AFTER_PLANT})" -else - fail "the scan detects a planted orphan" \ - "count did not increase (${BASELINE} -> ${AFTER_PLANT}) - the residue scan is BROKEN and cannot be trusted" -fi - -if list_vault_db_roles | grep -q "${PLANTED_ROLE}"; then - pass "the planted orphan is named in the scan output" -else - fail "the planted orphan is named in the scan output" \ - "the scan counts roles it cannot list - orphan reports would be unactionable" -fi - -db_admin_query "DROP ROLE IF EXISTS \"${PLANTED_ROLE}\";" >/dev/null 2>&1 || true - -if db_role_exists "${PLANTED_ROLE}"; then - fail "planted orphan removed after the self-test" "${PLANTED_ROLE} still exists" -else - pass "planted orphan removed after the self-test" -fi - -# 2. Issue, then revoke everything, then scan. -suite "Issued credentials leave no residue" - -ISSUED_USERS="" -ISSUED_RECORDS="" -for role in "tenant-${TENANT_A}-readonly" "tenant-${TENANT_A}-readwrite" "tenant-${TENANT_B}-readonly"; do - token="${TOKEN_A_WRITER}" - case "${role}" in *"${TENANT_B}"*) token="${TOKEN_B_WRITER}" ;; esac - - if cred="$(issue_credential "${token}" "${role}")"; then - IFS=$'\t' read -r u _ lease _ <<< "${cred}" - ISSUED_USERS="${ISSUED_USERS} ${u}" - ISSUED_RECORDS="${ISSUED_RECORDS}${token}"$'\t'"${lease}"$'\n' - fi -done - -ISSUED_COUNT="$(printf '%s' "${ISSUED_USERS}" | wc -w | tr -d '[:space:]')" -if [ "${ISSUED_COUNT}" -ge 3 ]; then - pass "issued ${ISSUED_COUNT} credentials to clean up" -else - fail "issued credentials to clean up" "only ${ISSUED_COUNT} were issued - the cleanup assertion would be weak" -fi - -# Present before revocation. Without this, "no roles found afterwards" could -# simply mean none were ever created. -present=0 -for u in ${ISSUED_USERS}; do - db_role_exists "${u}" && present=$((present + 1)) -done -assert_eq "${ISSUED_COUNT}" "${present}" "all issued credentials exist in pg_roles before revocation" - -while IFS=$'\t' read -r token lease; do - [ -n "${lease}" ] || continue - revoke_lease "${token}" "${lease}" || true -done <<< "${ISSUED_RECORDS}" - -# Bounded wait rather than a fixed sleep, for the same reason as in the -# revocation suite: a sleep long enough to always pass hides slow revocation. -cleaned=false -for _ in $(seq 1 15); do - remaining=0 - for u in ${ISSUED_USERS}; do - db_role_exists "${u}" && remaining=$((remaining + 1)) - done - [ "${remaining}" -eq 0 ] && { cleaned=true; break; } - sleep 1 -done - -if [ "${cleaned}" = "true" ]; then - pass "every revoked credential's role was dropped from PostgreSQL" -else - orphans="" - for u in ${ISSUED_USERS}; do - db_role_exists "${u}" && orphans="${orphans} ${u}" - done - fail "every revoked credential's role was dropped from PostgreSQL" \ - "orphaned roles remain:${orphans} - these are credentials nobody is tracking" -fi - -# 3. Whole-mount scan. -suite "No unaccounted Vault-created roles remain" - -FINAL_ROLES="$(list_vault_db_roles | grep -v '^[[:space:]]*$' || true)" -FINAL_COUNT="$(printf '%s' "${FINAL_ROLES}" | grep -c . || true)" - -# Roles from a still-live lease are legitimate, so the scan reports what it -# found rather than failing outright - an unexplained count is a finding to -# investigate, not automatically a defect. -if [ "${FINAL_COUNT}" -eq 0 ]; then - pass "zero ${VAULT_ROLE_PREFIX}* roles remain in pg_roles" -else - printf ' NOTE %d %s* role(s) still present:\n' "${FINAL_COUNT}" "${VAULT_ROLE_PREFIX}" - printf '%s\n' "${FINAL_ROLES}" | sed 's/^/ /' - - VAULT_TOKEN="${PROVISIONING_TOKEN}" - live_leases=0 - for role in "tenant-${TENANT_A}-readonly" "tenant-${TENANT_A}-readwrite" \ - "tenant-${TENANT_B}-readonly" "tenant-${TENANT_B}-readwrite"; do - if vault_request GET "sys/leases/lookup/${DATABASE_MOUNT}/creds/${role}?list=true" >/dev/null 2>&1; then - n="$(vault_body | jq -r '.data.keys | length' 2>/dev/null || echo 0)" - live_leases=$((live_leases + n)) - fi - done - - if [ "${live_leases}" -ge "${FINAL_COUNT}" ]; then - pass "all remaining roles are accounted for by ${live_leases} live lease(s)" - else - fail "all remaining roles are accounted for by live leases" \ - "${FINAL_COUNT} role(s) present but only ${live_leases} live lease(s) - the difference is orphaned residue" - fi -fi - -finish diff --git a/tests/conformance/isolation/010-kv-lifecycle.sh b/tests/conformance/isolation/010-kv-lifecycle.sh deleted file mode 100755 index fd4c279..0000000 --- a/tests/conformance/isolation/010-kv-lifecycle.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# KV v2 lifecycle. Positive path first, then reader cannot mutate. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants - -suite "KV v2 lifecycle (positive path)" - -A_SECRET="$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" -A_META="$(kv_meta "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" - -# KV v2 metadata delete removes every version of this key. Without it, a -# leftover from a previous conformance run makes "exactly two versions" fail -# even though versioning still works. Reset is scoped to this suite's fixture. -as_token "${TOKEN_A_WRITER}" DELETE "${A_META}" -case "${ASSERT_STATUS}" in - 2*|404) ;; - *) die "could not reset lifecycle fixture for ${TENANT_A} (HTTP ${ASSERT_STATUS})" ;; -esac - -assert_allowed "${TOKEN_A_WRITER}" POST "${A_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "${FIXTURE_VALUE_A}")" \ - "writer creates a secret in its own subtree" - -assert_allowed "${TOKEN_A_WRITER}" GET "${A_SECRET}" "" \ - "writer reads back its own secret" - -# Confirms the value survived the KV v2 data envelope intact. A write that -# nests the payload one level too deep succeeds and reads back as valid JSON, -# so only checking the value catches it. -as_token "${TOKEN_A_WRITER}" GET "${A_SECRET}" -assert_eq "${FIXTURE_VALUE_A}" \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r --arg k "${FIXTURE_SECRET_KEY}" '.data.data[$k] // empty')" \ - "stored value round-trips unchanged" - -assert_allowed "${TOKEN_A_WRITER}" POST "${A_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-rotated-value-tenant-a")" \ - "writer rotates the secret to a new version" - -as_token "${TOKEN_A_WRITER}" GET "${A_META}" -assert_eq "2" \ - "$(printf '%s' "${ASSERT_BODY}" | jq -r '.data.versions | length')" \ - "version history records both versions" - -assert_allowed "${TOKEN_A_WRITER}" GET "${A_SECRET}?version=1" "" \ - "an earlier version is retrievable for rollback" - -assert_allowed "${TOKEN_A_READER}" GET "${A_SECRET}" "" \ - "reader reads its own tenant's secret" - -suite "Reader cannot mutate" - -# Each of these is a distinct capability. Granting one by accident while -# intending another is the common policy slip, so they are asserted separately -# rather than as a single "reader cannot write". -assert_denied "${TOKEN_A_READER}" POST "${A_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-should-never-be-written")" \ - "reader cannot create or update a secret" - -assert_denied "${TOKEN_A_READER}" DELETE "${A_SECRET}" "" \ - "reader cannot soft-delete the latest version" - -assert_denied "${TOKEN_A_READER}" POST \ - "${KV_MOUNT}/destroy/${TENANT_PREFIX}/${TENANT_A}/${FIXTURE_SECRET_NAME}" \ - '{"versions":[1]}' \ - "reader cannot permanently destroy a version" - -assert_denied "${TOKEN_A_READER}" DELETE "${A_META}" "" \ - "reader cannot delete secret metadata" - -suite "Writer lifecycle completes" - -assert_allowed "${TOKEN_A_WRITER}" DELETE "${A_SECRET}" "" \ - "writer soft-deletes the latest version" - -assert_allowed "${TOKEN_A_WRITER}" POST \ - "${KV_MOUNT}/undelete/${TENANT_PREFIX}/${TENANT_A}/${FIXTURE_SECRET_NAME}" \ - '{"versions":[2]}' \ - "writer restores the soft-deleted version" - -finish diff --git a/tests/conformance/isolation/020-cross-tenant.sh b/tests/conformance/isolation/020-cross-tenant.sh deleted file mode 100755 index 8e224d2..0000000 --- a/tests/conformance/isolation/020-cross-tenant.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Cross-tenant matrix. Denials must be HTTP 403. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants -conformance_seed_fixtures - -A_SECRET="$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" -B_SECRET="$(kv_data "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" -A_META="$(kv_meta "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" -B_META="$(kv_meta "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" - -# The allow half of the matrix runs first. Without it, a Vault that denies -# everything - a broken mount, a failed bootstrap, an expired token - would -# score a perfect pass on every deny assertion below. -suite "Same-tenant access is permitted (control)" - -assert_allowed "${TOKEN_A_READER}" GET "${A_SECRET}" "" "${TENANT_A} reads ${TENANT_A}" -assert_allowed "${TOKEN_B_READER}" GET "${B_SECRET}" "" "${TENANT_B} reads ${TENANT_B}" - -suite "Cross-tenant read is denied" - -assert_denied "${TOKEN_A_READER}" GET "${B_SECRET}" "" "${TENANT_A} reader cannot read ${TENANT_B}" -assert_denied "${TOKEN_B_READER}" GET "${A_SECRET}" "" "${TENANT_B} reader cannot read ${TENANT_A}" -assert_denied "${TOKEN_A_WRITER}" GET "${B_SECRET}" "" "${TENANT_A} writer cannot read ${TENANT_B}" -assert_denied "${TOKEN_B_WRITER}" GET "${A_SECRET}" "" "${TENANT_B} writer cannot read ${TENANT_A}" - -suite "Cross-tenant write is denied" - -# Checked independently of read. A policy that denies read while permitting -# write would let one tenant silently corrupt another's secrets - arguably -# worse than disclosure, and invisible to a read-only test. -assert_denied "${TOKEN_A_WRITER}" POST "${B_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-cross-tenant-write")" \ - "${TENANT_A} writer cannot write into ${TENANT_B}" - -assert_denied "${TOKEN_B_WRITER}" POST "${A_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-cross-tenant-write")" \ - "${TENANT_B} writer cannot write into ${TENANT_A}" - -# A path that does not exist yet, so a permissive policy would return 404 -# rather than 403. Asserting 403 proves the denial comes from authorization and -# not from absence. -assert_denied "${TOKEN_A_WRITER}" POST "$(kv_data "${TENANT_B}" "newly-planted-secret")" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-planted")" \ - "${TENANT_A} cannot create a NEW secret inside ${TENANT_B}" - -suite "Cross-tenant delete and destroy are denied" - -assert_denied "${TOKEN_A_WRITER}" DELETE "${B_SECRET}" "" \ - "${TENANT_A} cannot soft-delete ${TENANT_B}'s secret" - -assert_denied "${TOKEN_A_WRITER}" POST \ - "${KV_MOUNT}/destroy/${TENANT_PREFIX}/${TENANT_B}/${FIXTURE_SECRET_NAME}" \ - '{"versions":[1]}' \ - "${TENANT_A} cannot destroy ${TENANT_B}'s secret versions" - -assert_denied "${TOKEN_A_WRITER}" DELETE "${B_META}" "" \ - "${TENANT_A} cannot delete ${TENANT_B}'s metadata" - -suite "Cross-tenant metadata access is denied" - -# Metadata is a separate KV v2 API surface. A policy covering data/ but not -# metadata/ leaks version counts, timestamps, and key names across tenants -# while every data-path test still passes. -assert_denied "${TOKEN_A_READER}" GET "${B_META}" "" \ - "${TENANT_A} cannot read ${TENANT_B}'s secret metadata" - -assert_denied "${TOKEN_B_READER}" GET "${A_META}" "" \ - "${TENANT_B} cannot read ${TENANT_A}'s secret metadata" - -suite "Tenant enumeration is denied" - -# Listing the shared parent would reveal the full customer list. That is a -# commercially sensitive disclosure even when no secret value is exposed. -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/metadata/${TENANT_PREFIX}?list=true" "" \ - "${TENANT_A} cannot list the tenant prefix" - -assert_denied "${TOKEN_B_READER}" GET "${KV_MOUNT}/metadata/${TENANT_PREFIX}?list=true" "" \ - "${TENANT_B} cannot list the tenant prefix" - -assert_denied "${TOKEN_A_READER}" GET "$(kv_meta "${TENANT_B}" "")?list=true" "" \ - "${TENANT_A} cannot list inside ${TENANT_B}'s subtree" - -finish diff --git a/tests/conformance/isolation/030-path-traversal.sh b/tests/conformance/isolation/030-path-traversal.sh deleted file mode 100755 index d2721ba..0000000 --- a/tests/conformance/isolation/030-path-traversal.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# Parent, sibling, and prefix-anchor denials. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants -conformance_seed_fixtures - -suite "Parent paths are denied" - -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/data/${TENANT_PREFIX}" "" \ - "cannot read the tenant prefix itself" - -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/data" "" \ - "cannot read the mount root" - -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/metadata/${TENANT_PREFIX}" "" \ - "cannot read the tenant prefix metadata" - -assert_denied "${TOKEN_A_WRITER}" POST "${KV_MOUNT}/data/${TENANT_PREFIX}/shared-secret" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-planted-at-parent")" \ - "cannot plant a secret at the shared parent level" - -suite "Sibling paths outside the tenant subtree are denied" - -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/data/platform/root-credentials" "" \ - "cannot read a sibling prefix beside the tenant prefix" - -assert_denied "${TOKEN_A_WRITER}" POST "${KV_MOUNT}/data/platform/root-credentials" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-planted-sibling")" \ - "cannot write to a sibling prefix" - -suite "Prefix-anchoring is exact" - -# The critical case. A rule ending "tenant-a*" instead of "tenant-a/*" matches -# every tenant whose ID merely starts with the same characters. Nothing else in -# the suite detects this, and it is a plausible typo. -assert_denied "${TOKEN_A_READER}" GET \ - "${KV_MOUNT}/data/${TENANT_PREFIX}/${TENANT_A}-extended/secret" "" \ - "a tenant ID that merely starts with '${TENANT_A}' is not reachable" - -assert_denied "${TOKEN_A_READER}" GET \ - "${KV_MOUNT}/data/${TENANT_PREFIX}/${TENANT_A}x/secret" "" \ - "appending a character to the tenant ID does not stay inside the subtree" - -suite "Traversal sequences do not escape the subtree" - -# Vault normalizes and rejects these, so the expected result is simply "not -# allowed". Asserted anyway: this suite is the contract's evidence that -# traversal was considered, and a future proxy or gateway in front of Vault is -# exactly the component that could reintroduce the problem. -for traversal in \ - "${KV_MOUNT}/data/${TENANT_PREFIX}/${TENANT_A}/../${TENANT_B}/${FIXTURE_SECRET_NAME}" \ - "${KV_MOUNT}/data/${TENANT_PREFIX}/${TENANT_A}/..%2f${TENANT_B}/${FIXTURE_SECRET_NAME}" \ - "${KV_MOUNT}/data/${TENANT_PREFIX}/${TENANT_A}//../${TENANT_B}/${FIXTURE_SECRET_NAME}" -do - as_token "${TOKEN_A_READER}" GET "${traversal}" - case "${ASSERT_STATUS}" in - 2*) fail "traversal blocked: ${traversal}" \ - "ACCESS WAS GRANTED (HTTP ${ASSERT_STATUS}) - this reaches another tenant's data" ;; - *) pass "traversal blocked (HTTP ${ASSERT_STATUS}): ${traversal##*"${TENANT_A}"/}" ;; - esac -done - -suite "Wildcard characters in a request path grant nothing" - -# A tenant asking for a literal wildcard must not receive a wildcard's worth of -# access. Vault treats `*` as a literal path segment, not a glob. -# customers/* is outside this tenant's allow, so that is a hard 403. -# customers/tenant-a/* is inside the allow; a missing literal key is 404. -assert_denied "${TOKEN_A_READER}" GET "${KV_MOUNT}/data/${TENANT_PREFIX}/*" "" \ - "requesting a wildcard path does not match every tenant" - -assert_not_granted "${TOKEN_A_READER}" GET "$(kv_data "${TENANT_A}" "")*" "" \ - "requesting a wildcard inside the tenant subtree returns no data" - -finish diff --git a/tests/conformance/isolation/040-admin-surfaces.sh b/tests/conformance/isolation/040-admin-surfaces.sh deleted file mode 100755 index ce8f594..0000000 --- a/tests/conformance/isolation/040-admin-surfaces.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Tenants cannot reach sys, auth, policy, or token admin. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants - -suite "Token self-management is permitted (control)" - -# A tenant must be able to manage its own token lifetime. If these fail, the -# denials below could simply mean the token is dead. -assert_allowed "${TOKEN_A_READER}" GET "auth/token/lookup-self" "" \ - "tenant can look up its own token" - -assert_allowed "${TOKEN_A_READER}" POST "auth/token/renew-self" '{}' \ - "tenant can renew its own token" - -suite "System administration is denied" - -assert_denied "${TOKEN_A_READER}" GET "sys/mounts" "" "cannot enumerate mounts" -assert_denied "${TOKEN_A_READER}" GET "sys/auth" "" "cannot enumerate auth methods" -assert_denied "${TOKEN_A_WRITER}" POST "sys/mounts/rogue" \ - '{"type":"kv","options":{"version":"2"}}' "cannot mount a new secrets engine" -assert_denied "${TOKEN_A_WRITER}" DELETE "sys/mounts/${KV_MOUNT}" "" \ - "cannot unmount the KV engine" - -suite "Audit configuration is denied" - -# An identity that can disable audit can act unobserved, which would defeat the -# evidence trail every other control depends on. -assert_denied "${TOKEN_A_READER}" GET "sys/audit" "" "cannot read audit configuration" -assert_denied "${TOKEN_A_WRITER}" DELETE "sys/audit/file" "" "cannot disable the audit device" - -suite "Policy administration is denied" - -assert_denied "${TOKEN_A_READER}" GET "sys/policies/acl?list=true" "" \ - "cannot list policies" - -assert_denied "${TOKEN_A_READER}" GET \ - "sys/policies/acl/$(tenant_policy_reader "${TENANT_B}")" "" \ - "cannot read another tenant's policy" - -# The direct escalation: rewrite your own policy to grant everything. -assert_denied "${TOKEN_A_WRITER}" PUT \ - "sys/policies/acl/$(tenant_policy_reader "${TENANT_A}")" \ - '{"policy":"path \"kv/data/*\" { capabilities = [\"read\", \"list\"] }"}' \ - "cannot rewrite its own policy to widen access" - -assert_denied "${TOKEN_A_WRITER}" PUT "sys/policies/acl/escalated" \ - '{"policy":"path \"*\" { capabilities = [\"sudo\", \"read\"] }"}' \ - "cannot create a new privileged policy" - -suite "Auth administration is denied" - -assert_denied "${TOKEN_A_READER}" GET "auth/${AUTH_MOUNT}/role?list=true" "" \ - "cannot enumerate AppRole roles" - -assert_denied "${TOKEN_A_READER}" GET \ - "auth/${AUTH_MOUNT}/role/$(tenant_role_reader "${TENANT_B}")/role-id" "" \ - "cannot read another tenant's role-id" - -# The indirect escalation: mint a credential for another tenant's role and log -# in as them. Closing the data path is not enough if the identity path is open. -assert_denied "${TOKEN_A_WRITER}" POST \ - "auth/${AUTH_MOUNT}/role/$(tenant_role_reader "${TENANT_B}")/secret-id" '{}' \ - "cannot issue a secret-id for another tenant's role" - -assert_denied "${TOKEN_A_WRITER}" POST \ - "auth/${AUTH_MOUNT}/role/tenant-rogue-writer" \ - '{"token_policies":["operator"]}' \ - "cannot create an AppRole role bound to a privileged policy" - -suite "Token administration is denied" - -# Creating a token with different policies is the shortest escalation path -# there is, and it leaves a token that outlives the request. -assert_denied "${TOKEN_A_WRITER}" POST "auth/token/create" \ - '{"policies":["operator"]}' \ - "cannot create a token carrying another policy" - -assert_denied "${TOKEN_A_WRITER}" POST "auth/token/create-orphan" \ - '{"policies":["provisioning"]}' \ - "cannot create an orphan token carrying the provisioning policy" - -assert_denied "${TOKEN_A_READER}" GET "auth/token/accessors?list=true" "" \ - "cannot enumerate token accessors" - -suite "Identity secrets engine is denied" - -# Entity aliases can attach additional policies to an identity, which is -# policy administration through a different endpoint. -assert_denied "${TOKEN_A_WRITER}" POST "identity/entity" \ - '{"name":"rogue","policies":["operator"]}' \ - "cannot create an identity entity with elevated policies" - -suite "Other secrets engines are denied" - -# Cubbyhole is per-token. Another token's keys are not addressable, so a -# missing name is 404 rather than ACL 403. Granting 2xx would be the breach. -assert_not_granted "${TOKEN_A_READER}" GET "cubbyhole/other" "" \ - "cannot read another token's cubbyhole" - -assert_denied "${TOKEN_A_READER}" GET "${DATABASE_MOUNT}/config/app" "" \ - "cannot read the database engine's connection configuration" - -finish diff --git a/tests/conformance/isolation/050-identity-separation.sh b/tests/conformance/isolation/050-identity-separation.sh deleted file mode 100755 index c781c58..0000000 --- a/tests/conformance/isolation/050-identity-separation.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# Provisioning cannot read tenant secrets. Wrong identity is denied. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants -conformance_seed_fixtures - -PROVISIONING_TOKEN="${VAULT_TOKEN}" - -A_SECRET="$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" -A_META="$(kv_meta "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" - -suite "Provisioning identity can provision (control)" - -# Establishes that the token is alive and privileged. Without this, every -# denial below could be explained by an expired or invalid token. -assert_allowed "${PROVISIONING_TOKEN}" GET \ - "auth/${AUTH_MOUNT}/role/$(tenant_role_reader "${TENANT_A}")/role-id" "" \ - "provisioning can read a tenant role-id" - -assert_allowed "${PROVISIONING_TOKEN}" GET \ - "sys/policies/acl/$(tenant_policy_reader "${TENANT_A}")" "" \ - "provisioning can read a tenant policy" - -suite "Provisioning identity cannot read tenant secrets" - -# Creating a tenant and reading a tenant are different privileges; automation -# needs only the first. If these pass as ALLOW, a compromised onboarding -# pipeline reads every tenant's secrets at once. -assert_denied "${PROVISIONING_TOKEN}" GET "${A_SECRET}" "" \ - "provisioning cannot read a tenant secret" - -assert_denied "${PROVISIONING_TOKEN}" GET "${A_META}" "" \ - "provisioning cannot read tenant secret metadata" - -assert_denied "${PROVISIONING_TOKEN}" POST "${A_SECRET}" \ - "$(kv_payload "${FIXTURE_SECRET_KEY}" "FAKE-written-by-provisioning")" \ - "provisioning cannot write into a tenant subtree" - -assert_denied "${PROVISIONING_TOKEN}" DELETE "${A_SECRET}" "" \ - "provisioning cannot delete a tenant secret" - -assert_denied "${PROVISIONING_TOKEN}" GET \ - "${KV_MOUNT}/metadata/${TENANT_PREFIX}?list=true" "" \ - "provisioning cannot enumerate tenants through the KV mount" - -suite "Provisioning identity cannot escalate" - -# Scoped to tenant-* precisely so it cannot rewrite the policy that constrains -# it. Without this boundary, every deny above is one API call away from being -# removed by the identity they constrain. -assert_denied "${PROVISIONING_TOKEN}" PUT "sys/policies/acl/provisioning" \ - '{"policy":"path \"kv/data/*\" { capabilities = [\"read\"] }"}' \ - "provisioning cannot rewrite its own policy" - -assert_denied "${PROVISIONING_TOKEN}" PUT "sys/policies/acl/operator" \ - '{"policy":"path \"*\" { capabilities = [\"sudo\"] }"}' \ - "provisioning cannot rewrite the operator policy" - -assert_denied "${PROVISIONING_TOKEN}" DELETE "sys/audit/file" "" \ - "provisioning cannot disable audit logging" - -assert_denied "${PROVISIONING_TOKEN}" POST "auth/token/create" \ - '{"policies":["operator"]}' \ - "provisioning cannot mint a token with another policy" - -suite "A token with no relevant policy is refused" - -# Vault's default-deny, asserted rather than assumed. The 'default' policy is -# attached to every token, so what it permits is a platform-wide floor. -DEFAULT_TOKEN="$( - vault_request POST "auth/token/create" \ - '{"policies":["default"],"ttl":"5m","no_default_policy":false}' >/dev/null 2>&1 - vault_body | jq -r '.auth.client_token // empty' -)" - -if [ -n "${DEFAULT_TOKEN}" ]; then - assert_denied "${DEFAULT_TOKEN}" GET "${A_SECRET}" "" \ - "a token holding only the default policy cannot read tenant secrets" - assert_denied "${DEFAULT_TOKEN}" GET "${KV_MOUNT}/metadata/${TENANT_PREFIX}?list=true" "" \ - "a token holding only the default policy cannot enumerate tenants" -else - # Expected when the suite runs as provisioning, which cannot mint tokens. - # Reported rather than skipped silently: an unrun assertion that prints - # nothing is indistinguishable from one that passed. - printf ' SKIP default-policy token checks (this identity cannot create tokens)\n' -fi - -suite "The wrong tenant's policy grants nothing" - -# Distinct from the cross-tenant matrix: there the token was correctly bound -# and asked for the wrong data. Here the binding itself is the subject - a -# tenant holding tenant B's identity gets tenant B's access and no more, which -# confirms access follows the policy rather than anything ambient. -assert_denied "${TOKEN_B_READER}" GET "${A_SECRET}" "" \ - "${TENANT_B}'s identity cannot read ${TENANT_A}'s secret" - -assert_allowed "${TOKEN_B_READER}" GET "$(kv_data "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" "" \ - "${TENANT_B}'s identity reads exactly its own secret" - -finish diff --git a/tests/conformance/isolation/060-audit.sh b/tests/conformance/isolation/060-audit.sh deleted file mode 100755 index e9b74ba..0000000 --- a/tests/conformance/isolation/060-audit.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -# Audit records allow/deny/login. Raw secret values must not appear. -set -euo pipefail - -# shellcheck source=../common/setup.sh -. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../common" && pwd)/setup.sh" - -conformance_require_env -conformance_login_tenants -conformance_seed_fixtures - -suite "Audit device is enabled" - -# Checked with the caller's token; provisioning is denied sys/audit by design, -# so a 403 here confirms the device question is answerable only by an operator. -as_token "${VAULT_TOKEN}" GET "sys/audit" -case "${ASSERT_STATUS}" in - 2*) - if printf '%s' "${ASSERT_BODY}" | jq -e '(.data // .) | to_entries | length > 0' >/dev/null 2>&1; then - pass "at least one audit device is enabled" - else - fail "at least one audit device is enabled" \ - "sys/audit returned no devices - Vault is running with no audit trail" - fi - ;; - 403) printf ' SKIP sys/audit not readable by this identity (expected for provisioning)\n' ;; - *) fail "at least one audit device is enabled" "unexpected HTTP ${ASSERT_STATUS}" ;; -esac - -if [ -z "${AUDIT_READ_CMD:-}" ]; then - # Deliberately no example command here. Naming one would hardcode a specific - # runtime into a suite whose entire purpose is to be runtime-independent - - # the target's README is where that belongs. - printf '\n SKIP audit content assertions: AUDIT_READ_CMD is not set.\n' - printf ' Set it to a command that prints the audit log for this\n' - printf ' environment; see local/README.md for the local value.\n' - finish -fi - -# Generate one request of each kind, then inspect the log. -A_SECRET="$(kv_data "${TENANT_A}" "${FIXTURE_SECRET_NAME}")" -B_SECRET="$(kv_data "${TENANT_B}" "${FIXTURE_SECRET_NAME}")" - -as_token "${TOKEN_A_READER}" GET "${A_SECRET}" # expected allow -as_token "${TOKEN_A_READER}" GET "${B_SECRET}" # expected deny - -# The file audit device writes synchronously before the response is returned, -# so no sleep is needed. A sleep here would mask a genuinely broken device by -# making the test pass whenever the log eventually caught up. -# -# Grep a temp file. Do not load the log into a bash string and printf it -# through a pipe: grep -q closes on the first match, printf gets SIGPIPE, and -# with pipefail the `if` looks like "not found" even when the needle is present. -AUDIT_LOG_FILE="$(mktemp "${TMPDIR:-/tmp}/vault-audit.XXXXXX")" -chmod 600 "${AUDIT_LOG_FILE}" -if ! eval "${AUDIT_READ_CMD}" >"${AUDIT_LOG_FILE}" 2>/dev/null; then - rm -f "${AUDIT_LOG_FILE}" - fail "audit log is readable" "AUDIT_READ_CMD failed: ${AUDIT_READ_CMD}" - finish -fi - -suite "Audit log content" - -if [ ! -s "${AUDIT_LOG_FILE}" ]; then - rm -f "${AUDIT_LOG_FILE}" - fail "audit log is readable" "AUDIT_READ_CMD produced no output: ${AUDIT_READ_CMD}" - finish -fi -pass "audit log is readable" - -if grep -F -q '"type":"response"' "${AUDIT_LOG_FILE}"; then - pass "audit log contains response entries" -else - fail "audit log contains response entries" "expected to find '\"type\":\"response\"'" -fi - -# Vault writes a request and a response entry for every operation, including -# denied ones. A device that logged only successes would omit precisely the -# events an investigation needs. -if grep -F -q "${TENANT_PREFIX}/${TENANT_A}" "${AUDIT_LOG_FILE}"; then - pass "the allowed read was recorded" -else - fail "the allowed read was recorded" "no entry references ${TENANT_PREFIX}/${TENANT_A}" -fi - -if grep -F -q 'permission denied' "${AUDIT_LOG_FILE}"; then - pass "the denied cross-tenant request was recorded" -else - fail "the denied cross-tenant request was recorded" \ - "no 'permission denied' entry found - denials are the events that matter most" -fi - -if grep -F -q "auth/${AUTH_MOUNT}/login" "${AUDIT_LOG_FILE}"; then - pass "AppRole authentication was recorded" -else - fail "AppRole authentication was recorded" "no login entry found" -fi - -suite "Audit log does not contain raw secret values" - -# The reason log_raw stays false. If secret values were written here, the audit -# log would become the most valuable secret store in the system - and one that -# is deliberately shipped to log aggregators. -for fixture_value in "${FIXTURE_VALUE_A}" "${FIXTURE_VALUE_B}"; do - if grep -F -q "${fixture_value}" "${AUDIT_LOG_FILE}"; then - fail "raw secret value is absent from the audit log" \ - "PLAINTEXT SECRET FOUND IN AUDIT LOG: '${fixture_value}' - check that log_raw is false" - else - pass "raw secret value '${fixture_value:0:12}...' is absent from the audit log" - fi -done - -# Positive control for the check above: HMAC output must actually be present, -# otherwise "no plaintext found" could simply mean nothing was logged at all. -if grep -F -q 'hmac-sha256:' "${AUDIT_LOG_FILE}"; then - pass "values are HMAC-ed rather than omitted" -else - fail "values are HMAC-ed rather than omitted" \ - "no hmac-sha256 markers found - the absence of plaintext above may be meaningless" -fi - -rm -f "${AUDIT_LOG_FILE}" -finish diff --git a/tests/lint/lint-self-test.sh b/tests/lint/lint-self-test.sh deleted file mode 100755 index 1ac0f6b..0000000 --- a/tests/lint/lint-self-test.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# Linter must reject every BAD-* fixture and accept GOOD-*. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -LINTER="${SCRIPT_DIR}/policy-lint.sh" -FIXTURE_DIR="${SCRIPT_DIR}/fixtures" - -PASSED=0 -FAILED=0 - -ok() { printf ' PASS %s\n' "$*"; PASSED=$((PASSED + 1)); } -bad() { printf ' FAIL %s\n' "$*"; FAILED=$((FAILED + 1)); } - -printf '\nPolicy linter self-test\n\n' - -# Fixtures named BAD-* must be rejected. If the linter accepts one, the rule it -# tests is gone or broken - which is the case this whole file exists to catch. -for fixture in "${FIXTURE_DIR}"/BAD-*.hcl; do - name="$(basename "${fixture}")" - if "${LINTER}" "${fixture}" >/dev/null 2>&1; then - bad "${name} was ACCEPTED but must be rejected" - else - ok "${name} correctly rejected" - fi -done - -# Fixtures named GOOD-* must pass. Without this direction, a linter that -# rejects everything unconditionally would score a perfect result above. -for fixture in "${FIXTURE_DIR}"/GOOD-*.hcl; do - name="$(basename "${fixture}")" - if "${LINTER}" "${fixture}" >/dev/null 2>&1; then - ok "${name} correctly accepted" - else - bad "${name} was REJECTED but must pass" - printf ' linter output:\n' - "${LINTER}" "${fixture}" 2>&1 | sed 's/^/ /' || true - fi -done - -printf '\n %d passed, %d failed\n\n' "${PASSED}" "${FAILED}" -[ "${FAILED}" -eq 0 ] || exit 1 diff --git a/tests/lint/policy-lint.sh b/tests/lint/policy-lint.sh deleted file mode 100755 index ac5adeb..0000000 --- a/tests/lint/policy-lint.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -# Reject ACL policies that silently break isolation. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../scripts/lib/common.sh -. "${SCRIPT_DIR}/../../scripts/lib/common.sh" - -platform_defaults - -[ $# -ge 1 ] || die "usage: $(basename "$0") [...]" - -# KV v2 exposes its API through these path segments. Anything else immediately -# after the mount name is not a real KV v2 path. -KV_V2_SEGMENTS="data metadata delete undelete destroy config subkeys" -VALID_CAPABILITIES="create read update delete list patch sudo deny recover subscribe" - -FINDINGS=0 - -finding() { - FINDINGS=$((FINDINGS + 1)) - printf ' [%d] %s\n' "${FINDINGS}" "$*" >&2 -} - -lint_file() { - local file="$1" - local policy_name; policy_name="$(basename "${file}" .hcl)" - local seen_paths="" - - [ -f "${file}" ] || { finding "no such file: ${file}"; return; } - - # Extract "pathcapabilities" pairs. A small awk state machine rather - # than a real HCL parser: policy files are a narrow, well-known subset of - # HCL, and adding an HCL toolchain dependency to lint five files is not a - # trade worth making. - local pairs - pairs="$(awk ' - /^[[:space:]]*#/ { next } - match($0, /path[[:space:]]+"[^"]+"/) { - p = substr($0, RSTART, RLENGTH) - sub(/^path[[:space:]]+"/, "", p); sub(/"$/, "", p) - current = p - caps = "" - next - } - /capabilities[[:space:]]*=/ { - line = $0 - sub(/.*\[/, "", line); sub(/\].*/, "", line) - gsub(/[" ]/, "", line) - if (current != "") { print current "\t" line; current = "" } - } - ' "${file}")" - - if [ -z "${pairs}" ]; then - finding "${policy_name}: no path rules found - an empty policy grants nothing and is almost certainly a rendering bug" - return - fi - - local path caps - while IFS=$'\t' read -r path caps; do - [ -n "${path}" ] || continue - - # RULE 1: universal wildcard. - # Grants or denies the entire Vault API in one line. - if [ "${path}" = "*" ] || [ "${path}" = "/*" ]; then - case "${caps}" in - deny) ;; - *) finding "${policy_name}: path \"${path}\" grants [${caps}] over the ENTIRE Vault API" ;; - esac - fi - - # RULE 2: capability sanity. - if [ -z "${caps}" ]; then - finding "${policy_name}: path \"${path}\" has an empty capabilities list" - fi - - local c found - for c in ${caps//,/ }; do - found=0 - for valid in ${VALID_CAPABILITIES}; do - [ "${c}" = "${valid}" ] && { found=1; break; } - done - [ "${found}" -eq 1 ] || finding "${policy_name}: path \"${path}\" has unknown capability '${c}' - Vault ignores unknown capabilities, so this rule grants less than it appears to" - done - - # deny is absolute and cannot be combined; listing it alongside grants - # reads as "allow these, deny the rest", which is not what it means. - case ",${caps}," in - *,deny,*) - if [ "${caps}" != "deny" ]; then - finding "${policy_name}: path \"${path}\" mixes 'deny' with [${caps}] - deny overrides everything else here, so the other capabilities are misleading" - fi - ;; - esac - - # RULE 3: sudo outside the operator policy. - case ",${caps}," in - *,sudo,*) - if [ "${policy_name}" != "operator" ] && [ "${policy_name}" != "admin" ]; then - finding "${policy_name}: path \"${path}\" grants 'sudo' - root-equivalent on that path, and not appropriate outside a break-glass policy" - fi - ;; - esac - - # RULE 4: KV v2 path-split - # The highest-probability defect in this platform. A policy written against - # //... matches nothing on KV v2, because values live - # under /data/ and history under /metadata/. It applies - # cleanly, returns no error, and grants exactly nothing. - # - # Checked for grants only. A deny that matches nothing is harmless, and - # mount-wide catch-all denies like "/*" are deliberate: they are how - # the provisioning policy states that it has no business in tenant data at - # all. Flagging those would train people to ignore this rule, which is the - # one rule that must never be ignored. - case "${path}" in - "${KV_MOUNT}"/*) - local second; second="$(printf '%s' "${path#"${KV_MOUNT}"/}" | cut -d/ -f1)" - local ok=0 seg - [ "${caps}" = "deny" ] && ok=1 - [ "${second}" = "*" ] && ok=1 - for seg in ${KV_V2_SEGMENTS}; do - [ "${second}" = "${seg}" ] && { ok=1; break; } - done - if [ "${ok}" -eq 0 ]; then - finding "${policy_name}: path \"${path}\" is not a valid KV v2 path. - KV v2 stores values under ${KV_MOUNT}/data/... and version history under - ${KV_MOUNT}/metadata/... . This rule matches nothing, applies without - error, and grants no access. Did you mean - \"${KV_MOUNT}/data/${path#"${KV_MOUNT}"/}\"?" - fi - ;; - esac - - # RULE 5: cross-tenant wildcard. - # A grant on ///* covers EVERY tenant. Only a deny - # belongs at that level; the tenant-scoped grant must include the tenant - # segment. - case "${path}" in - "${KV_MOUNT}"/*/"${TENANT_PREFIX}"/\*|"${KV_MOUNT}"/*/"${TENANT_PREFIX}"/) - if [ "${caps}" != "deny" ]; then - finding "${policy_name}: path \"${path}\" grants [${caps}] across ALL tenants. - A wildcard directly under '${TENANT_PREFIX}/' crosses every tenant - boundary. Scope it to one tenant, or make it a deny." - fi - ;; - esac - - # Same problem one level up: a grant on the whole mount. - case "${path}" in - "${KV_MOUNT}"/\*|"${KV_MOUNT}"/data/\*|"${KV_MOUNT}"/metadata/\*) - if [ "${caps}" != "deny" ]; then - finding "${policy_name}: path \"${path}\" grants [${caps}] over the entire KV mount, crossing every tenant boundary" - fi - ;; - esac - - # RULE 6: cross-tenant dynamic credentials. - case "${path}" in - "${DATABASE_MOUNT}"/creds/\*) - if [ "${caps}" != "deny" ]; then - finding "${policy_name}: path \"${path}\" grants [${caps}] on every tenant's database credentials" - fi - ;; - esac - - # RULE 7: duplicate path stanzas. - # Vault keeps one of them. Which one is not something to rely on. - case " ${seen_paths} " in - *" ${path} "*) finding "${policy_name}: path \"${path}\" is declared more than once - only one stanza takes effect" ;; - *) seen_paths="${seen_paths} ${path}" ;; - esac - - done <<< "${pairs}" -} - -for f in "$@"; do - lint_file "${f}" -done - -if [ "${FINDINGS}" -gt 0 ]; then - printf '\npolicy lint FAILED: %d finding(s)\n\n' "${FINDINGS}" >&2 - exit 1 -fi - -exit 0 diff --git a/tests/run-conformance.sh b/tests/run-conformance.sh deleted file mode 100755 index 9160bc9..0000000 --- a/tests/run-conformance.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# run-conformance.sh --layer isolation|credentials|all -# Do not use set -e: one suite failure must not skip the rest. -set -uo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../scripts/lib/common.sh -. "${SCRIPT_DIR}/../scripts/lib/common.sh" - -platform_defaults -require_cmd curl jq - -LAYER="isolation" -while [ $# -gt 0 ]; do - case "$1" in - --layer) shift; LAYER="${1:-}" ;; - --layer=*) LAYER="${1#*=}" ;; - -h|--help) sed -n '2,3p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $1 (try --help)" ;; - esac - shift -done - -case "${LAYER}" in - isolation|credentials|all) ;; - *) die "unknown layer '${LAYER}' (expected: isolation, credentials, or all)" ;; -esac - -[ -n "${VAULT_ADDR:-}" ] || die "VAULT_ADDR is not set" -[ -n "${VAULT_TOKEN:-}" ] || die "VAULT_TOKEN is not set" - -if [ "${LAYER}" = "credentials" ] && ! credentials_enabled; then - die "the credentials suite was requested but ENABLE_DYNAMIC_CREDENTIALS is not true. - Refusing to report success for a suite that would not run. - Start the environment with --with-credentials, or run --layer isolation." -fi - -FAILED_SUITES="" -PASSED_SUITES="" - -run_suite_dir() { - local dir="$1" label="$2" - [ -d "${dir}" ] || { warn "no such suite directory: ${dir}"; return 0; } - - printf '\n===============================================================\n' - printf ' %s conformance\n' "${label}" - printf '===============================================================\n' - - local file name - for file in "${dir}"/*.sh; do - [ -f "${file}" ] || continue - name="$(basename "${file}" .sh)" - printf '\n--- %s ---\n' "${name}" - - if bash "${file}"; then - PASSED_SUITES="${PASSED_SUITES} ${label}/${name}" - else - FAILED_SUITES="${FAILED_SUITES} ${label}/${name}" - fi - done -} - -printf '\nConformance run\n' -printf ' target %s\n' "${VAULT_ADDR}" -printf ' layer %s\n' "${LAYER}" -printf ' tenants %s, %s\n' "${TENANT_A:-tenant-a}" "${TENANT_B:-tenant-b}" -printf ' kv mount %s/\n' "${KV_MOUNT}" -[ -n "${AUDIT_READ_CMD:-}" ] || printf ' audit AUDIT_READ_CMD unset - audit content assertions will be skipped\n' - -# The linter needs no Vault and is cheap, so it runs first: a broken policy -# template makes every downstream result unreliable. -printf '\n--- policy linter self-test ---\n' -if "${TESTS_DIR}/lint/lint-self-test.sh"; then - PASSED_SUITES="${PASSED_SUITES} lint/self-test" -else - FAILED_SUITES="${FAILED_SUITES} lint/self-test" -fi - -case "${LAYER}" in - isolation) - run_suite_dir "${TESTS_DIR}/conformance/isolation" "isolation" - ;; - credentials) - run_suite_dir "${TESTS_DIR}/conformance/credentials" "credentials" - ;; - all) - run_suite_dir "${TESTS_DIR}/conformance/isolation" "isolation" - if credentials_enabled; then - run_suite_dir "${TESTS_DIR}/conformance/credentials" "credentials" - else - printf '\n credentials suite not enabled - skipping (ENABLE_DYNAMIC_CREDENTIALS=false)\n' - fi - ;; -esac - -printf '\n===============================================================\n' -printf ' Result\n' -printf '===============================================================\n\n' - -for s in ${PASSED_SUITES}; do printf ' PASS %s\n' "${s}"; done -for s in ${FAILED_SUITES}; do printf ' FAIL %s\n' "${s}"; done - -if [ -n "${FAILED_SUITES}" ]; then - printf '\nCONFORMANCE FAILED\n' - printf 'A failure in the isolation layer is a cross-tenant breach, not a flaky test.\n\n' - exit 1 -fi - -printf '\nCONFORMANCE PASSED\n\n'