diff --git a/.dockerignore b/.dockerignore index 32881c66..84a06964 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,9 @@ docs # other repos checked out for reference reference/ +# Local agent state (Claude Code settings and worktrees) +.claude/ + # Web app artifacts web/node_modules/ web/dist diff --git a/.github/actions/install-convox/action.yml b/.github/actions/install-convox/action.yml new file mode 100644 index 00000000..5906c131 --- /dev/null +++ b/.github/actions/install-convox/action.yml @@ -0,0 +1,28 @@ +name: Install Convox CLI +description: Install a pinned, checksum-verified Convox CLI (linux amd64) into /usr/local/bin. + +inputs: + version: + description: Convox CLI release version + default: "3.25.7" + sha256: + description: SHA-256 of the convox-linux asset for that version + default: 6a0ffe6faf269302c311c2f8e4d1cdc116902c933c7d91f461c47d8a25190759 + +runs: + using: composite + steps: + - name: Install Convox CLI + shell: bash + env: + CONVOX_VERSION: ${{ inputs.version }} + CONVOX_SHA256: ${{ inputs.sha256 }} + run: | + set -euo pipefail + tmp="$(mktemp -d)" + curl -fsSL -o "${tmp}/convox" \ + "https://github.com/convox/convox/releases/download/${CONVOX_VERSION}/convox-linux" + echo "${CONVOX_SHA256} ${tmp}/convox" | sha256sum --check --strict + sudo install -m 0755 "${tmp}/convox" /usr/local/bin/convox + rm -rf "${tmp}" + convox version || true diff --git a/.github/actions/install-task/action.yml b/.github/actions/install-task/action.yml new file mode 100644 index 00000000..47868220 --- /dev/null +++ b/.github/actions/install-task/action.yml @@ -0,0 +1,29 @@ +name: Install Task +description: Install a pinned, checksum-verified Task (taskfile.dev) binary into /usr/local/bin. + +inputs: + version: + description: Task release version + default: "3.53.1" + sha256: + description: SHA-256 of task_linux_amd64.tar.gz for that version (from task_checksums.txt) + default: a54a408f6861ff921f6e87774180db31bacd8c1e7c944ca696db9fea49a82fc7 + +runs: + using: composite + steps: + - name: Install Task + shell: bash + env: + TASK_VERSION: ${{ inputs.version }} + TASK_SHA256: ${{ inputs.sha256 }} + run: | + set -euo pipefail + tmp="$(mktemp -d)" + curl -fsSL -o "${tmp}/task.tar.gz" \ + "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_linux_amd64.tar.gz" + echo "${TASK_SHA256} ${tmp}/task.tar.gz" | sha256sum --check --strict + tar -xzf "${tmp}/task.tar.gz" -C "${tmp}" task + sudo install -m 0755 "${tmp}/task" /usr/local/bin/task + rm -rf "${tmp}" + task --version diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4dcc6101 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 + +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + ignore: + # Replaced by the local modules in internal/shims (see go.mod replace directives) + - dependency-name: github.com/docker/docker + - dependency-name: github.com/moby/buildkit + groups: + go-minor-patch: + update-types: [minor, patch] + + - package-ecosystem: bun + directories: + - /web + - /docs + - /mock-oauth + schedule: + interval: weekly + groups: + bun-minor-patch: + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions-minor-patch: + update-types: [minor, patch] + + - package-ecosystem: docker + directories: + - / + - /mock-oauth + schedule: + interval: weekly + groups: + docker-minor-patch: + update-types: [minor, patch] diff --git a/.github/wait-for-checks.js b/.github/wait-for-checks.js index 04e689a3..691efe04 100644 --- a/.github/wait-for-checks.js +++ b/.github/wait-for-checks.js @@ -42,13 +42,16 @@ module.exports = async function waitForChecks({ ); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { data } = await github.rest.checks.listForRef({ + // Paginate: scheduled workflows (e.g. the daily security scan) add check runs to the same commit, + // which can push the required ones off the first page. + const allRuns = await github.paginate(github.rest.checks.listForRef, { owner, repo, ref, + per_page: 100, }); - const runs = data.check_runs.filter((run) => checkSet.has(run.name)); + const runs = allRuns.filter((run) => checkSet.has(run.name)); if (runs.length === checkSet.size) { const incomplete = runs.filter((run) => run.status !== "completed"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557e4917..59195aac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: go-tests: runs-on: ubuntu-latest @@ -14,10 +17,10 @@ jobs: TEST_DATABASE_URL: postgres://postgres:postgres@localhost:55432/gateway_test?sslmode=disable GOLANGCI_LINT_VERSION: v2.11.1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.9" @@ -27,25 +30,13 @@ jobs: sudo apt-get install -y libfido2-dev libudev-dev pkg-config - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version + uses: ./.github/actions/install-task - name: Go deps run: task go:deps - name: Install Convox CLI - run: | - set -euo pipefail - ARCH=$(uname -m) - URL="https://github.com/convox/convox/releases/latest/download/convox-linux" - if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then - URL="https://github.com/convox/convox/releases/latest/download/convox-linux-arm64" - fi - curl -fsSL "$URL" -o /tmp/convox - sudo mv /tmp/convox /usr/local/bin/convox - sudo chmod 755 /usr/local/bin/convox - convox version || true + uses: ./.github/actions/install-convox - name: Install Go tools run: task go:tools @@ -58,15 +49,15 @@ jobs: env: GOLANGCI_LINT_VERSION: v2.11.1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.9" - name: Cache golangci-lint cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cache/golangci-lint @@ -78,24 +69,15 @@ jobs: sudo apt-get install -y libfido2-dev libudev-dev pkg-config - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version - - - name: Install golangci-lint - run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ - | sudo sh -s -- -b /usr/local/bin "${GOLANGCI_LINT_VERSION}" - golangci-lint version + uses: ./.github/actions/install-task - name: Go deps (lint warmup) run: task go:deps - - name: Verify golangci-lint config - run: task go:lint:config - + # The action installs the pinned golangci-lint release and verifies .golangci.yml + # against its JSON schema (verify: true) before linting. - name: golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: version: ${{ env.GOLANGCI_LINT_VERSION }} env: @@ -121,14 +103,25 @@ jobs: run: task shellcheck - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest + run: go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - name: Check for vulnerabilities run: task go:sec:vuln - name: Install TruffleHog + env: + TRUFFLEHOG_VERSION: "3.97.9" + TRUFFLEHOG_SHA256: 40377e6572495412fb9ba0bc21c9401f73b72f1d2afd11b9931bc4a5ed622866 run: | - curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin + set -euo pipefail + tmp="$(mktemp -d)" + curl -fsSL -o "${tmp}/trufflehog.tar.gz" \ + "https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz" + echo "${TRUFFLEHOG_SHA256} ${tmp}/trufflehog.tar.gz" | sha256sum --check --strict + tar -xzf "${tmp}/trufflehog.tar.gz" -C "${tmp}" trufflehog + sudo install -m 0755 "${tmp}/trufflehog" /usr/local/bin/trufflehog + rm -rf "${tmp}" + trufflehog --version - name: Scan for secrets run: task go:sec:secrets @@ -136,15 +129,15 @@ jobs: web-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.3.1" @@ -154,9 +147,7 @@ jobs: bun install --frozen-lockfile - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version + uses: ./.github/actions/install-task - name: Web lint (Typecheck, Biome, and knip) run: task web:lint @@ -170,17 +161,15 @@ jobs: mock-oauth-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.3.1" - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version + uses: ./.github/actions/install-task - name: Mock OAuth lint (Typecheck and Biome) run: task mock-oauth:lint diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 540e6968..f36da583 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,11 +6,8 @@ on: paths: ["docs/**"] workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write - id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -23,32 +20,38 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.1" - name: Install dependencies - run: cd docs && bun install + run: cd docs && bun install --frozen-lockfile - name: Build docs run: cd docs && bun run build - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: docs/dist deploy: needs: build runs-on: ubuntu-latest + # Only the deploy job may publish to GitHub Pages + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e1912131..fbad9928 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: build-image: runs-on: ubuntu-latest @@ -12,13 +15,13 @@ jobs: GATEWAY_IMAGE: rack-gateway-api:e2e-${{ github.sha }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build gateway image (with web UI) and export - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: Dockerfile @@ -31,7 +34,7 @@ jobs: outputs: type=docker,dest=${{ runner.temp }}/gateway-api.tar - name: Upload image artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: gateway-image path: ${{ runner.temp }}/gateway-api.tar @@ -54,12 +57,10 @@ jobs: CGO_ENABLED: 1 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version + uses: ./.github/actions/install-task - name: Install libfido2 dependencies run: | @@ -67,24 +68,24 @@ jobs: sudo apt-get install -y libfido2-dev libudev-dev pkg-config - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.3.1" - name: Download gateway image - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: gateway-image path: ${{ runner.temp }} - name: Load gateway image run: | - docker load --input ${{ runner.temp }}/gateway-api.tar + docker load --input "${RUNNER_TEMP}/gateway-api.tar" docker image ls -a | grep rack-gateway-api || true - name: Build mock services @@ -108,8 +109,8 @@ jobs: run: | set -x docker compose ps - curl -sv http://127.0.0.1:${GATEWAY_PORT}/api/v1/health || true - curl -sv http://127.0.0.1:${GATEWAY_PORT}/app/login -o /dev/null || true + curl -sv "http://127.0.0.1:${GATEWAY_PORT}/api/v1/health" || true + curl -sv "http://127.0.0.1:${GATEWAY_PORT}/app/login" -o /dev/null || true - name: Install Playwright and deps working-directory: web @@ -142,12 +143,10 @@ jobs: CGO_ENABLED: 1 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Task - run: | - curl -sL https://taskfile.dev/install.sh | sh -s -- -b /usr/local/bin - task --version + uses: ./.github/actions/install-task - name: Install libfido2 dependencies run: | @@ -155,19 +154,19 @@ jobs: sudo apt-get install -y libfido2-dev libudev-dev pkg-config - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.9" - name: Download gateway image - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: gateway-image path: ${{ runner.temp }} - name: Load gateway image run: | - docker load --input ${{ runner.temp }}/gateway-api.tar + docker load --input "${RUNNER_TEMP}/gateway-api.tar" docker image ls -a | grep rack-gateway-api || true - name: Build mock services @@ -187,17 +186,7 @@ jobs: GATEWAY_PORT=9447 WEB_PORT=9447 MOCK_OAUTH_PORT=9345 CHECK_VITE_PROXY=false ./scripts/wait-for-services.sh - name: Install Convox CLI - run: | - set -euo pipefail - ARCH=$(uname -m) - URL="https://github.com/convox/convox/releases/latest/download/convox-linux" - if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then - URL="https://github.com/convox/convox/releases/latest/download/convox-linux-arm64" - fi - curl -fsSL "$URL" -o /tmp/convox - sudo mv /tmp/convox /usr/local/bin/convox - sudo chmod 755 /usr/local/bin/convox - convox version || true + uses: ./.github/actions/install-convox - name: Run CLI E2E run: GATEWAY_PORT=9447 MOCK_OAUTH_PORT=9345 MOCK_CONVOX_PORT=6443 E2E_DATABASE_NAME=gateway_test E2E_GATEWAY_SERVICE=gateway-api-test ./scripts/cli-e2e.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5d7f30b..11f326c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,177 +1,231 @@ name: Release +# Releases are built only from v* tags. A repository ruleset restricts creating, +# moving and deleting v* tags to repository admins. on: push: tags: - v* - workflow_dispatch: {} -permissions: - contents: write - checks: read +# Each job declares the minimum permissions it needs. +permissions: {} jobs: - docker_build: + verify_tag: runs-on: ubuntu-latest + permissions: + contents: read + checks: read # wait-for-checks.js reads check runs for the tagged commit + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + short_sha: ${{ steps.version.outputs.short_sha }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - fetch-depth: 0 - fetch-tags: true - - uses: actions/github-script@v7 + persist-credentials: false + + - name: Validate tag and version + id: version + env: + TAG: ${{ github.ref_name }} + COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Error: invalid tag '$TAG' (expected vMAJOR.MINOR.PATCH[-PRERELEASE])" >&2 + exit 1 + fi + VERSION="${TAG#v}" + PACKAGE_VERSION="$(jq -r '.version' web/package.json)" + if [ "$VERSION" != "$PACKAGE_VERSION" ]; then + echo "Error: tag $TAG does not match web/package.json version $PACKAGE_VERSION" >&2 + exit 1 + fi + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "short_sha=${COMMIT:0:7}" + } >> "$GITHUB_OUTPUT" + + - name: Wait for CI and E2E checks on the tagged commit + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const script = require('./.github/wait-for-checks.js'); await script({ github, context, core }); + docker_build: + runs-on: ubuntu-latest + needs: verify_tag + permissions: + contents: read + id-token: write # sign the build provenance attestation + attestations: write + outputs: + digest: ${{ steps.build.outputs.digest }} + env: + IMAGE: docker.io/docspringcom/rack-gateway + VERSION: ${{ needs.verify_tag.outputs.version }} + SHORT_SHA: ${{ needs.verify_tag.outputs.short_sha }} + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Extract version info - run: | - if [ "${{ github.event_name }}" = "push" ]; then - TAG="${GITHUB_REF#refs/tags/}" - echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - echo "GIT_TAG=${TAG}" >> "$GITHUB_ENV" - else - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest") - echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - echo "GIT_TAG=${TAG}" >> "$GITHUB_ENV" - fi - echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" - + # No build cache: release images are built from scratch so a cache written by + # another workflow run can't influence what gets published. - name: Build and push Docker image - uses: docker/build-push-action@v6 + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64 push: true + no-cache: true build-args: | COMMIT_SHA=${{ github.sha }} + # Prerelease versions (vX.Y.Z-rc.1) don't move :latest. tags: | - docker.io/docspringcom/rack-gateway:${{ env.COMMIT_SHA }} - docker.io/docspringcom/rack-gateway:latest - cache-from: type=gha - cache-to: type=gha,mode=max + ${{ env.IMAGE }}:v${{ env.VERSION }} + ${{ env.IMAGE }}:${{ env.SHORT_SHA }} + ${{ !contains(env.VERSION, '-') && format('{0}:latest', env.IMAGE) || '' }} + + - name: Attest image build provenance + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-name: index.docker.io/docspringcom/rack-gateway + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true release_build: runs-on: ubuntu-latest + needs: verify_tag + permissions: + contents: read + id-token: write # sign the build provenance attestation + attestations: write + env: + VERSION: ${{ needs.verify_tag.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - uses: actions/github-script@v7 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - script: | - const script = require('./.github/wait-for-checks.js'); - await script({ github, context, core }); + persist-credentials: false + + # cache: false so modules restored from another workflow's cache can't end up in the release binary - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.9" + cache: false + - name: Build binary env: CGO_ENABLED: 0 GOOS: linux GOARCH: amd64 + GOFLAGS: -mod=readonly run: | - set -e + set -euo pipefail + BUILD_TIME="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" go build \ -tags nofido \ -buildvcs=false \ - -ldflags "-s -w" \ + -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \ -o rack-gateway-linux-amd64 \ ./cmd/rack-gateway/ - - name: Create archive + ./rack-gateway-linux-amd64 version | grep -F "client: ${VERSION}" + + - name: Create archive and checksum run: | - set -e - tar czf rack-gateway-linux-amd64.tar.gz rack-gateway-linux-amd64 - echo "ASSET_PATH=rack-gateway-linux-amd64.tar.gz" >> "$GITHUB_ENV" - - uses: actions/upload-artifact@v4 + set -euo pipefail + mkdir -p dist + tar czf dist/rack-gateway-linux-amd64.tar.gz rack-gateway-linux-amd64 + (cd dist && sha256sum rack-gateway-linux-amd64.tar.gz > rack-gateway-linux-amd64.tar.gz.sha256) + + - name: Attest binary build provenance + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: dist/rack-gateway-linux-amd64.tar.gz + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - path: ${{ env.ASSET_PATH }} name: rack-gateway-linux-amd64 + path: dist/ release_create: runs-on: ubuntu-latest needs: + - verify_tag + - docker_build - release_build + permissions: + contents: write # create the GitHub release + env: + RELEASE_TAG: ${{ needs.verify_tag.outputs.tag }} + RELEASE_VERSION: ${{ needs.verify_tag.outputs.version }} + REPOSITORY: ${{ github.repository }} + IMAGE_DIGEST: ${{ needs.docker_build.outputs.digest }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rack-gateway-linux-amd64 - path: artifacts/rack-gateway-linux-amd64 - - run: | - if [ "${{ github.event_name }}" = "push" ]; then - TAG="${GITHUB_REF#refs/tags/}" - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "Error: Invalid tag format $TAG (expected v*.*.* semver)" >&2 - exit 1 - fi - echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" - VERSION="${TAG#v}" - echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" - else - # For workflow_dispatch, use latest tag - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$TAG" ]; then - echo "Error: No tags found. Cannot create release without a tag." >&2 - exit 1 - fi - echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" - VERSION="${TAG#v}" - echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" - fi - - name: Generate checksums + path: dist + + - name: Verify checksum run: | - set -e - cd artifacts - for dir in */; do - cd "$dir" - for file in *; do - case "$file" in - *.tar.gz|*.zip) - if [ -f "$file" ]; then - sha256sum "$file" > "${file}.sha256" || shasum -a 256 "$file" | awk '{print $1}' > "${file}.sha256" - fi - ;; - esac - done - cd .. - done - cd .. + set -euo pipefail + cd dist + sha256sum --check --strict rack-gateway-linux-amd64.tar.gz.sha256 + - name: Generate changelog run: | - cat > changelog.md < changelog.md + + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: | - artifacts/**/*.tar.gz - artifacts/**/*.sha256 + dist/rack-gateway-linux-amd64.tar.gz + dist/rack-gateway-linux-amd64.tar.gz.sha256 prerelease: ${{ contains(env.RELEASE_TAG, '-') }} body_path: changelog.md name: rack-gateway v${{ env.RELEASE_VERSION }} draft: false tag_name: ${{ env.RELEASE_TAG }} + fail_on_unmatched_files: true diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..82d6e165 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,68 @@ +name: Security Scan + +# Daily scan so newly disclosed vulnerabilities show up even when nobody pushes. +# GitHub emails the workflow's last editor when a scheduled run fails. +on: + schedule: + - cron: "17 18 * * *" # daily, 06:17 NZST + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26.9" + cache: false + + - name: Install libfido2 dependencies + run: | + sudo apt-get update + sudo apt-get install -y libfido2-dev libudev-dev pkg-config + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + + - name: Check Go vulnerabilities + run: govulncheck ./... + + web-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.1" + + - name: Install web packages + working-directory: web + run: bun install --frozen-lockfile + + - name: Audit web dependencies (high and critical) + working-directory: web + run: bun audit --audit-level=high + + image-scan: + runs-on: ubuntu-latest + steps: + - name: Scan the published gateway image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: v0.74.0 + image-ref: docker.io/docspringcom/rack-gateway:latest + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" diff --git a/CLAUDE.md b/CLAUDE.md index 819c2153..693f8bc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,59 +525,50 @@ See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for complete environment vari ## Version Management and Deployment -**IMPORTANT: Deploying to production requires a new Docker image, which is only built when you push a git tag.** +**IMPORTANT: Deploying to production requires a new Docker image, which is only built when you push a `v*` git tag.** -There is no way to deploy code changes without: -1. Creating a version tag (e.g., `v0.0.19`) -2. Pushing the tag to trigger the GitHub Actions release workflow -3. Waiting for the Docker image to build and push +**Single Source of Truth**: The project version is stored in `web/package.json`. `scripts/bump-version.sh` also pins +both `convox.yml` services to the matching immutable image tag (`docker.io/docspringcom/rack-gateway:vX.Y.Z`), so a +deploy never uses the mutable `:latest` tag. -**Single Source of Truth**: The project version is stored in `web/package.json`. +**Who can release:** a repository ruleset only lets repo admins create, move or delete `v*` tags, and `main` requires +a PR with green CI (admins can bypass). The Release workflow only runs on `v*` tags; it has no manual trigger. **Releasing a new version:** -1. Bump the version in `web/package.json`: +1. Bump the version (updates `web/package.json`, `web/bun.lock` and the image tag in `convox.yml`): ```bash ./scripts/bump-version.sh patch # or minor, major ``` -2. Commit the version bump: +2. Commit and push the version bump: ```bash git commit -am "chore: bump version to vX.Y.Z" - ``` - -3. Push the commit to main: - ```bash git push origin main ``` -4. Create and push a git tag to trigger the release: +3. Create and push the release tag (admins only): ```bash ./scripts/create-release-tags.sh - git push --tags + git push origin vX.Y.Z ``` -The GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered by `v*` tags and: -- Builds the Docker image for linux/amd64 -- Pushes to `docker.io/docspringcom/rack-gateway` with both version tag and `latest` tags -- Creates a GitHub release with binaries and checksums - -**Deployment to Convox:** +The Release workflow (`.github/workflows/release.yml`): +- Checks the tag matches `web/package.json` and waits for CI + E2E to pass on the tagged commit +- Builds the Docker image without any build cache and pushes `:vX.Y.Z`, `:` and `:latest` +- Builds the CLI (`rack-gateway version` reports the release version) with checksums +- Publishes signed build provenance attestations for the image and the CLI archive +- Creates a GitHub release with the CLI archive and checksum -After the release workflow completes successfully: -1. The `convox.yml` uses `image: docker.io/docspringcom/rack-gateway:latest` (or a specific version tag) -2. Run `convox deploy` to deploy the new image to the rack +**Deployment to Convox:** once the Release workflow has published `:vX.Y.Z`, run `./scripts/deploy_all.sh` from the +repo root (staging → eu → us). For each rack it builds (`convox.yml` already points at the new tag), runs +`./rack-gateway-api migrate` against the new release, then promotes. Production never migrates on startup, so plain +`convox deploy` would skip migrations. To deploy one rack: `./scripts/deploy.sh `. -**Quick Release Workflow:** +**Verifying a release:** ```bash -# After your changes are merged to main: -./scripts/bump-version.sh patch -git commit -am "chore: bump version to v0.0.19" -git push origin main -./scripts/create-release-tags.sh -git push --tags -# Wait for GitHub Actions to complete, then deploy -convox deploy +gh attestation verify oci://docker.io/docspringcom/rack-gateway:vX.Y.Z --repo DocSpring/rack-gateway +gh attestation verify rack-gateway-linux-amd64.tar.gz --repo DocSpring/rack-gateway ``` ## Code Structure diff --git a/Dockerfile b/Dockerfile index efe2d0d5..01fad852 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM oven/bun:1.3.2-alpine AS webbuild +FROM oven/bun:1.3.2-alpine@sha256:adda30fd4db7d8ef9a2113cb935c6f751de3daad39373713b56eefe49db78471 AS webbuild ARG COMMIT_SHA RUN test -n "$COMMIT_SHA" || (echo "COMMIT_SHA build arg is required" && exit 1) @@ -15,7 +15,7 @@ RUN bun install --frozen-lockfile COPY web/ ./ RUN bun run build -FROM golang:1.26.9-alpine AS builder +FROM golang:1.26.9-alpine@sha256:cdfd4fe2da6b225d8b40c6b7a105736e548e83ff56d5d8f9394446eeb5eb84e0 AS builder RUN apk add --no-cache git ca-certificates make gcc musl-dev nodejs npm @@ -33,24 +33,30 @@ COPY web/package.json ./web/package.json COPY internal ./internal COPY cmd/gateway ./cmd/gateway -# Build the gateway binary with version info -ARG COMMIT_HASH=unknown +# Build the gateway binary with version info (COMMIT_SHA is the same build arg the web stage requires) +ARG COMMIT_SHA +RUN test -n "$COMMIT_SHA" || (echo "COMMIT_SHA build arg is required" && exit 1) RUN VERSION=$(node -p "require('./web/package.json').version") && \ CGO_ENABLED=0 go build \ - -ldflags "-X github.com/DocSpring/rack-gateway/internal/gateway/version.Version=${VERSION} -X github.com/DocSpring/rack-gateway/internal/gateway/version.CommitHash=${COMMIT_HASH}" \ + -ldflags "-X github.com/DocSpring/rack-gateway/internal/gateway/version.Version=${VERSION} -X github.com/DocSpring/rack-gateway/internal/gateway/version.CommitHash=${COMMIT_SHA}" \ -o /out/rack-gateway-api ./cmd/gateway \ && /out/rack-gateway-api help -FROM alpine:latest +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -RUN apk --no-cache add ca-certificates curl +# ca-certificates for outbound TLS. No curl: the compose healthcheck uses busybox wget. +RUN apk --no-cache add ca-certificates \ + && addgroup -S -g 10001 gateway \ + && adduser -S -D -H -u 10001 -G gateway -s /sbin/nologin gateway WORKDIR /app +# Files stay root-owned and read-only to the runtime user. COPY --from=builder /out/rack-gateway-api ./ COPY --from=webbuild /app/web/dist ./web/dist -COPY scripts/start-gateway.sh ./scripts/start-gateway.sh -RUN chmod +x ./scripts/start-gateway.sh +COPY --chmod=0755 scripts/start-gateway.sh ./scripts/start-gateway.sh + +USER 10001:10001 EXPOSE 8080 diff --git a/Dockerfile.gateway-dev b/Dockerfile.gateway-dev index 963b6a02..db7737a2 100644 --- a/Dockerfile.gateway-dev +++ b/Dockerfile.gateway-dev @@ -1,4 +1,4 @@ -FROM golang:1.26.9-alpine AS builder +FROM golang:1.26.9-alpine@sha256:cdfd4fe2da6b225d8b40c6b7a105736e548e83ff56d5d8f9394446eeb5eb84e0 AS builder RUN apk add --no-cache git ca-certificates make gcc musl-dev @@ -21,9 +21,9 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ CGO_ENABLED=0 go build -o /out/rack-gateway-api ./cmd/gateway \ && /out/rack-gateway-api help -FROM alpine:latest +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -RUN apk --no-cache add ca-certificates curl +RUN apk --no-cache add ca-certificates WORKDIR /root/ diff --git a/Dockerfile.mock-convox b/Dockerfile.mock-convox index 0831c183..27d79607 100644 --- a/Dockerfile.mock-convox +++ b/Dockerfile.mock-convox @@ -1,7 +1,7 @@ # Build stage # syntax=docker/dockerfile:1.5 -FROM golang:1.26.9-alpine AS builder +FROM golang:1.26.9-alpine@sha256:cdfd4fe2da6b225d8b40c6b7a105736e548e83ff56d5d8f9394446eeb5eb84e0 AS builder RUN apk add --no-cache git make @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ && /out/mock-convox help # Final stage -FROM alpine:latest +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk --no-cache add ca-certificates diff --git a/README.md b/README.md index 71d139d8..a791b168 100644 --- a/README.md +++ b/README.md @@ -458,30 +458,39 @@ If your support needs are more complex, please consider using the official Convo To create a new release: ```bash -# 1. Bump the version +# 1. Bump the version (also pins convox.yml to docker.io/docspringcom/rack-gateway:v1.0.1) ./scripts/bump-version.sh patch # or minor, major -# 2. Commit the version bump +# 2. Commit and push the version bump git commit -am "chore: bump version to v1.0.1" +git push origin main -# 3. Create and push the release tag +# 3. Create and push the release tag (only repository admins can push v* tags) ./scripts/create-release-tags.sh -git push origin v1.0.1 # or: git push --tags +git push origin v1.0.1 ``` -The GitHub Actions release workflow automatically: +The GitHub Actions release workflow (triggered only by `v*` tags): -- Builds the Docker image for linux/amd64 -- Pushes to `docker.io/docspringcom/rack-gateway` with commit SHA and `latest` tags -- Creates a GitHub release with binaries and checksums +- Checks the tag matches `web/package.json` and waits for CI and E2E to pass on the tagged commit +- Builds the Docker image for linux/amd64 without a build cache +- Pushes `docker.io/docspringcom/rack-gateway` with the version (`v1.0.1`), short commit SHA and `latest` tags +- Publishes signed build provenance attestations for the image and the CLI archive +- Creates a GitHub release with the CLI archive and its checksum -After the release completes, update your deployment: +After the release completes, deploy. `convox.yml` already references the new version tag. The deploy script +builds, runs database migrations against the new release, then promotes it (production never migrates on +startup): ```bash -# Update convox.yml to use the new image tag -# image: docker.io/docspringcom/rack-gateway:${COMMIT_SHA} +./scripts/deploy_all.sh # every rack: staging -> eu -> us +./scripts/deploy.sh staging # or one rack +``` + +Verify what you deployed: -convox deploy +```bash +gh attestation verify oci://docker.io/docspringcom/rack-gateway:v1.0.1 --repo DocSpring/rack-gateway ``` ## Deployment diff --git a/cmd/mock-convox/handlers_apps.go b/cmd/mock-convox/handlers_apps.go index 76a44339..b6626edf 100644 --- a/cmd/mock-convox/handlers_apps.go +++ b/cmd/mock-convox/handlers_apps.go @@ -99,7 +99,13 @@ func updateService(w http.ResponseWriter, r *http.Request) { app := vars["app"] service := vars["service"] - updated, err := updateServiceState(app, service, r.URL.Query()) + // Like the real rack, read options from the form body (where the SDK sends them) as well as the query. + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + updated, err := updateServiceState(app, service, r.Form) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/convox.yml b/convox.yml index 42101bf3..70e69f2d 100644 --- a/convox.yml +++ b/convox.yml @@ -28,9 +28,10 @@ environment: services: gateway: - # To deploy: Build locally and push to Docker Hub using scripts/build-and-push.sh - # Then update the image tag below to the new version - image: docker.io/docspringcom/rack-gateway:latest + # Pinned to an immutable release tag. scripts/bump-version.sh sets it to the new + # version (:vX.Y.Z); the release workflow publishes that tag when the v* git tag is pushed. + # :e327add is the v0.1.1 release (identical to :latest when this was pinned). + image: docker.io/docspringcom/rack-gateway:e327add command: ./scripts/start-gateway.sh environment: - PORT=8080 @@ -51,8 +52,8 @@ services: - eks.amazonaws.com/role-arn: "${IAM_ROLE_ARN}" admin: - # Uses same image as gateway service - image: docker.io/docspringcom/rack-gateway:latest + # Uses same image as gateway service (kept in sync by scripts/bump-version.sh) + image: docker.io/docspringcom/rack-gateway:e327add command: echo "Use this service to run migrations, database admin tasks, etc." environment: - PORT=8080 diff --git a/docker-compose.yml b/docker-compose.yml index f8d32e9a..d67f8958 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ x-gateway-env: &gateway_common_env x-gateway-health: &gateway_healthcheck test: - ["CMD-SHELL", "curl -fsS http://localhost:$$PORT/api/v1/health || exit 1"] + ["CMD-SHELL", "wget -q -O /dev/null http://localhost:$$PORT/api/v1/health || exit 1"] interval: 2s timeout: 5s retries: 3 diff --git a/docs/legacy/DEPLOY.md b/docs/legacy/DEPLOY.md index 2427514a..c9940e7c 100644 --- a/docs/legacy/DEPLOY.md +++ b/docs/legacy/DEPLOY.md @@ -2,6 +2,13 @@ Deploy the gateway and UI using the `convox.yml` in this repo — no separate manifest needed. +> `convox.yml` deploys a released image pinned to an immutable version tag +> (`docker.io/docspringcom/rack-gateway:vX.Y.Z`). `scripts/bump-version.sh` updates that tag, and the Release +> workflow publishes it when a repository admin pushes the matching `v*` git tag. Verify a release with +> `gh attestation verify oci://docker.io/docspringcom/rack-gateway:vX.Y.Z --repo DocSpring/rack-gateway`. +> Production doesn't migrate on startup: deploy with `scripts/deploy.sh ` (or `scripts/deploy_all.sh`), +> which builds, runs `./rack-gateway-api migrate` against the new release, then promotes. + ## Prerequisites - Convox CLI, authenticated against your rack (e.g., `staging`) diff --git a/docs/legacy/WEBAUTHN.md b/docs/legacy/WEBAUTHN.md deleted file mode 100644 index 71017052..00000000 --- a/docs/legacy/WEBAUTHN.md +++ /dev/null @@ -1,74 +0,0 @@ -# WebAuthn CLI Implementation - Continuation - -## Current State - -✅ **Completed:** - -- Removed Yubico OTP from UI (requires cloud validation or self-hosted server) -- Switched CLI WebAuthn implementation to `github.com/keys-pub/go-libfido2` -- Added system library checks to `scripts/install.sh` for Linux (libfido2-dev, libudev-dev, libusb-1.0-0-dev) -- GitHub Actions installs libfido2, enables CGO, and builds/tests the CLI with hardware support enabled by default -- **Gateway API endpoints for WebAuthn assertion:** - - `POST /api/v1/auth/mfa/webauthn/assertion/start` - Start WebAuthn assertion ceremony - - `POST /api/v1/auth/mfa/webauthn/assertion/verify` - Verify WebAuthn assertion response -- **MFA service methods:** - - `StartWebAuthnAssertion()` - Begin WebAuthn login ceremony - - `VerifyWebAuthnAssertion()` - Validate assertion response with stored session data -- **DTOs:** - - `WebAuthnAssertionStartResponse` - Returns challenge and session data - - `VerifyWebAuthnAssertionRequest` - Accepts assertion response and session data -- **CLI WebAuthn integration:** - - Created `internal/cli/webauthn` package with `GetAssertion()` and `CheckAvailability()` functions - - Integrated into CLI login flow at `cmd/rack-gateway/main.go:performMFAVerification()` - - Calls MFA status endpoint to detect WebAuthn enrollment - - Attempts WebAuthn first if available, falls back to TOTP if it fails or no device found - - Properly handles origin, challenge, and assertion flow - -📝 **Testing Checklist:** - -- [ ] Test WebAuthn login on macOS (no system libs needed) -- [ ] Test WebAuthn login on Linux with system libs installed -- [ ] Test WebAuthn login on Linux without system libs (should fallback to TOTP) -- [ ] Test fallback to TOTP when WebAuthn fails -- [ ] Test with user who has both WebAuthn and TOTP enrolled -- [ ] Test with user who only has TOTP enrolled -- [ ] Test error handling (device disconnected mid-auth, wrong key, etc.) - -📝 **Documentation Updates Needed:** - -- [ ] Update README with WebAuthn CLI usage -- [ ] Document system requirements for Linux in installation docs -- [ ] Add troubleshooting section for common WebAuthn issues -- [ ] Document fallback behavior (WebAuthn → TOTP) - -## Architecture Notes - -**Library Choice: `github.com/keys-pub/go-libfido2`** - -- Thin CGO wrapper around Yubico's libfido2 with stable device support -- Linux requires libfido2 development headers (`libfido2-dev`) plus udev/usb libs -- macOS ships libfido2 already; builds succeed with CGO enabled -- The library is a hard dependency: builds fail immediately if the native headers are missing - -**Flow:** - -1. CLI calls MFA status endpoint → knows what methods user has -2. If WebAuthn available → CLI calls assertion start endpoint → gets challenge -3. CLI uses `internal/cli/webauthn.GetAssertion()` → prompts user to touch device -4. Device signs challenge → CLI submits assertion to verify endpoint -5. Gateway validates → returns success/failure -6. On WebAuthn failure → CLI falls back to TOTP prompt - -**Build Requirements:** - -- Ensure `CGO_ENABLED=1` (Go defaults to this when a C toolchain is present) -- Install libfido2 + libudev + libusb development headers before running `go build ./cmd/rack-gateway` -- CI and release pipelines install the packages and compile with real hardware support to keep the path green - -## Related Files - -- `internal/cli/webauthn/webauthn.go` - WebAuthn client implementation -- `cmd/rack-gateway/main.go` - CLI login flow (performMFAVerification function) -- `scripts/install.sh` - System dependency checks -- `internal/gateway/handlers/auth.go` - Gateway MFA handlers -- `internal/gateway/auth/mfa/service.go` - MFA service with WebAuthn support diff --git a/docs/src/content/docs/configuration/environment-variables.mdx b/docs/src/content/docs/configuration/environment-variables.mdx index 9ce44902..b3ef0d86 100644 --- a/docs/src/content/docs/configuration/environment-variables.mdx +++ b/docs/src/content/docs/configuration/environment-variables.mdx @@ -6,6 +6,22 @@ description: Complete reference of all environment variables for Rack Gateway. This page provides a complete reference of Rack Gateway environment variables. For a shorter overview, see [Configuration](/configuration/). +## Production Safety Check + +The first time a gateway starts against a database, it marks the database as `production` (or `development` when `DEV_MODE=true`). A gateway refuses to start against a production database while any development or test-only setting is present, because each one weakens a security control: + +| Variable | Why it's refused in production | +|----------|--------------------------------| +| `DEV_MODE=true` | Relaxes cookies, CSP and secret requirements | +| `E2E_TEST_MODE=true` | Skips WebAuthn assertion checks | +| `AWS_ENDPOINT_URL` | Sends every AWS call (S3 audit anchors, STS, KMS) to another endpoint | +| `AWS_ENDPOINT_URL_S3` | Sends audit anchors to another S3 endpoint | +| `AWS_ENDPOINT_URL_STS`, `AWS_ENDPOINT_URL_KMS` | Sends AWS credential exchange or key operations to another endpoint | +| `POSTMARK_API_BASE` | Sends the Postmark token to another server | +| `GOOGLE_OAUTH_BASE_URL` without `https://` | Accepts identity tokens over plain HTTP | + +The error names every offending variable. Remove them from the production environment and restart. `GOOGLE_ALLOWED_DOMAIN` is required whenever `DEV_MODE` is off. + ## Core Server | Variable | Default | Description | diff --git a/docs/src/content/docs/deployment/docker.mdx b/docs/src/content/docs/deployment/docker.mdx index bd2a229c..2a972489 100644 --- a/docs/src/content/docs/deployment/docker.mdx +++ b/docs/src/content/docs/deployment/docker.mdx @@ -19,8 +19,18 @@ docker pull docker.io/docspringcom/rack-gateway:latest | Tag | Description | |-----|-------------| -| `latest` | Most recent release | -| `v0.x.x` | Specific version (recommended for production) | +| `v0.x.x` | Specific release (use this in production) | +| `` | The commit a release was built from | +| `latest` | Most recent release (mutable, avoid in production) | + +### Verifying an Image + +Every release image has a signed build provenance attestation generated by GitHub Actions. Verify it before +deploying: + +```bash +gh attestation verify oci://docker.io/docspringcom/rack-gateway:v0.x.x --repo DocSpring/rack-gateway +``` ## Quick Start @@ -108,7 +118,7 @@ services: RACK_HOST: ${RACK_HOST} ADMIN_USERS: ${ADMIN_USERS} healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"] interval: 10s timeout: 5s retries: 5 @@ -169,7 +179,7 @@ Example health check configuration: ```yaml healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/src/content/docs/deployment/production-checklist.mdx b/docs/src/content/docs/deployment/production-checklist.mdx index 2884adff..35715aae 100644 --- a/docs/src/content/docs/deployment/production-checklist.mdx +++ b/docs/src/content/docs/deployment/production-checklist.mdx @@ -186,6 +186,9 @@ Use this checklist before deploying Rack Gateway to production. Each item addres - CircleCI token configured - Approval job names match - GitHub integration tested + - `service_image_patterns` set for each app, pinning the image repository (approval-bound builds are refused without it) + - `approved_deploy_commands` lists the exact commands CI runs (an empty list allows none) + - `vcs_repo` set to `owner/repo` for CircleCI auto-approval - [ ] **CI/CD tokens created** - API tokens with `cicd` role diff --git a/docs/src/content/docs/development/api-reference.mdx b/docs/src/content/docs/development/api-reference.mdx index 399a7129..82084094 100644 --- a/docs/src/content/docs/development/api-reference.mdx +++ b/docs/src/content/docs/development/api-reference.mdx @@ -17,7 +17,9 @@ The OpenAPI spec is the source of truth, but this page summarizes the current su ## Authentication -Most endpoints require either a session cookie (browser/CLI login) or an API token (automation). +Gateway endpoints require a session (browser cookie or CLI login). API tokens (automation) can use the rack proxy (`/api/v1/rack-proxy/*`) and only four gateway endpoints: `GET /info`, `GET /rack`, `POST /deploy-approval-requests` and `GET /deploy-approval-requests/{id}`. Every other gateway endpoint returns `403 API tokens cannot use this endpoint`. + +Each gateway endpoint declares the permission it needs; callers without it get `403 insufficient permissions: requires `. Endpoints under `/users/{email}` that read or sign out a user's own sessions, profile or audit log also accept that user themselves. ### Session Authentication @@ -46,11 +48,15 @@ For the complete, generated schema, use `GET /openapi.json`. ### OAuth + CLI Login -- `POST /auth/cli/start` -- `GET /auth/cli/callback` -- `POST /auth/cli/complete` -- `GET /auth/cli/mfa` -- `POST /auth/cli/mfa` +CLI login uses the RFC 8252 loopback flow (see [OAuth Flow](/security/authentication/oauth-flow/)): + +- `POST /auth/cli/start` - S256 code challenge, CLI state and loopback `redirect_uri`; returns `auth_url` +- `GET /auth/cli/callback` - identity provider redirect; exchanges the code, then binds the browser +- `GET /auth/cli/mfa` - continues the bound browser to MFA (or enrollment) +- `POST /auth/cli/mfa` - submits an MFA code from the bound browser +- `GET /auth/cli/return` - issues the single-use login code and redirects the browser to the CLI's loopback listener +- `POST /auth/cli/cancel` - cancels the login from the bound browser; returns the loopback URL that tells the CLI +- `POST /auth/cli/complete` - redeems the login code with the CLI's code verifier for a session token - `GET /auth/web/login` (also supports `HEAD`) - `GET /auth/web/callback` - `GET /auth/web/logout` diff --git a/docs/src/content/docs/getting-started/architecture.mdx b/docs/src/content/docs/getting-started/architecture.mdx index e488d4dd..d5639b34 100644 --- a/docs/src/content/docs/getting-started/architecture.mdx +++ b/docs/src/content/docs/getting-started/architecture.mdx @@ -71,7 +71,7 @@ rack-gateway apps 4. Gateway validates session token 5. Gateway checks MFA requirements (if enabled) 6. Gateway checks RBAC permissions for `convox:app:list` -7. If authorized, gateway forwards to real Convox rack +7. If authorized, gateway forwards to real Convox rack. Only the request headers the Convox API uses (an allowlist) are forwarded; cookies, CSRF and MFA headers and any client-supplied identity headers are dropped. The gateway authenticates to the rack with its own credential and sets `X-Convox-Actor` to the signed-in user (or `token:`), so the rack's own logs name the real caller. Query parameters are limited to the ones the Convox SDK sends; a request with any other is refused, because the rack would read options such as an exec command or release env from the query string without the gateway checking them. 8. Gateway logs the action to audit log 9. Response returned to user diff --git a/docs/src/content/docs/integrations/deploy-approvals/circleci.mdx b/docs/src/content/docs/integrations/deploy-approvals/circleci.mdx index d836cb59..99d467db 100644 --- a/docs/src/content/docs/integrations/deploy-approvals/circleci.mdx +++ b/docs/src/content/docs/integrations/deploy-approvals/circleci.mdx @@ -141,7 +141,7 @@ jobs: --app myapp \ --git-commit "$CIRCLE_SHA1" \ --branch "$CIRCLE_BRANCH" \ - --ci-metadata "{\"workflow_id\":\"$CIRCLE_WORKFLOW_ID\",\"pipeline_number\":<< pipeline.number >>}" \ + --ci-metadata "{\"workflow_id\":\"$CIRCLE_WORKFLOW_ID\",\"pipeline_number\":\"<< pipeline.number >>\"}" \ --message "Deploy $CIRCLE_BRANCH@${CIRCLE_SHA1:0:7} to production" environment: RACK_GATEWAY_API_TOKEN: $RACK_GATEWAY_API_TOKEN @@ -241,6 +241,12 @@ sequenceDiagram participant Gateway as Gateway participant CircleCI as CircleCI API + Gateway->>Gateway: Deploy approval still approved and unexpired? + Gateway->>CircleCI: GET /workflow/{workflow_id} + CircleCI-->>Gateway: pipeline_id + Gateway->>CircleCI: GET /pipeline/{pipeline_id} + CircleCI-->>Gateway: vcs.revision, repository URLs + Gateway->>Gateway: Revision = approved commit? Repository = vcs_repo? Not a fork? Gateway->>CircleCI: GET /workflow/{workflow_id}/job CircleCI-->>Gateway: List of jobs Gateway->>Gateway: Find job matching approval_job_name @@ -248,12 +254,17 @@ sequenceDiagram CircleCI-->>Gateway: 202 Accepted ``` +The `ci_metadata` comes from the CI job that requested the approval, so the gateway doesn't trust it on its +own: the hold job is only approved when the workflow's pipeline is building the approved commit of the app's +repository, and the job is re-checked against the deploy approval on every attempt (retries can run later, +after a rejection or expiry). + ### What Gateway Needs | Source | Data | |--------|------| -| CI metadata | `workflow_id` | -| App settings | `circleci_approval_job_name` | +| CI metadata | `workflow_id` (a CircleCI workflow UUID), `pipeline_number` (string) | +| App settings | `circleci_approval_job_name`, `vcs_repo` (`owner/repo`) | | Gateway config | `CIRCLECI_TOKEN` | ## Pipeline URL Display @@ -345,6 +356,8 @@ curl -H "Circle-Token: YOUR_TOKEN" https://circleci.com/api/v2/me - [ ] `ci_metadata` includes `workflow_id` - [ ] `CIRCLECI_TOKEN` has correct permissions - [ ] App `ci_provider` is set to `circleci` +- [ ] App `vcs_repo` is set to the repository CircleCI builds (`owner/repo`) +- [ ] The workflow was building the approved commit (not a later re-run of another commit) **Check gateway logs:** ```bash @@ -358,6 +371,9 @@ convox logs --app rack-gateway | grep -i circleci | `approval job 'xxx' not found in workflow` | Job name mismatch | | `403 Forbidden` | Token lacks permissions or expired | | `workflow not found` | Invalid workflow_id in metadata | +| `invalid CircleCI workflow_id` | `workflow_id` is not a UUID (the job is cancelled) | +| `CircleCI pipeline does not match the deploy approval` | The pipeline builds another commit or repository, is a fork, or its repository can't be determined (the job is cancelled; approve the hold manually after checking) | +| `deploy approval is no longer active` | The approval was rejected, expired or deployed before the job ran (the job is cancelled) | ### API Token Permissions diff --git a/docs/src/content/docs/integrations/deploy-approvals/index.mdx b/docs/src/content/docs/integrations/deploy-approvals/index.mdx index 94220c8d..4ab56099 100644 --- a/docs/src/content/docs/integrations/deploy-approvals/index.mdx +++ b/docs/src/content/docs/integrations/deploy-approvals/index.mdx @@ -94,11 +94,14 @@ graph TB ### Git Commit Verification -Every approval is tied to a specific git commit hash: +Every approval is tied to a specific full (40-character) git commit SHA. An API token deploying under an approval must: -- Approval cannot be reused for different code -- Manifest validation ensures deployed images match approved commit -- Prevents deploying arbitrary code even with compromised CI/CD token +- Build the archive it uploaded under that approval (one upload and one build per approval) +- Send no `git-sha`, or one equal to the approved commit (the gateway always uses the approved commit, never the client's) +- Reference only pre-built images, each tagged with the approved commit (`` or `-`, e.g. `-amd64`) and matching the app's `service_image_patterns` +- Run only commands listed in `approved_deploy_commands` (an empty list allows none) + +`service_image_patterns` is required for approval-bound builds: the commit tag alone does not pin the image repository. ### MFA Step-Up @@ -183,7 +186,9 @@ Configure via UI or environment variables: | `vcs_repo` | Repository in org/repo format | | `ci_provider` | CI system (circleci) | | `circleci_approval_job_name` | CircleCI approval job name | -| `circleci_auto_approve_on_approval` | Enable auto-approval | +| `circleci_auto_approve_on_approval` | Enable auto-approval (the gateway first checks the workflow's pipeline builds the approved commit of `vcs_repo`) | +| `service_image_patterns` | **Required.** Map of service name (or `*`) to an anchored regex; `{{GIT_COMMIT}}` is replaced with the approved commit, e.g. `{"*": "docker\\.io/org/app:{{GIT_COMMIT}}-amd64"}` | +| `approved_deploy_commands` | Exact commands a token may run under an approval (e.g. migrations); empty allows none |