Add token-based authentication for receiver
All checks were successful
ci / build (push) Successful in 28s
All checks were successful
ci / build (push) Successful in 28s
This commit is contained in:
parent
042ce77ddc
commit
aa3e8cddf9
10 changed files with 635 additions and 41 deletions
34
README.md
34
README.md
|
|
@ -30,7 +30,7 @@ Runs as a sidecar alongside CI workloads. On a configurable interval, it reads `
|
|||
./collector --interval=2s --top=10 --push-endpoint=http://receiver:8080/api/v1/metrics
|
||||
```
|
||||
|
||||
**Flags:** `--interval`, `--proc-path`, `--log-level`, `--log-format`, `--top`, `--push-endpoint`
|
||||
**Flags:** `--interval`, `--proc-path`, `--log-level`, `--log-format`, `--top`, `--push-endpoint`, `--push-token`
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ Runs as a sidecar alongside CI workloads. On a configurable interval, it reads `
|
|||
| `GITHUB_WORKFLOW` | Workflow filename | `ci.yml` |
|
||||
| `GITHUB_JOB` | Job name | `build` |
|
||||
| `GITHUB_RUN_ID` | Unique run identifier | `run-123` |
|
||||
| `COLLECTOR_PUSH_TOKEN` | Bearer token for push endpoint auth | — |
|
||||
| `CGROUP_PROCESS_MAP` | JSON: process name → container name | `{"node":"runner"}` |
|
||||
| `CGROUP_LIMITS` | JSON: per-container CPU/memory limits | See below |
|
||||
|
||||
|
|
@ -69,23 +70,42 @@ HTTP service that stores metric summaries in SQLite (via GORM) and exposes a que
|
|||
| -------------- | --------------------- | ---------------------------------------------- | ------------ |
|
||||
| `--addr` | — | HTTP listen address | `:8080` |
|
||||
| `--db` | — | SQLite database path | `metrics.db` |
|
||||
| `--read-token` | `RECEIVER_READ_TOKEN` | Pre-shared token for read endpoints (optional) | — |
|
||||
| `--read-token` | `RECEIVER_READ_TOKEN` | Pre-shared token for authentication (required) | — |
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `POST /api/v1/metrics` — receive and store a metric summary
|
||||
- `GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}` — query stored metrics (protected if `--read-token` is set)
|
||||
- `POST /api/v1/metrics` — receive and store a metric summary (requires scoped push token)
|
||||
- `POST /api/v1/token` — generate a scoped push token (requires read token auth)
|
||||
- `GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}` — query stored metrics (requires read token auth)
|
||||
|
||||
**Authentication:**
|
||||
|
||||
When `--read-token` is configured, the GET endpoint requires a Bearer token:
|
||||
All metrics endpoints require authentication via `--read-token`:
|
||||
|
||||
- The GET endpoint requires a Bearer token matching the read token
|
||||
- The POST metrics endpoint requires a scoped push token (generated via `POST /api/v1/token`)
|
||||
- The token endpoint itself requires the read token
|
||||
|
||||
**Token flow:**
|
||||
|
||||
```bash
|
||||
# 1. Admin generates a scoped push token using the read token
|
||||
curl -X POST http://localhost:8080/api/v1/token \
|
||||
-H "Authorization: Bearer my-secret-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"organization":"my-org","repository":"my-repo","workflow":"ci.yml","job":"build"}'
|
||||
# → {"token":"<hex-encoded HMAC>"}
|
||||
|
||||
# 2. Collector uses the scoped token to push metrics
|
||||
./collector --push-endpoint=http://localhost:8080/api/v1/metrics \
|
||||
--push-token=<token-from-step-1>
|
||||
|
||||
# 3. Query metrics with the read token
|
||||
curl -H "Authorization: Bearer my-secret-token" \ #gitleaks:allow
|
||||
http://localhost:8080/api/v1/metrics/repo/org/repo/workflow/job
|
||||
http://localhost:8080/api/v1/metrics/repo/my-org/my-repo/ci.yml/build
|
||||
```
|
||||
|
||||
If no token is configured, the endpoint remains open.
|
||||
Push tokens are HMAC-SHA256 digests derived from the read token and the scope (org/repo/workflow/job). They are stateless — no database storage is needed.
|
||||
|
||||
## How Metrics Are Collected
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ func main() {
|
|||
logFormat := flag.String("log-format", defaultLogFormat, "Output format: json, text")
|
||||
topN := flag.Int("top", defaultTopN, "Number of top processes to include")
|
||||
pushEndpoint := flag.String("push-endpoint", "", "HTTP endpoint to push metrics to (e.g., http://localhost:8080/api/v1/metrics)")
|
||||
pushToken := flag.String("push-token", os.Getenv("COLLECTOR_PUSH_TOKEN"), "Bearer token for push endpoint authentication (or set COLLECTOR_PUSH_TOKEN)")
|
||||
flag.Parse()
|
||||
|
||||
// Setup structured logging for application logs
|
||||
|
|
@ -61,7 +62,7 @@ func main() {
|
|||
|
||||
// Setup push client if endpoint is configured
|
||||
if *pushEndpoint != "" {
|
||||
pushClient := summary.NewPushClient(*pushEndpoint)
|
||||
pushClient := summary.NewPushClient(*pushEndpoint, *pushToken)
|
||||
c.SetPushClient(pushClient)
|
||||
execCtx := pushClient.ExecutionContext()
|
||||
appLogger.Info("push client configured",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import (
|
|||
"edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/summary"
|
||||
)
|
||||
|
||||
// setupTestReceiver creates a test receiver with SQLite storage and HTTP server
|
||||
const testReadToken = "integration-test-token"
|
||||
|
||||
// setupTestReceiver creates a test receiver with SQLite storage, auth, and HTTP server
|
||||
func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
|
@ -27,7 +29,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
|
|||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), "")
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -41,6 +43,11 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
|
|||
return store, server, cleanup
|
||||
}
|
||||
|
||||
// generatePushToken generates a scoped push token for an execution context
|
||||
func generatePushToken(exec summary.ExecutionContext) string {
|
||||
return receiver.GenerateScopedToken(testReadToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
}
|
||||
|
||||
func TestPushClientToReceiver(t *testing.T) {
|
||||
store, server, cleanup := setupTestReceiver(t)
|
||||
defer cleanup()
|
||||
|
|
@ -85,10 +92,18 @@ func TestPushClientToReceiver(t *testing.T) {
|
|||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Send via HTTP client
|
||||
resp, err := http.Post(server.URL+"/api/v1/metrics", "application/json", bytes.NewReader(body))
|
||||
// Send via HTTP client with scoped push token
|
||||
pushToken := generatePushToken(testCtx)
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/metrics", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Post() error = %v", err)
|
||||
t.Fatalf("NewRequest() error = %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+pushToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do() error = %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
|
|
@ -148,8 +163,11 @@ func TestPushClientIntegration(t *testing.T) {
|
|||
t.Setenv("GITHUB_JOB", "push-job")
|
||||
t.Setenv("GITHUB_RUN_ID", "push-run-456")
|
||||
|
||||
// Create push client - it reads from env vars
|
||||
pushClient := summary.NewPushClient(server.URL + "/api/v1/metrics")
|
||||
// Generate scoped push token
|
||||
pushToken := receiver.GenerateScopedToken(testReadToken, "push-client-org", "push-client-repo", "push-test.yml", "push-job")
|
||||
|
||||
// Create push client with token - it reads execution context from env vars
|
||||
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", pushToken)
|
||||
|
||||
// Verify execution context was read from env
|
||||
ctx := pushClient.ExecutionContext()
|
||||
|
|
@ -194,7 +212,6 @@ func TestMultiplePushes(t *testing.T) {
|
|||
defer cleanup()
|
||||
|
||||
// Simulate multiple workflow runs pushing metrics via direct HTTP POST
|
||||
// This avoids env var manipulation which could cause issues with parallel tests
|
||||
runs := []summary.ExecutionContext{
|
||||
{Organization: "org-a", Repository: "repo-1", Workflow: "ci.yml", Job: "build", RunID: "run-1"},
|
||||
{Organization: "org-a", Repository: "repo-1", Workflow: "ci.yml", Job: "build", RunID: "run-2"},
|
||||
|
|
@ -219,9 +236,17 @@ func TestMultiplePushes(t *testing.T) {
|
|||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(server.URL+"/api/v1/metrics", "application/json", bytes.NewReader(body))
|
||||
pushToken := generatePushToken(execCtx)
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/metrics", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Post() error = %v for run %+v", err, execCtx)
|
||||
t.Fatalf("NewRequest() error = %v for run %+v", err, execCtx)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+pushToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do() error = %v for run %+v", err, execCtx)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
|
|
@ -247,3 +272,110 @@ func TestMultiplePushes(t *testing.T) {
|
|||
t.Errorf("got %d metrics for org-a/repo-1/ci.yml/test, want 1", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushClientWithTokenIntegration(t *testing.T) {
|
||||
readToken := "integration-secret"
|
||||
store, server, cleanup := setupTestReceiverWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
// Generate a scoped token via the API
|
||||
tokenReqBody, _ := json.Marshal(map[string]string{
|
||||
"organization": "token-org",
|
||||
"repository": "token-repo",
|
||||
"workflow": "ci.yml",
|
||||
"job": "build",
|
||||
})
|
||||
tokenReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/token", bytes.NewReader(tokenReqBody))
|
||||
tokenReq.Header.Set("Authorization", "Bearer "+readToken)
|
||||
tokenReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
tokenResp, err := http.DefaultClient.Do(tokenReq)
|
||||
if err != nil {
|
||||
t.Fatalf("token request error: %v", err)
|
||||
}
|
||||
defer func() { _ = tokenResp.Body.Close() }()
|
||||
|
||||
if tokenResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("token request status = %d, want %d", tokenResp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
var tokenBody struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil {
|
||||
t.Fatalf("decode token response: %v", err)
|
||||
}
|
||||
|
||||
// Use the scoped token to push metrics
|
||||
t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org")
|
||||
t.Setenv("GITHUB_REPOSITORY", "token-repo")
|
||||
t.Setenv("GITHUB_WORKFLOW", "ci.yml")
|
||||
t.Setenv("GITHUB_JOB", "build")
|
||||
t.Setenv("GITHUB_RUN_ID", "token-run-1")
|
||||
|
||||
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", tokenBody.Token)
|
||||
|
||||
testSummary := &summary.RunSummary{
|
||||
StartTime: time.Now().Add(-10 * time.Second),
|
||||
EndTime: time.Now(),
|
||||
DurationSeconds: 10.0,
|
||||
SampleCount: 2,
|
||||
}
|
||||
|
||||
if err := pushClient.Push(context.Background(), testSummary); err != nil {
|
||||
t.Fatalf("Push() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify stored
|
||||
metrics, err := store.GetMetricsByWorkflowJob("token-org", "token-repo", "ci.yml", "build")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("got %d metrics, want 1", len(metrics))
|
||||
}
|
||||
if metrics[0].RunID != "token-run-1" {
|
||||
t.Errorf("RunID = %q, want %q", metrics[0].RunID, "token-run-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushClientWithWrongTokenIntegration(t *testing.T) {
|
||||
readToken := "integration-secret"
|
||||
_, server, cleanup := setupTestReceiverWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org")
|
||||
t.Setenv("GITHUB_REPOSITORY", "token-repo")
|
||||
t.Setenv("GITHUB_WORKFLOW", "ci.yml")
|
||||
t.Setenv("GITHUB_JOB", "build")
|
||||
t.Setenv("GITHUB_RUN_ID", "token-run-2")
|
||||
|
||||
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", "wrong-token")
|
||||
|
||||
err := pushClient.Push(context.Background(), &summary.RunSummary{SampleCount: 1})
|
||||
if err == nil {
|
||||
t.Error("Push() with wrong token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestReceiverWithToken(t *testing.T, token string) (*receiver.Store, *httptest.Server, func()) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := receiver.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), token)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
cleanup := func() {
|
||||
server.Close()
|
||||
_ = store.Close()
|
||||
}
|
||||
|
||||
return store, server, cleanup
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ import (
|
|||
type Handler struct {
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
readToken string // Pre-shared token for read endpoints (empty = no auth required)
|
||||
readToken string // Pre-shared token for authentication (required)
|
||||
}
|
||||
|
||||
// NewHandler creates a new HTTP handler with the given store.
|
||||
// If readToken is non-empty, the GET metrics endpoint will require
|
||||
// Bearer token authentication.
|
||||
// readToken is required for authenticating all metrics endpoints.
|
||||
func NewHandler(store *Store, logger *slog.Logger, readToken string) *Handler {
|
||||
return &Handler{store: store, logger: logger, readToken: readToken}
|
||||
}
|
||||
|
|
@ -27,15 +26,17 @@ func NewHandler(store *Store, logger *slog.Logger, readToken string) *Handler {
|
|||
// RegisterRoutes registers all HTTP routes on the given mux
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/metrics", h.handleReceiveMetrics)
|
||||
mux.HandleFunc("POST /api/v1/token", h.handleGenerateToken)
|
||||
mux.HandleFunc("GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}", h.handleGetByWorkflowJob)
|
||||
mux.HandleFunc("GET /health", h.handleHealth)
|
||||
}
|
||||
|
||||
// validateReadToken checks the Authorization header for a valid Bearer token.
|
||||
// Returns true if authentication is disabled (empty readToken) or if the token matches.
|
||||
func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.readToken == "" {
|
||||
return true
|
||||
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
|
@ -62,6 +63,65 @@ func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool
|
|||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
if h.readToken == "" {
|
||||
http.Error(w, "token generation requires a configured read-token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.validateReadToken(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req TokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
|
||||
http.Error(w, "organization, repository, workflow, and job are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token := GenerateScopedToken(h.readToken, req.Organization, req.Repository, req.Workflow, req.Job)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
|
||||
}
|
||||
|
||||
// validatePushToken checks push authentication via scoped HMAC token.
|
||||
func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool {
|
||||
if h.readToken == "" {
|
||||
h.logger.Warn("no read-token configured, rejecting push", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
h.logger.Warn("missing push authorization", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
h.logger.Warn("invalid push authorization format", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if !ValidateScopedToken(h.readToken, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) {
|
||||
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
var payload MetricsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
|
|
@ -75,6 +135,10 @@ func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if !h.validatePushToken(w, r, payload.Execution) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.store.SaveMetric(&payload)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to save metric", slog.String("error", err.Error()))
|
||||
|
|
|
|||
|
|
@ -14,17 +14,21 @@ import (
|
|||
)
|
||||
|
||||
func TestHandler_ReceiveMetrics(t *testing.T) {
|
||||
h, cleanup := newTestHandler(t)
|
||||
const readToken = "test-token"
|
||||
h, cleanup := newTestHandlerWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
exec := ExecutionContext{
|
||||
Organization: "test-org",
|
||||
Repository: "test-repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
RunID: "run-123",
|
||||
}
|
||||
pushToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
|
||||
payload := MetricsPayload{
|
||||
Execution: ExecutionContext{
|
||||
Organization: "test-org",
|
||||
Repository: "test-repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
RunID: "run-123",
|
||||
},
|
||||
Execution: exec,
|
||||
Summary: summary.RunSummary{
|
||||
DurationSeconds: 60.0,
|
||||
SampleCount: 12,
|
||||
|
|
@ -34,6 +38,7 @@ func TestHandler_ReceiveMetrics(t *testing.T) {
|
|||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+pushToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -99,7 +104,8 @@ func TestHandler_ReceiveMetrics_MissingRunID(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestHandler_GetByWorkflowJob(t *testing.T) {
|
||||
h, cleanup := newTestHandler(t)
|
||||
const readToken = "test-token"
|
||||
h, cleanup := newTestHandlerWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
// Save metrics for different workflow/job combinations
|
||||
|
|
@ -115,6 +121,7 @@ func TestHandler_GetByWorkflowJob(t *testing.T) {
|
|||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org-x/repo-y/ci.yml/build", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -135,10 +142,12 @@ func TestHandler_GetByWorkflowJob(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestHandler_GetByWorkflowJob_NotFound(t *testing.T) {
|
||||
h, cleanup := newTestHandler(t)
|
||||
const readToken = "test-token"
|
||||
h, cleanup := newTestHandlerWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/workflow/job", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -224,6 +233,212 @@ func TestHandler_Health(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandler_GenerateToken(t *testing.T) {
|
||||
h, cleanup := newTestHandlerWithToken(t, "secret-token")
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(TokenRequest{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer secret-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp TokenResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
if len(resp.Token) != 64 {
|
||||
t.Errorf("token length = %d, want 64", len(resp.Token))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GenerateToken_NoAuth(t *testing.T) {
|
||||
h, cleanup := newTestHandlerWithToken(t, "secret-token")
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(TokenRequest{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GenerateToken_MissingFields(t *testing.T) {
|
||||
h, cleanup := newTestHandlerWithToken(t, "secret-token")
|
||||
defer cleanup()
|
||||
|
||||
// Missing job field
|
||||
body, _ := json.Marshal(TokenRequest{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer secret-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GenerateToken_NoReadToken(t *testing.T) {
|
||||
h, cleanup := newTestHandler(t) // no readToken configured
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(TokenRequest{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
|
||||
readToken := "secret-token"
|
||||
h, cleanup := newTestHandlerWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
exec := ExecutionContext{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
RunID: "run-1",
|
||||
}
|
||||
|
||||
validToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
wrongScopeToken := GenerateScopedToken(readToken, "other-org", "repo", "ci.yml", "build")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
wantCode int
|
||||
}{
|
||||
{"no auth", "", http.StatusUnauthorized},
|
||||
{"wrong token", "Bearer wrong-token", http.StatusUnauthorized},
|
||||
{"wrong scope", "Bearer " + wrongScopeToken, http.StatusUnauthorized},
|
||||
{"valid token", "Bearer " + validToken, http.StatusCreated},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := MetricsPayload{
|
||||
Execution: exec,
|
||||
Summary: summary.RunSummary{SampleCount: 1},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if tt.authHeader != "" {
|
||||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ReceiveMetrics_RejectsWhenNoReadToken(t *testing.T) {
|
||||
h, cleanup := newTestHandlerWithToken(t, "") // no readToken configured
|
||||
defer cleanup()
|
||||
|
||||
payload := MetricsPayload{
|
||||
Execution: ExecutionContext{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
Workflow: "ci.yml",
|
||||
Job: "build",
|
||||
RunID: "run-1",
|
||||
},
|
||||
Summary: summary.RunSummary{SampleCount: 1},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_GetByWorkflowJob_RejectsWhenNoReadToken(t *testing.T) {
|
||||
h, cleanup := newTestHandlerWithToken(t, "") // no readToken configured
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, func()) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
|
@ -233,7 +448,7 @@ func newTestHandler(t *testing.T) (*Handler, func()) {
|
|||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := NewHandler(store, logger, "") // no auth token for basic tests
|
||||
handler := NewHandler(store, logger, "") // no auth token — endpoints will reject
|
||||
|
||||
return handler, func() { _ = store.Close() }
|
||||
}
|
||||
|
|
|
|||
25
internal/receiver/token.go
Normal file
25
internal/receiver/token.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// ABOUTME: HMAC-SHA256 token generation and validation for scoped push authentication.
|
||||
// ABOUTME: Tokens are derived from a key + scope, enabling stateless validation without DB storage.
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateScopedToken computes an HMAC-SHA256 token scoped to a specific org/repo/workflow/job.
|
||||
// The canonical input is "v1\x00<org>\x00<repo>\x00<workflow>\x00<job>".
|
||||
func GenerateScopedToken(key, org, repo, workflow, job string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateScopedToken checks whether a token matches the expected HMAC for the given scope.
|
||||
// Uses constant-time comparison to prevent timing attacks.
|
||||
func ValidateScopedToken(key, token, org, repo, workflow, job string) bool {
|
||||
expected := GenerateScopedToken(key, org, repo, workflow, job)
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
|
||||
}
|
||||
78
internal/receiver/token_test.go
Normal file
78
internal/receiver/token_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package receiver
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateScopedToken_Deterministic(t *testing.T) {
|
||||
token1 := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
token2 := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if token1 != token2 {
|
||||
t.Errorf("tokens differ: %q vs %q", token1, token2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_ScopePinning(t *testing.T) {
|
||||
base := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
|
||||
variants := []struct {
|
||||
name string
|
||||
org string
|
||||
repo string
|
||||
wf string
|
||||
job string
|
||||
}{
|
||||
{"different org", "other-org", "repo", "wf", "job"},
|
||||
{"different repo", "org", "other-repo", "wf", "job"},
|
||||
{"different workflow", "org", "repo", "other-wf", "job"},
|
||||
{"different job", "org", "repo", "wf", "other-job"},
|
||||
}
|
||||
|
||||
for _, v := range variants {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
token := GenerateScopedToken("key", v.org, v.repo, v.wf, v.job)
|
||||
if token == base {
|
||||
t.Errorf("token for %s should differ from base", v.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_DifferentKeys(t *testing.T) {
|
||||
token1 := GenerateScopedToken("key-a", "org", "repo", "wf", "job")
|
||||
token2 := GenerateScopedToken("key-b", "org", "repo", "wf", "job")
|
||||
if token1 == token2 {
|
||||
t.Error("different keys should produce different tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_ValidHex(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if len(token) != 64 {
|
||||
t.Errorf("token length = %d, want 64", len(token))
|
||||
}
|
||||
if _, err := hex.DecodeString(token); err != nil {
|
||||
t.Errorf("token is not valid hex: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_Correct(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if !ValidateScopedToken("key", token, "org", "repo", "wf", "job") {
|
||||
t.Error("ValidateScopedToken should accept correct token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_WrongToken(t *testing.T) {
|
||||
if ValidateScopedToken("key", "deadbeef", "org", "repo", "wf", "job") {
|
||||
t.Error("ValidateScopedToken should reject wrong token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_WrongScope(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if ValidateScopedToken("key", token, "org", "repo", "wf", "other-job") {
|
||||
t.Error("ValidateScopedToken should reject token for different scope")
|
||||
}
|
||||
}
|
||||
|
|
@ -30,3 +30,16 @@ type StoredMetric struct {
|
|||
ReceivedAt string
|
||||
Payload string // JSON-encoded RunSummary
|
||||
}
|
||||
|
||||
// TokenRequest is the request body for POST /api/v1/token
|
||||
type TokenRequest struct {
|
||||
Organization string `json:"organization"`
|
||||
Repository string `json:"repository"`
|
||||
Workflow string `json:"workflow"`
|
||||
Job string `json:"job"`
|
||||
}
|
||||
|
||||
// TokenResponse is the response body for POST /api/v1/token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,14 +30,17 @@ type MetricsPayload struct {
|
|||
// PushClient sends metrics to the receiver service
|
||||
type PushClient struct {
|
||||
endpoint string
|
||||
token string
|
||||
client *http.Client
|
||||
ctx ExecutionContext
|
||||
}
|
||||
|
||||
// NewPushClient creates a new push client configured from environment variables
|
||||
func NewPushClient(endpoint string) *PushClient {
|
||||
// NewPushClient creates a new push client configured from environment variables.
|
||||
// If token is non-empty, it is sent as a Bearer token on each push request.
|
||||
func NewPushClient(endpoint, token string) *PushClient {
|
||||
return &PushClient{
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
|
|
@ -86,6 +89,9 @@ func (p *PushClient) Push(ctx context.Context, summary *RunSummary) error {
|
|||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func TestPushClient_Push(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewPushClient(server.URL)
|
||||
client := NewPushClient(server.URL, "")
|
||||
client.ctx = ExecutionContext{
|
||||
Organization: "test-org",
|
||||
Repository: "test-repo",
|
||||
|
|
@ -59,7 +59,7 @@ func TestPushClient_Push(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPushClient_Push_NilSummary(t *testing.T) {
|
||||
client := NewPushClient("http://localhost:9999")
|
||||
client := NewPushClient("http://localhost:9999", "")
|
||||
err := client.Push(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Errorf("Push(nil) error = %v, want nil", err)
|
||||
|
|
@ -72,7 +72,7 @@ func TestPushClient_Push_ServerError(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewPushClient(server.URL)
|
||||
client := NewPushClient(server.URL, "")
|
||||
client.ctx = ExecutionContext{RunID: "test"}
|
||||
|
||||
err := client.Push(context.Background(), &RunSummary{})
|
||||
|
|
@ -82,7 +82,7 @@ func TestPushClient_Push_ServerError(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPushClient_Push_ConnectionError(t *testing.T) {
|
||||
client := NewPushClient("http://localhost:1") // Invalid port
|
||||
client := NewPushClient("http://localhost:1", "") // Invalid port
|
||||
client.ctx = ExecutionContext{RunID: "test"}
|
||||
|
||||
err := client.Push(context.Background(), &RunSummary{})
|
||||
|
|
@ -147,8 +147,48 @@ func TestExecutionContextFromEnv_GiteaFallback(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPushClient_Push_WithToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewPushClient(server.URL, "my-token")
|
||||
client.ctx = ExecutionContext{RunID: "test"}
|
||||
|
||||
err := client.Push(context.Background(), &RunSummary{})
|
||||
if err != nil {
|
||||
t.Fatalf("Push() error = %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer my-token" {
|
||||
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer my-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushClient_Push_WithoutToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewPushClient(server.URL, "")
|
||||
client.ctx = ExecutionContext{RunID: "test"}
|
||||
|
||||
err := client.Push(context.Background(), &RunSummary{})
|
||||
if err != nil {
|
||||
t.Fatalf("Push() error = %v", err)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Errorf("Authorization = %q, want empty", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushClient_ExecutionContext(t *testing.T) {
|
||||
client := NewPushClient("http://example.com")
|
||||
client := NewPushClient("http://example.com", "")
|
||||
client.ctx = ExecutionContext{
|
||||
Organization: "org",
|
||||
Repository: "repo",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue