From f250a0b93df298cc7679353a0aedc7ab1ffc818f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 23:02:47 +0530 Subject: [PATCH 1/5] feat(ci): publish Argus application image and chart 0.1.1 Chart 0.1.0 defaulted to an unpublished ghcr.io/opennsw/argus tag, which left installs in ImagePullBackOff. Build and push ghcr.io/lsflk/argus on main, then republish the chart so the default image is pullable. Co-authored-by: Cursor --- .dockerignore | 12 ++ .github/workflows/build-image.yml | 195 ++++++++++++++++++ .github/workflows/helm-ci.yml | 3 +- README.md | 4 +- deployments/helm/argus/Chart.yaml | 2 +- deployments/helm/argus/README.md | 13 +- deployments/helm/argus/templates/_helpers.tpl | 7 + .../helm/argus/templates/deployment.yaml | 2 +- deployments/helm/argus/values.yaml | 3 +- deployments/helm/values-example.yaml | 2 +- 10 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build-image.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8654089 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.DS_Store +**/.DS_Store +deployments +docs +*.md +CLAUDE.md +Dockerfile.local +docker-compose.yml +coverage.out +**/*_test.go diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..96c82b5 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,195 @@ +name: Build and Push Container Image +# Builds the Argus application image and publishes it to GHCR as ghcr.io//argus. +# On main (or workflow_dispatch), also packages and pushes the Helm chart so the +# chart default image.repository/tag matches a real, pullable image. + +on: + push: + branches: [ main ] + paths: + - 'Dockerfile' + - 'go.mod' + - 'go.sum' + - 'cmd/**' + - 'internal/**' + - 'pkg/**' + - 'configs/**' + - 'deployments/helm/**' + - '.github/workflows/build-image.yml' + pull_request: + branches: [ main ] + paths: + - 'Dockerfile' + - 'go.mod' + - 'go.sum' + - 'cmd/**' + - 'internal/**' + - 'pkg/**' + - 'configs/**' + - '.github/workflows/build-image.yml' + workflow_dispatch: + inputs: + chart_version: + description: 'Stable chart version to publish (leave empty to use Chart.yaml version)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: argus + CHART_NAME: argus + CHART_DIR: deployments/helm/argus + +jobs: + build-and-push: + name: Build & Push Image + runs-on: ubuntu-latest + outputs: + image: ${{ steps.image.outputs.image }} + digest: ${{ steps.build.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Convert repository owner to lowercase + id: repo_owner + run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Set image name + id: image + run: echo "image=${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.image }} + tags: | + type=raw,value=${{ github.sha }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + labels: | + org.opencontainers.image.title=argus + org.opencontainers.image.description=Centralized tamper-evident audit logging service + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.revision=${{ github.sha }} + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILD_VERSION=${{ github.sha }} + BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }} + GIT_COMMIT=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Summary + run: | + { + echo "## Application Image" + echo "" + echo "- Image: \`${{ steps.image.outputs.image }}\`" + echo "- Tags: \`${{ github.sha }}\`${{ github.ref == 'refs/heads/main' && ', `latest`' || '' }}" + echo "- Pushed: \`${{ github.event_name != 'pull_request' }}\`" + } >> "$GITHUB_STEP_SUMMARY" + + publish-chart: + name: Publish Helm Chart + needs: build-and-push + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 10 + + - name: Decide whether to publish a stable chart + id: should + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git diff --name-only HEAD~1 HEAD > /tmp/changed 2>/dev/null; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if grep -Eq '^(deployments/helm/|\.github/workflows/build-image\.yml)' /tmp/changed; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Helm + if: steps.should.outputs.publish == 'true' + uses: azure/setup-helm@v4 + with: + version: v3.16.2 + + - name: Convert repository owner to lowercase + if: steps.should.outputs.publish == 'true' + id: repo_owner + run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR (Helm) + if: steps.should.outputs.publish == 'true' + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} --username ${{ github.actor }} --password-stdin + + - name: Package and push chart + if: steps.should.outputs.publish == 'true' + id: package + run: | + CHART_VER="${{ github.event.inputs.chart_version }}" + if [ -z "$CHART_VER" ]; then + CHART_VER="$(awk '/^version:/{print $2; exit}' ${{ env.CHART_DIR }}/Chart.yaml)" + fi + echo "version=${CHART_VER}" >> "$GITHUB_OUTPUT" + + helm lint ${{ env.CHART_DIR }} -f deployments/helm/values-example.yaml + helm package ${{ env.CHART_DIR }} \ + --version "${CHART_VER}" \ + --destination . + helm push ${{ env.CHART_NAME }}-${CHART_VER}.tgz \ + oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts + + - name: Summary + if: steps.should.outputs.publish == 'true' + run: | + { + echo "## Helm Chart Published" + echo "" + echo "- Chart: \`oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts/${{ env.CHART_NAME }}\`" + echo "- Version: \`${{ steps.package.outputs.version }}\`" + echo "- Default image: \`${{ needs.build-and-push.outputs.image }}:latest\`" + echo "- Pull command:" + echo " \`helm pull oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts/${{ env.CHART_NAME }} --version ${{ steps.package.outputs.version }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index f6197b8..ac50cff 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -1,6 +1,7 @@ name: Helm Chart CI # Validates the Helm chart on PRs: lint + render. Never publishes — packaging and -# pushing to GHCR is handled by build-dev-chart.yml (dev) and release-chart.yml (release). +# pushing to GHCR is handled by build-dev-chart.yml (dev charts) and +# build-image.yml (application image + stable chart). on: pull_request: diff --git a/README.md b/README.md index 272b6e6..34a5d97 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,12 @@ Argus exports standard Prometheus metrics at `/metrics`: ## Deployment & Helm Chart -Argus provides an official Helm chart published as an **OCI Artifact** to GitHub Container Registry (`ghcr.io/lsflk/charts/argus`), as well as local chart source at [`deployments/helm/argus`](deployments/helm/argus). +Argus provides an official Helm chart published as an **OCI Artifact** to GitHub Container Registry (`ghcr.io/lsflk/charts/argus`), as well as local chart source at [`deployments/helm/argus`](deployments/helm/argus). The application container image is published to `ghcr.io/lsflk/argus` (`:latest` and `:`). ### Install via OCI Artifact (Recommended) ```bash helm upgrade --install argus oci://ghcr.io/lsflk/charts/argus \ - --version 0.1.0 \ + --version 0.1.1 \ -n \ --create-namespace \ -f custom-values.yaml diff --git a/deployments/helm/argus/Chart.yaml b/deployments/helm/argus/Chart.yaml index 123b3a9..d5e04d3 100644 --- a/deployments/helm/argus/Chart.yaml +++ b/deployments/helm/argus/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: argus description: Secure, Tamper-Proof cryptographic Audit Log Service type: application -version: 0.1.0 +version: 0.1.1 appVersion: "1.0.0" home: https://github.com/LSFLK/argus sources: diff --git a/deployments/helm/argus/README.md b/deployments/helm/argus/README.md index 5adec00..33ead3b 100644 --- a/deployments/helm/argus/README.md +++ b/deployments/helm/argus/README.md @@ -22,12 +22,12 @@ This chart provisions: ### 1. Install via OCI Artifact (Recommended) -Argus Helm charts are published as OCI artifacts to the GitHub Container Registry (`ghcr.io`). +Argus Helm charts are published as OCI artifacts to the GitHub Container Registry (`ghcr.io`). Chart `0.1.1` defaults to the application image `ghcr.io/lsflk/argus:latest` (the same image is also tagged with the git SHA). Do not use chart `0.1.0` — it pointed at an unpublished `ghcr.io/opennsw/argus` image. ```bash # Install directly from OCI registry helm upgrade --install argus oci://ghcr.io/lsflk/charts/argus \ - --version 0.1.0 \ + --version 0.1.1 \ --namespace \ --create-namespace \ --values ./custom-values.yaml @@ -36,7 +36,7 @@ helm upgrade --install argus oci://ghcr.io/lsflk/charts/argus \ To pull the packaged chart locally: ```bash -helm pull oci://ghcr.io/lsflk/charts/argus --version 0.1.0 +helm pull oci://ghcr.io/lsflk/charts/argus --version 0.1.1 ``` ### 2. Standalone Deployment from Source @@ -57,7 +57,7 @@ When referencing Argus as a dependency in your umbrella chart (`Chart.yaml`): ```yaml dependencies: - name: argus - version: "0.1.0" + version: "0.1.1" repository: "oci://ghcr.io/lsflk/charts" ``` @@ -81,6 +81,7 @@ argus: ### Automated (CI/CD) The Helm chart automation follows a standard GitOps setup: +- **Application image (`.github/workflows/build-image.yml`)**: Builds and pushes `ghcr.io/lsflk/argus` (`:` and `:latest` on `main`). After a successful image push, also publishes the stable chart version from `Chart.yaml` (currently `0.1.1`) to `oci://ghcr.io/lsflk/charts`. - **Dev Chart (`.github/workflows/build-dev-chart.yml`)**: On pushes to `main` with chart changes (or manual dispatch), packages and publishes a dev chart (`0.0.0-dev.`) to `oci://ghcr.io/lsflk/charts`. - **Chart CI (`.github/workflows/helm-ci.yml`)**: Lints the chart and verifies template rendering on pull requests. @@ -96,7 +97,7 @@ helm package deployments/helm/argus -d .cr-release-packages/ echo "$CR_PAT" | helm registry login ghcr.io -u --password-stdin # 3. Push OCI artifact -helm push .cr-release-packages/argus-0.1.0.tgz oci://ghcr.io/lsflk/charts +helm push .cr-release-packages/argus-0.1.1.tgz oci://ghcr.io/lsflk/charts ``` --- @@ -107,7 +108,7 @@ helm push .cr-release-packages/argus-0.1.0.tgz oci://ghcr.io/lsflk/charts | --- | --- | --- | | `replicaCount` | Number of pod replicas | `2` | | `image.repository` | Container image repository | `ghcr.io/lsflk/argus` | -| `image.tag` | Container image tag | `f21da85558410c19b6a96275b6e0eef2a788fb4b` | +| `image.tag` | Container image tag (`:` also published) | `latest` | | `service.type` | Kubernetes service type | `ClusterIP` | | `service.port` | Service port | `3001` | | `env.ENVIRONMENT` | Deployment environment | `production` | diff --git a/deployments/helm/argus/templates/_helpers.tpl b/deployments/helm/argus/templates/_helpers.tpl index 596c087..7fed240 100644 --- a/deployments/helm/argus/templates/_helpers.tpl +++ b/deployments/helm/argus/templates/_helpers.tpl @@ -49,3 +49,10 @@ Selector labels app.kubernetes.io/name: {{ include "argus.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} + +{{/* +Container image reference. Empty image.tag falls back to Chart.AppVersion. +*/}} +{{- define "argus.image" -}} +{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }} +{{- end }} diff --git a/deployments/helm/argus/templates/deployment.yaml b/deployments/helm/argus/templates/deployment.yaml index 871fde8..7979743 100644 --- a/deployments/helm/argus/templates/deployment.yaml +++ b/deployments/helm/argus/templates/deployment.yaml @@ -18,7 +18,7 @@ spec: runAsNonRoot: true containers: - name: {{ .Chart.Name }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + image: "{{ include "argus.image" . }}" imagePullPolicy: {{ .Values.image.pullPolicy }} env: - name: DB_PASSWORD diff --git a/deployments/helm/argus/values.yaml b/deployments/helm/argus/values.yaml index 0a1d315..54363ca 100644 --- a/deployments/helm/argus/values.yaml +++ b/deployments/helm/argus/values.yaml @@ -4,7 +4,8 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - tag: f21da85558410c19b6a96275b6e0eef2a788fb4b + # Empty tag falls back to Chart.AppVersion. CI also publishes :latest and :. + tag: latest pullPolicy: IfNotPresent service: diff --git a/deployments/helm/values-example.yaml b/deployments/helm/values-example.yaml index 3c30f56..ef835d8 100644 --- a/deployments/helm/values-example.yaml +++ b/deployments/helm/values-example.yaml @@ -5,7 +5,7 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - tag: "1.0.0" + tag: latest pullPolicy: IfNotPresent service: From f507bc3edc1871f43c728051d43c1c7bb2420012 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 10:23:10 +0530 Subject: [PATCH 2/5] refactor(ci): keep image publish workflow image-only Drop the duplicate chart publisher, PR docker build, and unused Helm image helper. Stable chart 0.1.1 still goes out through the existing build-dev-chart dispatch. Co-authored-by: Cursor --- .dockerignore | 4 - .github/workflows/build-image.yml | 141 +----------------- .github/workflows/helm-ci.yml | 4 +- deployments/helm/argus/README.md | 8 +- deployments/helm/argus/templates/_helpers.tpl | 7 - .../helm/argus/templates/deployment.yaml | 2 +- deployments/helm/argus/values.yaml | 1 - deployments/helm/values-example.yaml | 2 +- 8 files changed, 15 insertions(+), 154 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8654089..62decef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,8 @@ .git .github .DS_Store -**/.DS_Store deployments docs *.md -CLAUDE.md Dockerfile.local docker-compose.yml -coverage.out -**/*_test.go diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 96c82b5..0ba6579 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -1,22 +1,8 @@ name: Build and Push Container Image -# Builds the Argus application image and publishes it to GHCR as ghcr.io//argus. -# On main (or workflow_dispatch), also packages and pushes the Helm chart so the -# chart default image.repository/tag matches a real, pullable image. +# Publishes the Argus application image to ghcr.io//argus. on: push: - branches: [ main ] - paths: - - 'Dockerfile' - - 'go.mod' - - 'go.sum' - - 'cmd/**' - - 'internal/**' - - 'pkg/**' - - 'configs/**' - - 'deployments/helm/**' - - '.github/workflows/build-image.yml' - pull_request: branches: [ main ] paths: - 'Dockerfile' @@ -28,11 +14,6 @@ on: - 'configs/**' - '.github/workflows/build-image.yml' workflow_dispatch: - inputs: - chart_version: - description: 'Stable chart version to publish (leave empty to use Chart.yaml version)' - required: false - type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -45,16 +26,11 @@ permissions: env: REGISTRY: ghcr.io IMAGE_NAME: argus - CHART_NAME: argus - CHART_DIR: deployments/helm/argus jobs: build-and-push: name: Build & Push Image runs-on: ubuntu-latest - outputs: - image: ${{ steps.image.outputs.image }} - digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -63,133 +39,30 @@ jobs: id: repo_owner run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - name: Set image name - id: image - run: echo "image=${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_OUTPUT" - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry - if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ steps.image.outputs.image }} - tags: | - type=raw,value=${{ github.sha }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - labels: | - org.opencontainers.image.title=argus - org.opencontainers.image.description=Centralized tamper-evident audit logging service - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.licenses=Apache-2.0 - org.opencontainers.image.revision=${{ github.sha }} - - name: Build and push - id: build uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} build-args: | BUILD_VERSION=${{ github.sha }} - BUILD_TIME=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }} GIT_COMMIT=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false - - - name: Summary - run: | - { - echo "## Application Image" - echo "" - echo "- Image: \`${{ steps.image.outputs.image }}\`" - echo "- Tags: \`${{ github.sha }}\`${{ github.ref == 'refs/heads/main' && ', `latest`' || '' }}" - echo "- Pushed: \`${{ github.event_name != 'pull_request' }}\`" - } >> "$GITHUB_STEP_SUMMARY" - - publish-chart: - name: Publish Helm Chart - needs: build-and-push - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 10 - - - name: Decide whether to publish a stable chart - id: should - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "publish=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if ! git diff --name-only HEAD~1 HEAD > /tmp/changed 2>/dev/null; then - echo "publish=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if grep -Eq '^(deployments/helm/|\.github/workflows/build-image\.yml)' /tmp/changed; then - echo "publish=true" >> "$GITHUB_OUTPUT" - else - echo "publish=false" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Helm - if: steps.should.outputs.publish == 'true' - uses: azure/setup-helm@v4 - with: - version: v3.16.2 - - - name: Convert repository owner to lowercase - if: steps.should.outputs.publish == 'true' - id: repo_owner - run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - - name: Log in to GHCR (Helm) - if: steps.should.outputs.publish == 'true' - run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} --username ${{ github.actor }} --password-stdin - - - name: Package and push chart - if: steps.should.outputs.publish == 'true' - id: package - run: | - CHART_VER="${{ github.event.inputs.chart_version }}" - if [ -z "$CHART_VER" ]; then - CHART_VER="$(awk '/^version:/{print $2; exit}' ${{ env.CHART_DIR }}/Chart.yaml)" - fi - echo "version=${CHART_VER}" >> "$GITHUB_OUTPUT" - - helm lint ${{ env.CHART_DIR }} -f deployments/helm/values-example.yaml - helm package ${{ env.CHART_DIR }} \ - --version "${CHART_VER}" \ - --destination . - helm push ${{ env.CHART_NAME }}-${CHART_VER}.tgz \ - oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts - - - name: Summary - if: steps.should.outputs.publish == 'true' - run: | - { - echo "## Helm Chart Published" - echo "" - echo "- Chart: \`oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts/${{ env.CHART_NAME }}\`" - echo "- Version: \`${{ steps.package.outputs.version }}\`" - echo "- Default image: \`${{ needs.build-and-push.outputs.image }}:latest\`" - echo "- Pull command:" - echo " \`helm pull oci://${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/charts/${{ env.CHART_NAME }} --version ${{ steps.package.outputs.version }}\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index ac50cff..d4c9637 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -1,7 +1,7 @@ name: Helm Chart CI # Validates the Helm chart on PRs: lint + render. Never publishes — packaging and -# pushing to GHCR is handled by build-dev-chart.yml (dev charts) and -# build-image.yml (application image + stable chart). +# pushing the chart to GHCR is handled by build-dev-chart.yml; the application +# image is handled by build-image.yml. on: pull_request: diff --git a/deployments/helm/argus/README.md b/deployments/helm/argus/README.md index 33ead3b..34db211 100644 --- a/deployments/helm/argus/README.md +++ b/deployments/helm/argus/README.md @@ -22,7 +22,7 @@ This chart provisions: ### 1. Install via OCI Artifact (Recommended) -Argus Helm charts are published as OCI artifacts to the GitHub Container Registry (`ghcr.io`). Chart `0.1.1` defaults to the application image `ghcr.io/lsflk/argus:latest` (the same image is also tagged with the git SHA). Do not use chart `0.1.0` — it pointed at an unpublished `ghcr.io/opennsw/argus` image. +Argus Helm charts are published as OCI artifacts to the GitHub Container Registry (`ghcr.io`). ```bash # Install directly from OCI registry @@ -81,8 +81,8 @@ argus: ### Automated (CI/CD) The Helm chart automation follows a standard GitOps setup: -- **Application image (`.github/workflows/build-image.yml`)**: Builds and pushes `ghcr.io/lsflk/argus` (`:` and `:latest` on `main`). After a successful image push, also publishes the stable chart version from `Chart.yaml` (currently `0.1.1`) to `oci://ghcr.io/lsflk/charts`. -- **Dev Chart (`.github/workflows/build-dev-chart.yml`)**: On pushes to `main` with chart changes (or manual dispatch), packages and publishes a dev chart (`0.0.0-dev.`) to `oci://ghcr.io/lsflk/charts`. +- **Application image (`.github/workflows/build-image.yml`)**: Builds and pushes `ghcr.io/lsflk/argus` (`:` and `:latest`) on pushes to `main`. +- **Dev Chart (`.github/workflows/build-dev-chart.yml`)**: On pushes to `main` with chart changes (or manual dispatch), packages and publishes a dev chart (`0.0.0-dev.`) to `oci://ghcr.io/lsflk/charts`. Stable versions (e.g. `0.1.1`) are published by dispatching this workflow with an explicit version. - **Chart CI (`.github/workflows/helm-ci.yml`)**: Lints the chart and verifies template rendering on pull requests. ### Manual Packaging and Push @@ -108,7 +108,7 @@ helm push .cr-release-packages/argus-0.1.1.tgz oci://ghcr.io/lsflk/charts | --- | --- | --- | | `replicaCount` | Number of pod replicas | `2` | | `image.repository` | Container image repository | `ghcr.io/lsflk/argus` | -| `image.tag` | Container image tag (`:` also published) | `latest` | +| `image.tag` | Container image tag | `latest` | | `service.type` | Kubernetes service type | `ClusterIP` | | `service.port` | Service port | `3001` | | `env.ENVIRONMENT` | Deployment environment | `production` | diff --git a/deployments/helm/argus/templates/_helpers.tpl b/deployments/helm/argus/templates/_helpers.tpl index 7fed240..596c087 100644 --- a/deployments/helm/argus/templates/_helpers.tpl +++ b/deployments/helm/argus/templates/_helpers.tpl @@ -49,10 +49,3 @@ Selector labels app.kubernetes.io/name: {{ include "argus.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} - -{{/* -Container image reference. Empty image.tag falls back to Chart.AppVersion. -*/}} -{{- define "argus.image" -}} -{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }} -{{- end }} diff --git a/deployments/helm/argus/templates/deployment.yaml b/deployments/helm/argus/templates/deployment.yaml index 7979743..871fde8 100644 --- a/deployments/helm/argus/templates/deployment.yaml +++ b/deployments/helm/argus/templates/deployment.yaml @@ -18,7 +18,7 @@ spec: runAsNonRoot: true containers: - name: {{ .Chart.Name }} - image: "{{ include "argus.image" . }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} env: - name: DB_PASSWORD diff --git a/deployments/helm/argus/values.yaml b/deployments/helm/argus/values.yaml index 54363ca..100ea7c 100644 --- a/deployments/helm/argus/values.yaml +++ b/deployments/helm/argus/values.yaml @@ -4,7 +4,6 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - # Empty tag falls back to Chart.AppVersion. CI also publishes :latest and :. tag: latest pullPolicy: IfNotPresent diff --git a/deployments/helm/values-example.yaml b/deployments/helm/values-example.yaml index ef835d8..3c30f56 100644 --- a/deployments/helm/values-example.yaml +++ b/deployments/helm/values-example.yaml @@ -5,7 +5,7 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - tag: latest + tag: "1.0.0" pullPolicy: IfNotPresent service: From 05e59dfd22179da3c3f00b04004287ad53dc5d60 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 10:44:27 +0530 Subject: [PATCH 3/5] fix(ci): verify image builds on PRs and Always-pull latest Rebuild the container on Dockerfile/Go PRs without pushing, force imagePullPolicy Always when the chart tag is latest, and align the example values with published tags. Co-authored-by: Cursor --- .dockerignore | 4 ++++ .github/workflows/build-image.yml | 14 +++++++++++++- deployments/helm/argus/README.md | 7 ++++--- deployments/helm/argus/templates/deployment.yaml | 2 +- deployments/helm/argus/values.yaml | 2 ++ deployments/helm/values-example.yaml | 3 ++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index 62decef..69da4a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ .git .github .DS_Store +.idea +.vscode +*.env* +coverage.out deployments docs *.md diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 0ba6579..f9fb678 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -13,6 +13,17 @@ on: - 'pkg/**' - 'configs/**' - '.github/workflows/build-image.yml' + pull_request: + branches: [ main ] + paths: + - 'Dockerfile' + - 'go.mod' + - 'go.sum' + - 'cmd/**' + - 'internal/**' + - 'pkg/**' + - 'configs/**' + - '.github/workflows/build-image.yml' workflow_dispatch: concurrency: @@ -43,6 +54,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -54,7 +66,7 @@ jobs: with: context: . file: ./Dockerfile - push: true + push: ${{ github.event_name != 'pull_request' }} tags: | ${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ steps.repo_owner.outputs.owner }}/${{ env.IMAGE_NAME }}:latest diff --git a/deployments/helm/argus/README.md b/deployments/helm/argus/README.md index 34db211..23ebbf5 100644 --- a/deployments/helm/argus/README.md +++ b/deployments/helm/argus/README.md @@ -81,8 +81,8 @@ argus: ### Automated (CI/CD) The Helm chart automation follows a standard GitOps setup: -- **Application image (`.github/workflows/build-image.yml`)**: Builds and pushes `ghcr.io/lsflk/argus` (`:` and `:latest`) on pushes to `main`. -- **Dev Chart (`.github/workflows/build-dev-chart.yml`)**: On pushes to `main` with chart changes (or manual dispatch), packages and publishes a dev chart (`0.0.0-dev.`) to `oci://ghcr.io/lsflk/charts`. Stable versions (e.g. `0.1.1`) are published by dispatching this workflow with an explicit version. +- **Application image (`.github/workflows/build-image.yml`)**: Builds and pushes `ghcr.io/lsflk/argus` (`:` and `:latest`) on pushes to `main`. PRs that touch Go code or the Dockerfile build the image without pushing. After the first publish, set the GHCR package visibility to public under https://github.com/orgs/LSFLK/packages so clusters can pull without an imagePullSecret. +- **Dev Chart (`.github/workflows/build-dev-chart.yml`)**: On pushes to `main` with chart changes (or manual dispatch), packages and publishes a dev chart (`0.0.0-dev.`) to `oci://ghcr.io/lsflk/charts`. After the image push completes, publish the stable chart by dispatching this workflow with `version=0.1.1`. - **Chart CI (`.github/workflows/helm-ci.yml`)**: Lints the chart and verifies template rendering on pull requests. ### Manual Packaging and Push @@ -108,7 +108,8 @@ helm push .cr-release-packages/argus-0.1.1.tgz oci://ghcr.io/lsflk/charts | --- | --- | --- | | `replicaCount` | Number of pod replicas | `2` | | `image.repository` | Container image repository | `ghcr.io/lsflk/argus` | -| `image.tag` | Container image tag | `latest` | +| `image.tag` | Container image tag (`:` is also published) | `latest` | +| `image.pullPolicy` | Image pull policy (`Always` when `tag` is `latest`) | `IfNotPresent` | | `service.type` | Kubernetes service type | `ClusterIP` | | `service.port` | Service port | `3001` | | `env.ENVIRONMENT` | Deployment environment | `production` | diff --git a/deployments/helm/argus/templates/deployment.yaml b/deployments/helm/argus/templates/deployment.yaml index 871fde8..a8775b3 100644 --- a/deployments/helm/argus/templates/deployment.yaml +++ b/deployments/helm/argus/templates/deployment.yaml @@ -19,7 +19,7 @@ spec: containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} + imagePullPolicy: {{ if eq .Values.image.tag "latest" }}Always{{ else }}{{ .Values.image.pullPolicy }}{{ end }} env: - name: DB_PASSWORD valueFrom: diff --git a/deployments/helm/argus/values.yaml b/deployments/helm/argus/values.yaml index 100ea7c..d63b2ad 100644 --- a/deployments/helm/argus/values.yaml +++ b/deployments/helm/argus/values.yaml @@ -4,6 +4,8 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus + # :latest is mutable; the chart uses pullPolicy Always for this tag. + # Pin a git SHA (and IfNotPresent) in production. tag: latest pullPolicy: IfNotPresent diff --git a/deployments/helm/values-example.yaml b/deployments/helm/values-example.yaml index 3c30f56..20f71dd 100644 --- a/deployments/helm/values-example.yaml +++ b/deployments/helm/values-example.yaml @@ -5,7 +5,8 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - tag: "1.0.0" + # Published tags are :latest and :. 1.0.0 is not published. + tag: latest pullPolicy: IfNotPresent service: From 61c26df1c9e468c275027f8c6ae1d051b9a2ef2d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 12:35:03 +0530 Subject: [PATCH 4/5] Address PR Comments Co-authored-by: Cursor --- CLAUDE.md | 2 +- README.md | 4 +- deployments/helm/argus/README.md | 3 +- .../helm/argus/templates/deployment.yaml | 4 +- .../argus/templates/external-secrets.yaml | 4 +- deployments/helm/argus/templates/secrets.yaml | 2 +- deployments/helm/argus/values.yaml | 2 + deployments/helm/values-example.yaml | 1 + docker-compose.yml | 1 + docs/API.md | 19 ++- internal/config/README.md | 2 +- internal/middleware/auth.go | 36 ++++-- internal/middleware/auth_test.go | 114 ++++++++++++++++++ internal/middleware/cors.go | 2 +- 14 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 internal/middleware/auth_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 189d50a..e6a5361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ route registration, graceful shutdown) — business logic does not belong there. changes to `CanonicalizeRequest` are a breaking/security-relevant change and must be mirrored in `pkg/audit/security.go`. - Auth middleware (`internal/middleware/auth.go`) uses `crypto/subtle.ConstantTimeCompare` over a - SHA-256 pre-hash of the bearer token specifically to avoid length-based timing side channels — don't + SHA-256 pre-hash of the API key specifically to avoid length-based timing side channels — don't replace this with a plain `==` comparison. ### Database diff --git a/README.md b/README.md index 34a5d97..8f7348f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ - **Cryptographic Non-Repudiation** – Server-side verification of RSA/Ed25519 signatures for incoming logs. The `computeHash` covers the entire payload (including all metadata and message bodies) to guarantee full payload integrity. - **High-Performance Batching** – Client-side worker pool with buffered batching to minimize HTTP overhead and eliminate goroutine leaks. The server utilizes GORM's `CreateInBatches` for high-throughput ingestion. - **Production Observability** – Built-in Prometheus metrics for ingestion rates, latencies, and security errors. -- **Secure by Default** – Fail-closed Bearer token authentication utilizing `crypto/subtle.ConstantTimeCompare` with SHA-256 pre-hashing to prevent length-based timing attacks. Strict validation of log schemas. +- **Secure by Default** – Fail-closed API key authentication utilizing `crypto/subtle.ConstantTimeCompare` with SHA-256 pre-hashing to prevent length-based timing attacks. Strict validation of log schemas. ## Quick Start: Using the Audit Interface @@ -172,7 +172,7 @@ For full Helm configuration parameters, GitOps umbrella chart integration, and O | Variable | Default | Description | | --- | --- | --- | -| `ARGUS_AUTH_TOKEN` | - | Bearer token required for API access. | +| `ARGUS_API_KEY` | - | API key required for write operations (`X-API-Key` or `Authorization: Bearer `). | | `DB_TYPE` | `sqlite` | `sqlite` or `postgres`. | | `AUDIT_ENUMS_CONFIG` | `configs/enums.yaml` | Path to allowed event types/actions. | diff --git a/deployments/helm/argus/README.md b/deployments/helm/argus/README.md index 23ebbf5..b46c0ba 100644 --- a/deployments/helm/argus/README.md +++ b/deployments/helm/argus/README.md @@ -119,5 +119,6 @@ helm push .cr-release-packages/argus-0.1.1.tgz oci://ghcr.io/lsflk/charts | `env.DB_NAME` | Database name | `audit_db` | | `env.REQUIRE_SIGNATURES` | Enable signature verification | `"true"` | | `env.S3_COMPLIANCE_BUCKET` | S3 WORM compliance bucket name | `"audit-compliance-logs-staging"` | -| `auth.existingSecret` | Existing Kubernetes secret containing `DB_PASSWORD` | `""` | +| `auth.existingSecret` | Existing Kubernetes secret containing `password` and `api-key` | `""` | +| `auth.apiKey` | API key for authentication (`ARGUS_API_KEY`) | `""` | | `auth.externalSecrets.enabled` | Enable ExternalSecrets Operator (ESO) | `false` | diff --git a/deployments/helm/argus/templates/deployment.yaml b/deployments/helm/argus/templates/deployment.yaml index a8775b3..fefa2cd 100644 --- a/deployments/helm/argus/templates/deployment.yaml +++ b/deployments/helm/argus/templates/deployment.yaml @@ -38,11 +38,11 @@ spec: name: {{ .Values.auth.existingSecret | default (printf "%s-credentials" (include "argus.fullname" .)) }} key: AWS_SECRET_ACCESS_KEY optional: true - - name: ARGUS_AUTH_TOKEN + - name: ARGUS_API_KEY valueFrom: secretKeyRef: name: {{ .Values.auth.existingSecret | default (printf "%s-credentials" (include "argus.fullname" .)) }} - key: ARGUS_AUTH_TOKEN + key: {{ if .Values.auth.existingSecret }}api-key{{ else }}ARGUS_API_KEY{{ end }} optional: true {{- range $key, $val := .Values.env }} - name: {{ $key }} diff --git a/deployments/helm/argus/templates/external-secrets.yaml b/deployments/helm/argus/templates/external-secrets.yaml index 9433431..feb6fb9 100644 --- a/deployments/helm/argus/templates/external-secrets.yaml +++ b/deployments/helm/argus/templates/external-secrets.yaml @@ -26,8 +26,8 @@ spec: remoteRef: key: {{ .Values.auth.externalSecrets.remoteAwsKey | quote }} property: "aws_secret_access_key" - - secretKey: ARGUS_AUTH_TOKEN + - secretKey: ARGUS_API_KEY remoteRef: key: {{ .Values.auth.externalSecrets.remoteDbKey | quote }} - property: "argus_auth_token" + property: "argus_api_key" {{- end }} diff --git a/deployments/helm/argus/templates/secrets.yaml b/deployments/helm/argus/templates/secrets.yaml index 27f32a8..28063a1 100644 --- a/deployments/helm/argus/templates/secrets.yaml +++ b/deployments/helm/argus/templates/secrets.yaml @@ -8,7 +8,7 @@ metadata: type: Opaque stringData: DB_PASSWORD: {{ required "A database password is required (.Values.auth.password)" .Values.auth.password | quote }} - ARGUS_AUTH_TOKEN: {{ .Values.auth.token | default "" | quote }} + ARGUS_API_KEY: {{ .Values.auth.apiKey | default .Values.auth.token | default "" | quote }} # Optional plain-text credentials for S3 development/testing AWS_ACCESS_KEY_ID: "" AWS_SECRET_ACCESS_KEY: "" diff --git a/deployments/helm/argus/values.yaml b/deployments/helm/argus/values.yaml index d63b2ad..b17b2cd 100644 --- a/deployments/helm/argus/values.yaml +++ b/deployments/helm/argus/values.yaml @@ -52,6 +52,8 @@ auth: username: "postgres" # Set plain password here if externalSecrets are disabled and existingSecret is empty password: "" + # Static API key injected as ARGUS_API_KEY. auth.token is still read as a fallback. + apiKey: "" existingSecret: "" # --- External Secrets Operator (ESO) --- diff --git a/deployments/helm/values-example.yaml b/deployments/helm/values-example.yaml index 20f71dd..00ef5ef 100644 --- a/deployments/helm/values-example.yaml +++ b/deployments/helm/values-example.yaml @@ -50,6 +50,7 @@ env: auth: username: "postgres" password: "example-db-password" + apiKey: "example-api-key" existingSecret: "" externalSecrets: enabled: false diff --git a/docker-compose.yml b/docker-compose.yml index f9068be..178c958 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - DB_NAME=audit_db - DB_SSLMODE=disable - PORT=3001 + - ARGUS_API_KEY=dev-api-key depends_on: - postgres restart: unless-stopped diff --git a/docs/API.md b/docs/API.md index e1e3929..b4776d2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,6 +7,16 @@ Complete API reference for integrating Argus into your microservices architectur - **Development**: `http://localhost:3001` - **Production**: `https://argus.yourdomain.com` or `http://argus-service:3001` (internal) +## Authentication + +Write and read API endpoints require a static API key (`ARGUS_API_KEY`). Send it as `X-API-Key`, or as `Authorization: Bearer ` for compatibility. `/health`, `/metrics`, and `/version` are unauthenticated. + +```bash +curl -H "X-API-Key: " ... +# or +curl -H "Authorization: Bearer " ... +``` + ## Endpoints Overview | Method | Endpoint | Description | @@ -46,6 +56,7 @@ Complete API reference for integrating Argus into your microservices architectur ```bash curl -X POST http://localhost:3001/api/audit-logs \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "traceId": "550e8400-e29b-41d4-a716-446655440000", "timestamp": "2024-01-20T10:00:00Z", @@ -111,16 +122,16 @@ curl -X POST http://localhost:3001/api/audit-logs \ ```bash # Get all audit logs (paginated) -curl http://localhost:3001/api/audit-logs +curl -H "X-API-Key: " http://localhost:3001/api/audit-logs # Filter by trace ID -curl http://localhost:3001/api/audit-logs?traceId=550e8400-e29b-41d4-a716-446655440000 +curl -H "X-API-Key: " http://localhost:3001/api/audit-logs?traceId=550e8400-e29b-41d4-a716-446655440000 # Filter by event type -curl http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT +curl -H "X-API-Key: " http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT # Multiple filters with pagination -curl http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT&status=SUCCESS&limit=20&offset=0 +curl -H "X-API-Key: " "http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT&status=SUCCESS&limit=20&offset=0" ``` **Success Response: 200 OK** diff --git a/internal/config/README.md b/internal/config/README.md index b496853..f19a302 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -78,7 +78,7 @@ In addition to the `enums.yaml` file, Argus relies on several environment variab | Variable | Required | Default | Description | | --- | --- | --- | --- | -| `ARGUS_AUTH_TOKEN` | **Yes** | - | A high-entropy Bearer token required for all API write operations. Argus fails closed if this is missing. | +| `ARGUS_API_KEY` | **Yes** | - | A high-entropy API key required for all API write operations (`X-API-Key` or `Authorization: Bearer `). Argus fails closed if this is missing. `ARGUS_AUTH_TOKEN` is still accepted as a fallback. | | `ENVIRONMENT` | No | `development` | Setting to `production` enables stricter logging and security defaults. | | `DB_TYPE` | No | `sqlite` | Database engine to use (`sqlite` or `postgres`). | | `AUDIT_ENUMS_CONFIG` | No | `configs/enums.yaml` | Override path for the Event Type configuration file. | diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 2d5e979..f2373c3 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -8,10 +8,15 @@ import ( "strings" ) -// AuthMiddleware validates the Authorization header for a Bearer token +// AuthMiddleware validates a static API key on write endpoints. +// The key is read from ARGUS_API_KEY, with ARGUS_AUTH_TOKEN as a fallback +// for older Helm/env deployments. Clients may send X-API-Key or +// Authorization: Bearer . func AuthMiddleware(next http.Handler) http.Handler { - // For production, we require an API key. Fail-closed if missing. apiKey := os.Getenv("ARGUS_API_KEY") + if apiKey == "" { + apiKey = os.Getenv("ARGUS_AUTH_TOKEN") + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Allow public access to health, metrics, and version endpoints @@ -26,26 +31,31 @@ func AuthMiddleware(next http.Handler) http.Handler { return } - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Unauthorized: Missing Authorization header", http.StatusUnauthorized) - return - } - - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - http.Error(w, "Unauthorized: Invalid Authorization header format", http.StatusUnauthorized) + presented := extractAPIKey(r) + if presented == "" { + http.Error(w, "Unauthorized: Missing API key", http.StatusUnauthorized) return } // Use constant-time comparison on hashes to prevent length-based timing attacks expectedHash := sha256.Sum256([]byte(apiKey)) - actualHash := sha256.Sum256([]byte(parts[1])) + actualHash := sha256.Sum256([]byte(presented)) if subtle.ConstantTimeCompare(actualHash[:], expectedHash[:]) != 1 { - http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) + http.Error(w, "Unauthorized: Invalid API key", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } + +func extractAPIKey(r *http.Request) string { + if key := r.Header.Get("X-API-Key"); key != "" { + return key + } + parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") { + return parts[1] + } + return "" +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..7fb8995 --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthMiddleware(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("health is public without a configured key", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "") + t.Setenv("ARGUS_AUTH_TOKEN", "") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("fails closed when no key is configured", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "") + t.Setenv("ARGUS_AUTH_TOKEN", "") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "not configured") + }) + + t.Run("accepts X-API-Key", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "secret-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("X-API-Key", "secret-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("accepts Authorization Bearer", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "secret-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("Authorization", "Bearer secret-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("falls back to ARGUS_AUTH_TOKEN env", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "") + t.Setenv("ARGUS_AUTH_TOKEN", "legacy-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("X-API-Key", "legacy-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("rejects a missing key", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "secret-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Missing API key") + }) + + t.Run("rejects an invalid key", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "secret-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("X-API-Key", "wrong") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Invalid API key") + }) + + t.Run("ARGUS_API_KEY takes precedence over ARGUS_AUTH_TOKEN", func(t *testing.T) { + t.Setenv("ARGUS_API_KEY", "new-key") + t.Setenv("ARGUS_AUTH_TOKEN", "legacy-key") + h := AuthMiddleware(ok) + + req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("Authorization", "Bearer legacy-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + req = httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) + req.Header.Set("Authorization", "Bearer new-key") + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + }) +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index cdd81ed..6b86c25 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -35,7 +35,7 @@ func DefaultCORSConfig() CORSConfig { "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", }, AllowedHeaders: []string{ - "Origin", "Content-Type", "Accept", "Authorization", + "Origin", "Content-Type", "Accept", "Authorization", "X-API-Key", "X-Requested-With", "X-CSRF-Token", "X-Request-ID", }, ExposedHeaders: []string{ From 3405d17c25ca2b0cc5045575124c0b10d4d8b863 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 12:59:52 +0530 Subject: [PATCH 5/5] fix(auth): require X-API-Key only Drop Authorization Bearer support. Request signing stays separate; IDP/Bearer can land later once the API key path is working. Co-authored-by: Cursor --- README.md | 2 +- docs/API.md | 4 +--- internal/config/README.md | 2 +- internal/middleware/auth.go | 17 ++--------------- internal/middleware/auth_test.go | 9 +++++---- pkg/audit/client.go | 2 +- 6 files changed, 11 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8f7348f..7db22a2 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ For full Helm configuration parameters, GitOps umbrella chart integration, and O | Variable | Default | Description | | --- | --- | --- | -| `ARGUS_API_KEY` | - | API key required for write operations (`X-API-Key` or `Authorization: Bearer `). | +| `ARGUS_API_KEY` | - | API key required for write operations (`X-API-Key`). | | `DB_TYPE` | `sqlite` | `sqlite` or `postgres`. | | `AUDIT_ENUMS_CONFIG` | `configs/enums.yaml` | Path to allowed event types/actions. | diff --git a/docs/API.md b/docs/API.md index b4776d2..cb945fe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,12 +9,10 @@ Complete API reference for integrating Argus into your microservices architectur ## Authentication -Write and read API endpoints require a static API key (`ARGUS_API_KEY`). Send it as `X-API-Key`, or as `Authorization: Bearer ` for compatibility. `/health`, `/metrics`, and `/version` are unauthenticated. +Write and read API endpoints require a static API key (`ARGUS_API_KEY`) via the `X-API-Key` header. `/health`, `/metrics`, and `/version` are unauthenticated. ```bash curl -H "X-API-Key: " ... -# or -curl -H "Authorization: Bearer " ... ``` ## Endpoints Overview diff --git a/internal/config/README.md b/internal/config/README.md index f19a302..a0817a6 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -78,7 +78,7 @@ In addition to the `enums.yaml` file, Argus relies on several environment variab | Variable | Required | Default | Description | | --- | --- | --- | --- | -| `ARGUS_API_KEY` | **Yes** | - | A high-entropy API key required for all API write operations (`X-API-Key` or `Authorization: Bearer `). Argus fails closed if this is missing. `ARGUS_AUTH_TOKEN` is still accepted as a fallback. | +| `ARGUS_API_KEY` | **Yes** | - | A high-entropy API key required for all API write operations (`X-API-Key`). Argus fails closed if this is missing. `ARGUS_AUTH_TOKEN` is still accepted as an env fallback. | | `ENVIRONMENT` | No | `development` | Setting to `production` enables stricter logging and security defaults. | | `DB_TYPE` | No | `sqlite` | Database engine to use (`sqlite` or `postgres`). | | `AUDIT_ENUMS_CONFIG` | No | `configs/enums.yaml` | Override path for the Event Type configuration file. | diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f2373c3..3606c44 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -5,13 +5,11 @@ import ( "crypto/subtle" "net/http" "os" - "strings" ) // AuthMiddleware validates a static API key on write endpoints. // The key is read from ARGUS_API_KEY, with ARGUS_AUTH_TOKEN as a fallback -// for older Helm/env deployments. Clients may send X-API-Key or -// Authorization: Bearer . +// for older Helm/env deployments. Clients must send X-API-Key. func AuthMiddleware(next http.Handler) http.Handler { apiKey := os.Getenv("ARGUS_API_KEY") if apiKey == "" { @@ -31,7 +29,7 @@ func AuthMiddleware(next http.Handler) http.Handler { return } - presented := extractAPIKey(r) + presented := r.Header.Get("X-API-Key") if presented == "" { http.Error(w, "Unauthorized: Missing API key", http.StatusUnauthorized) return @@ -48,14 +46,3 @@ func AuthMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - -func extractAPIKey(r *http.Request) string { - if key := r.Header.Get("X-API-Key"); key != "" { - return key - } - parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2) - if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") { - return parts[1] - } - return "" -} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 7fb8995..684c134 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -48,7 +48,7 @@ func TestAuthMiddleware(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) }) - t.Run("accepts Authorization Bearer", func(t *testing.T) { + t.Run("rejects Authorization Bearer", func(t *testing.T) { t.Setenv("ARGUS_API_KEY", "secret-key") h := AuthMiddleware(ok) @@ -56,7 +56,8 @@ func TestAuthMiddleware(t *testing.T) { req.Header.Set("Authorization", "Bearer secret-key") w := httptest.NewRecorder() h.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Missing API key") }) t.Run("falls back to ARGUS_AUTH_TOKEN env", func(t *testing.T) { @@ -100,13 +101,13 @@ func TestAuthMiddleware(t *testing.T) { h := AuthMiddleware(ok) req := httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) - req.Header.Set("Authorization", "Bearer legacy-key") + req.Header.Set("X-API-Key", "legacy-key") w := httptest.NewRecorder() h.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) req = httptest.NewRequest(http.MethodPost, "/api/audit-logs", nil) - req.Header.Set("Authorization", "Bearer new-key") + req.Header.Set("X-API-Key", "new-key") w = httptest.NewRecorder() h.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) diff --git a/pkg/audit/client.go b/pkg/audit/client.go index bce956a..d778cea 100644 --- a/pkg/audit/client.go +++ b/pkg/audit/client.go @@ -426,7 +426,7 @@ func (c *Client) logBatch(parentCtx context.Context, events []*AuditLogRequest) req.Header.Set("Content-Type", "application/json") if c.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("X-API-Key", c.apiKey) } resp, err := c.httpClient.Do(req)