Public events ingest with org API keys
Go · Go · intermediate · modification
Customers have been asking to send events without going through our collector, so this adds a public `POST /v1/events` that authenticates with the API keys orgs can already create on the dashboard. The middleware checks the `Bearer` scheme, strips it off and looks the key up by its SHA-256, the same hash the dashboard stores, so we never keep or compare raw keys. Unknown and revoked keys get a 401 with `WWW-Authenticate`, and the org from the key goes into the context, so `Ingest` itself is unchanged. Tried it locally with a key from the dashboard (202) and a revoked one (401).
Go 1.22 service, standard library only (`net/http` method-pattern `ServeMux`, `log/slog`). `Handler` (existing, wired in `main.go`) already holds `Keys KeyStore` and `Log *slog.Logger`; the dashboard's API-key page creates and revokes keys through the same store. `Ingest`, `WithOrg`, `writeJSON(w, status, v)` (sets the JSON content type, writes the status and encodes `v`) and `trustedOrgHeader` are existing code in the same package. The public load balancer does not route `/internal/`, and it applies body-size and rate limits to `/v1/` before requests reach this service.
Requirements
- Expose `POST /v1/events` publicly so customers can send events from their own servers. The existing `POST /internal/events` route for our collector stays as it is.
- A request authenticates with `Authorization: Bearer <key>`, where `<key>` is one of the org's API keys exactly as the dashboard showed it: 32 characters, each drawn uniformly at random from `A-Z a-z 0-9`. Our SDKs and docs always send the scheme as `Bearer` followed by one space, so matching that prefix case-sensitively is fine.
- The keys table stores the lowercase hex SHA-256 of each active key; `Keys.OrgByKeyHash` returns the key's org, or `ok == false` for an unknown or revoked key. A missing header, any other scheme, or a key that is not found answers 401 `{"error": "unauthorized"}` with `WWW-Authenticate: Bearer`. A lookup error is logged and answers 500 `{"error": "internal"}`.
- On success the key's org goes into the request context with `WithOrg`, which is where `Ingest` reads it.
Files touched
- internal/ingest/routes.go
- internal/ingest/apikey.go
--- internal/ingest/routes.go
// Internal collector only: /internal/ is not routed by the public load balancer.
mux.Handle("POST /internal/events", trustedOrgHeader(http.HandlerFunc(h.Ingest)))
+ // Public ingest for customers' own senders, authenticated by org API key.
+ mux.Handle("POST /v1/events", h.RequireAPIKey(http.HandlerFunc(h.Ingest)))
}
--- internal/ingest/apikey.go
+package ingest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "strings"
+)
+
+// RequireAPIKey authenticates a request by the org API key in its
+// Authorization header and puts the key's org into the request context.
+func (h *Handler) RequireAPIKey(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ header := r.Header.Get("Authorization")
+ if !strings.HasPrefix(header, "Bearer ") {
+ unauthorized(w)
+ return
+ }
+ key := strings.TrimLeft(header, "Bearer ")
+
+ sum := sha256.Sum256([]byte(key))
+ orgID, ok, err := h.Keys.OrgByKeyHash(r.Context(), hex.EncodeToString(sum[:]))