feat: backend service
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Package basicauth gates a route group behind HTTP Basic authentication.
|
||||
//
|
||||
// The middleware delegates the credential check to a Verifier.
|
||||
// Production wires `*admin.Service` (Postgres-backed, bcrypt cost 12).
|
||||
// The bundled StaticVerifier is a test utility — it accepts any
|
||||
// non-empty username together with a fixed password so the contract
|
||||
// test can exercise the admin route group without booting a database.
|
||||
// Production wiring never references StaticVerifier.
|
||||
package basicauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultRealm is the realm advertised on `WWW-Authenticate` for the admin
|
||||
// surface.
|
||||
const DefaultRealm = "galaxy-admin"
|
||||
|
||||
// usernameContextKey is the unexported context key used to expose the
|
||||
// authenticated admin username to downstream handlers (e.g. for
|
||||
// soft-delete audit trails). The unexported value type prevents
|
||||
// accidental collisions with keys defined in unrelated packages.
|
||||
type usernameContextKey struct{}
|
||||
|
||||
// Verifier validates a username/password pair. Implementations must run in
|
||||
// constant time relative to the credential bytes.
|
||||
type Verifier interface {
|
||||
// Verify reports whether the supplied credentials are accepted. A non-nil
|
||||
// error indicates an unexpected verifier failure, distinct from a clean
|
||||
// rejection (false, nil).
|
||||
Verify(ctx context.Context, username, password string) (bool, error)
|
||||
}
|
||||
|
||||
// UsernameFromContext returns the authenticated admin username stored on
|
||||
// ctx by Middleware. The boolean reports whether a value was found.
|
||||
func UsernameFromContext(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := ctx.Value(usernameContextKey{}).(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithUsername stores username on ctx under the package-private context
|
||||
// key. Exposed for tests that need to build a context outside the
|
||||
// middleware.
|
||||
func WithUsername(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, usernameContextKey{}, username)
|
||||
}
|
||||
|
||||
// Middleware returns a gin middleware that enforces Basic authentication via
|
||||
// verifier. realm is advertised on `WWW-Authenticate`. A nil verifier behaves
|
||||
// as a deny-all verifier, suitable for the operating mode where the admin
|
||||
// surface must remain mounted but inaccessible.
|
||||
func Middleware(verifier Verifier, realm string) gin.HandlerFunc {
|
||||
if realm == "" {
|
||||
realm = DefaultRealm
|
||||
}
|
||||
challenge := `Basic realm="` + realm + `"`
|
||||
|
||||
return func(c *gin.Context) {
|
||||
username, password, ok := c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "basic authentication is required")
|
||||
return
|
||||
}
|
||||
|
||||
if verifier == nil {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
|
||||
return
|
||||
}
|
||||
|
||||
accepted, err := verifier.Verify(c.Request.Context(), username, password)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "credential verification failed")
|
||||
return
|
||||
}
|
||||
if !accepted {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
|
||||
return
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(WithUsername(c.Request.Context(), username))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StaticVerifier accepts any non-empty username together with a
|
||||
// fixed shared password. It is a test-only utility: the OpenAPI
|
||||
// contract test wires it to exercise the admin route group without
|
||||
// booting a database. Production wiring uses the Postgres-backed
|
||||
// `*backend/internal/admin.Service`.
|
||||
type StaticVerifier struct {
|
||||
// Password is the shared secret. An empty value disables the verifier
|
||||
// (every request is rejected).
|
||||
Password string
|
||||
}
|
||||
|
||||
// NewStaticVerifier returns a StaticVerifier with the supplied password.
|
||||
func NewStaticVerifier(password string) StaticVerifier {
|
||||
return StaticVerifier{Password: password}
|
||||
}
|
||||
|
||||
// Verify accepts any non-empty username together with the configured password.
|
||||
// The password comparison runs in constant time. An empty configured password
|
||||
// rejects every request.
|
||||
func (v StaticVerifier) Verify(_ context.Context, username, password string) (bool, error) {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return false, nil
|
||||
}
|
||||
if v.Password == "" {
|
||||
return false, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(v.Password)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package geocounter exposes the gin middleware that records
|
||||
// `(user_id, country)` counters for every authenticated user-surface
|
||||
// request. The middleware sits one layer below `userid.Middleware` in
|
||||
// the route chain: it relies on the parsed user id already being on
|
||||
// the request context.
|
||||
//
|
||||
// The middleware never blocks: the underlying counter implementation
|
||||
// looks up the country synchronously (mmap read) and dispatches the
|
||||
// database upsert to a fire-and-forget goroutine. Errors from the
|
||||
// asynchronous path are logged inside the geo service, never surfaced
|
||||
// to the response.
|
||||
package geocounter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is the narrow contract the middleware needs from the geo
|
||||
// package. It is satisfied by `*geo.Service` directly; tests inject a
|
||||
// recording stub. A nil Service is allowed and disables the
|
||||
// middleware's side effect.
|
||||
type Service interface {
|
||||
IncrementCounterAsync(ctx context.Context, userID uuid.UUID, sourceIP string)
|
||||
}
|
||||
|
||||
// Middleware returns a gin handler that, after the wrapped handler
|
||||
// chain has run, dispatches an `IncrementCounterAsync` call for the
|
||||
// authenticated user and the originating IP. svc may be nil, in which
|
||||
// case the middleware is a no-op pass-through.
|
||||
//
|
||||
// The middleware reads the user id from the request context populated
|
||||
// by `userid.Middleware`; routes that mount this middleware without
|
||||
// `userid.Middleware` ahead of it will silently skip the increment
|
||||
// because the user id is absent.
|
||||
func Middleware(svc Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok || userID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
ip := clientip.ExtractSourceIP(c)
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
svc.IncrementCounterAsync(c.Request.Context(), userID, ip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package geocounter_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/server/middleware/geocounter"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type recordingSvc struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
}
|
||||
|
||||
type recordedCall struct {
|
||||
UserID uuid.UUID
|
||||
SourceIP string
|
||||
}
|
||||
|
||||
func (r *recordingSvc) IncrementCounterAsync(_ context.Context, userID uuid.UUID, sourceIP string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, recordedCall{UserID: userID, SourceIP: sourceIP})
|
||||
}
|
||||
|
||||
func (r *recordingSvc) snapshot() []recordedCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]recordedCall, len(r.calls))
|
||||
copy(out, r.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func newEngine(t *testing.T, svc geocounter.Service) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(userid.Middleware())
|
||||
r.Use(geocounter.Middleware(svc))
|
||||
r.GET("/probe", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestMiddlewareInvokesIncrementOnAuthenticatedRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.5")
|
||||
req.RemoteAddr = "10.0.0.1:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||
}
|
||||
calls := svc.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls: want 1, got %+v", calls)
|
||||
}
|
||||
if calls[0].UserID != userID {
|
||||
t.Errorf("user id: want %s, got %s", userID, calls[0].UserID)
|
||||
}
|
||||
if calls[0].SourceIP != "203.0.113.5" {
|
||||
t.Errorf("source ip: want 203.0.113.5, got %q", calls[0].SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareFallsBackToRemoteAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = "198.51.100.7:60000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
calls := svc.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls: want 1, got %+v", calls)
|
||||
}
|
||||
if calls[0].SourceIP != "198.51.100.7" {
|
||||
t.Errorf("source ip: want 198.51.100.7, got %q", calls[0].SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWhenNoSourceIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = ""
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if calls := svc.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("calls: want 0, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWithoutUserContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// No userid.Middleware on this chain.
|
||||
r.Use(geocounter.Middleware(svc))
|
||||
r.GET("/probe", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.RemoteAddr = "203.0.113.5:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if calls := svc.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("calls: want 0, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareNilServiceIsPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newEngine(t, nil)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = "203.0.113.5:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status with nil service: want 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package logging emits a single info-level access log entry per HTTP request,
|
||||
// enriched with the active OpenTelemetry trace fields and the resolved request
|
||||
// id when present.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/server/middleware/requestid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Middleware returns the access-log gin middleware. The provided logger should
|
||||
// already carry the per-process service-name field; the middleware adds the
|
||||
// request method, matched route, status, latency, request id, and trace
|
||||
// fields.
|
||||
func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
fields = append(fields,
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.FullPath()),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("duration", duration),
|
||||
)
|
||||
if requestID, ok := requestid.FromGin(c); ok {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
|
||||
|
||||
logger.Info("http request", fields...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package metrics emits per-request OpenTelemetry counters and histograms
|
||||
// scoped by route group.
|
||||
//
|
||||
// The metric names are fixed by `backend/README.md` §15:
|
||||
//
|
||||
// - http_requests_total{group, method, route, status}
|
||||
// - http_request_duration_seconds{group, method, route, status}
|
||||
//
|
||||
// One Middleware instance per route group keeps the `group` attribute stable
|
||||
// across requests while allowing the gin router to share the same Meter.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
// Group identifies the route family that emits the metric. The set is closed
|
||||
// and matches the prefixes registered by router.New.
|
||||
type Group string
|
||||
|
||||
const (
|
||||
// GroupRoot covers `/healthz`, `/readyz`, and unmatched routes.
|
||||
GroupRoot Group = "root"
|
||||
// GroupProbes covers the readiness/liveness probes when reported separately
|
||||
// from other root-level traffic.
|
||||
GroupProbes Group = "probes"
|
||||
// GroupPublic covers `/api/v1/public/*` endpoints.
|
||||
GroupPublic Group = "public"
|
||||
// GroupUser covers `/api/v1/user/*` endpoints.
|
||||
GroupUser Group = "user"
|
||||
// GroupAdmin covers `/api/v1/admin/*` endpoints.
|
||||
GroupAdmin Group = "admin"
|
||||
// GroupInternal covers `/api/v1/internal/*` endpoints.
|
||||
GroupInternal Group = "internal"
|
||||
)
|
||||
|
||||
// Instruments holds the shared metric instruments used by every Group-scoped
|
||||
// middleware. The instruments are constructed once per Meter; the
|
||||
// per-middleware closure binds them to the right `group` attribute.
|
||||
type Instruments struct {
|
||||
requestsTotal metric.Int64Counter
|
||||
requestDuration metric.Float64Histogram
|
||||
}
|
||||
|
||||
// NewInstruments builds the shared metric instruments from meter. A nil meter
|
||||
// returns nil instruments and disables metric emission.
|
||||
func NewInstruments(meter metric.Meter) (*Instruments, error) {
|
||||
if meter == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
requestsTotal, err := meter.Int64Counter(
|
||||
"http_requests_total",
|
||||
metric.WithDescription("Number of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestDuration, err := meter.Float64Histogram(
|
||||
"http_request_duration_seconds",
|
||||
metric.WithDescription("Duration of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
|
||||
metric.WithUnit("s"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Instruments{
|
||||
requestsTotal: requestsTotal,
|
||||
requestDuration: requestDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Middleware returns a gin middleware that records request counters and
|
||||
// duration histograms with the `group` attribute fixed to group. A nil
|
||||
// instruments value yields a no-op middleware so that metric emission is
|
||||
// strictly opt-in.
|
||||
func Middleware(instruments *Instruments, group Group) gin.HandlerFunc {
|
||||
if instruments == nil {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
attrs := metric.WithAttributes(
|
||||
attribute.String("group", string(group)),
|
||||
attribute.String("method", c.Request.Method),
|
||||
attribute.String("route", route),
|
||||
attribute.String("status", strconv.Itoa(c.Writer.Status())),
|
||||
)
|
||||
|
||||
instruments.requestsTotal.Add(c.Request.Context(), 1, attrs)
|
||||
instruments.requestDuration.Record(c.Request.Context(), duration.Seconds(), attrs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package panicrecovery converts unrecovered panics into a structured 500
|
||||
// response and a single error-level log entry. It is wired exactly once at the
|
||||
// top of the gin middleware chain.
|
||||
package panicrecovery
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/requestid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Middleware returns a gin middleware that recovers from panics, logs the
|
||||
// failure with trace fields, and writes the standard 500 envelope.
|
||||
func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||
fields := []zap.Field{
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.FullPath()),
|
||||
zap.Any("panic", recovered),
|
||||
}
|
||||
if requestID, ok := requestid.FromGin(c); ok {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
|
||||
|
||||
logger.Error("http handler panicked", fields...)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package requestid carries a per-request identifier across the gin handler
|
||||
// chain.
|
||||
//
|
||||
// The middleware reads the inbound `X-Request-ID` header, generates a UUIDv4
|
||||
// when absent, stores the value on the gin context, and reflects it on the
|
||||
// response. Downstream code retrieves the identifier through FromContext.
|
||||
package requestid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Header is the canonical case-correct header name carrying the request id.
|
||||
const Header = "X-Request-ID"
|
||||
|
||||
// ginContextKey is the gin.Context key under which the resolved request id is
|
||||
// stored. The value is a string. The key is exported in lowercase form so that
|
||||
// it never collides with handler-level keys; consumers should prefer
|
||||
// FromContext rather than reading the gin context directly.
|
||||
const ginContextKey = "backend.request_id"
|
||||
|
||||
// requestIDContextKey is the unexported context.Context key used when the
|
||||
// resolved request id is propagated outside gin (background goroutines,
|
||||
// downstream client calls). The unexported value type prevents accidental
|
||||
// collisions across packages.
|
||||
type requestIDContextKey struct{}
|
||||
|
||||
// Middleware returns the gin middleware that resolves and propagates the
|
||||
// request id.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := strings.TrimSpace(c.GetHeader(Header))
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
|
||||
c.Set(ginContextKey, requestID)
|
||||
c.Writer.Header().Set(Header, requestID)
|
||||
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), requestID))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromContext returns the request id stored on ctx by Middleware. The boolean
|
||||
// reports whether an id was found. Consumers must always check the boolean
|
||||
// before using the returned string.
|
||||
func FromContext(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := ctx.Value(requestIDContextKey{}).(string)
|
||||
if !ok || value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// FromGin returns the request id stored on the gin context by Middleware. The
|
||||
// boolean reports whether an id was found.
|
||||
func FromGin(c *gin.Context) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := c.Get(ginContextKey)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok || value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithValue stores requestID on ctx under the package-private context key.
|
||||
// Exposed primarily for tests that build a context outside the middleware.
|
||||
func WithValue(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, requestIDContextKey{}, requestID)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package userid extracts the calling user identifier from the trusted
|
||||
// `X-User-ID` header injected by gateway and exposes it through the request
|
||||
// context.
|
||||
//
|
||||
// Backend trusts the header value because the network segment between gateway
|
||||
// and backend is the trust boundary (see `ARCHITECTURE.md` §15). The
|
||||
// middleware therefore only validates the syntactic shape (UUID) and rejects
|
||||
// malformed or absent values with the standard `400 invalid_request` envelope.
|
||||
package userid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Header is the canonical case-correct header name carrying the trusted user
|
||||
// id forwarded by gateway.
|
||||
const Header = "X-User-ID"
|
||||
|
||||
// userIDContextKey is the unexported context key used to store the parsed
|
||||
// user id. The unexported value type prevents accidental collisions with
|
||||
// keys defined in unrelated packages.
|
||||
type userIDContextKey struct{}
|
||||
|
||||
// Middleware returns the gin middleware that requires a syntactically valid
|
||||
// `X-User-ID` header on every authenticated user request and stores the
|
||||
// parsed UUID on the request context.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.GetHeader(Header))
|
||||
if raw == "" {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header must be a valid UUID")
|
||||
return
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), userID))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromContext returns the user id stored on ctx by Middleware. The boolean
|
||||
// reports whether a value was found.
|
||||
func FromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
if ctx == nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
value, ok := ctx.Value(userIDContextKey{}).(uuid.UUID)
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithValue stores userID on ctx under the package-private context key.
|
||||
// Exposed for tests that need to build a context outside the middleware.
|
||||
func WithValue(ctx context.Context, userID uuid.UUID) context.Context {
|
||||
return context.WithValue(ctx, userIDContextKey{}, userID)
|
||||
}
|
||||
Reference in New Issue
Block a user