diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69da4a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.DS_Store +.idea +.vscode +*.env* +coverage.out +deployments +docs +*.md +Dockerfile.local +docker-compose.yml diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..f9fb678 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,80 @@ +name: Build and Push Container Image +# Publishes the Argus application image to ghcr.io//argus. + +on: + push: + branches: [ main ] + paths: + - 'Dockerfile' + - 'go.mod' + - 'go.sum' + - 'cmd/**' + - 'internal/**' + - '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: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: argus + +jobs: + build-and-push: + name: Build & Push Image + runs-on: ubuntu-latest + 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 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: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + 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 + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + build-args: | + BUILD_VERSION=${{ github.sha }} + GIT_COMMIT=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index f6197b8..d4c9637 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 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/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 272b6e6..7db22a2 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 @@ -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 @@ -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`). | | `DB_TYPE` | `sqlite` | `sqlite` or `postgres`. | | `AUDIT_ENUMS_CONFIG` | `configs/enums.yaml` | Path to allowed event types/actions. | 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..b46c0ba 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`). ```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,7 +81,8 @@ argus: ### Automated (CI/CD) The Helm chart automation follows a standard GitOps setup: -- **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`. 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 @@ -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,8 @@ 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 (`:` 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` | @@ -117,5 +119,6 @@ helm push .cr-release-packages/argus-0.1.0.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 871fde8..fefa2cd 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: @@ -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 0a1d315..b17b2cd 100644 --- a/deployments/helm/argus/values.yaml +++ b/deployments/helm/argus/values.yaml @@ -4,7 +4,9 @@ replicaCount: 2 image: repository: ghcr.io/lsflk/argus - tag: f21da85558410c19b6a96275b6e0eef2a788fb4b + # :latest is mutable; the chart uses pullPolicy Always for this tag. + # Pin a git SHA (and IfNotPresent) in production. + tag: latest pullPolicy: IfNotPresent service: @@ -50,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 3c30f56..00ef5ef 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: @@ -49,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..cb945fe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,6 +7,14 @@ 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`) via the `X-API-Key` header. `/health`, `/metrics`, and `/version` are unauthenticated. + +```bash +curl -H "X-API-Key: " ... +``` + ## Endpoints Overview | Method | Endpoint | Description | @@ -46,6 +54,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 +120,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..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_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`). 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 2d5e979..3606c44 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -5,13 +5,16 @@ import ( "crypto/subtle" "net/http" "os" - "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 must send X-API-Key. 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,23 +29,17 @@ 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 := r.Header.Get("X-API-Key") + 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 } diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..684c134 --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,115 @@ +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("rejects 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.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) { + 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("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("X-API-Key", "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{ 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)