Documentation
¶
Overview ¶
Bearer-token authentication middleware for Gin-based services. Routes that require a signed-in user wrap their group with AuthMiddleware; the actual token verification stays in the service layer (passed in as a function) so this shared package never has to know about JWT internals or which signing scheme a given service uses.
Package ginx contains Gin-specific helpers every HTTP service in the project shares: request binding with validator-driven 400s, typed error responses, request-logging middleware, and a route- mount collector. Shipped into <project>/common/go/ginx/ on `maestro init`.
This file holds the request-binding helper.
Failure-response shape + writer for Gin-based services. Error defines the wire schema for 4xx/5xx bodies; WriteError maps a service-layer error onto status + Error response via the APIError interface that service sentinels are expected to implement.
HTTP middleware shipped with ginx. Every service ends up needing a per-request log line; RequestLogger is the canonical shape so the team doesn't reinvent it once per service.
Route-mount collector for Gin-based services. Each handler file in a service's routes package appends its block via init(); the server calls Register once to wire everything up. Keeps the server wiring uniform regardless of which conditional handler files happen to be present (relevant for preset-driven services where filename-suffix gating determines what's on disk).
Index ¶
- Constants
- func AuthMiddleware(...) gin.HandlerFunc
- func Bind(c *gin.Context, out any) bool
- func BodyLimit(maxBytes int64) gin.HandlerFunc
- func CORS(cfg CORSConfig) gin.HandlerFunc
- func RequestLogger() gin.HandlerFunc
- func RequireRole(tier identity.Role) gin.HandlerFunc
- func Role(c *gin.Context) (identity.Role, bool)
- func SecurityHeaders() gin.HandlerFunc
- func SessionID(c *gin.Context) (uuid.UUID, bool)
- func UserID(c *gin.Context) (uuid.UUID, bool)
- func VerifyGateway(verify func(token string) (subject string, role identity.Role, ok bool)) gin.HandlerFunc
- func WriteError(c *gin.Context, err error)
- type APIError
- type CORSConfig
- type Error
- type Routes
Constants ¶
const DefaultMaxBodyBytes int64 = 1 << 20
DefaultMaxBodyBytes caps a request body at 1 MiB — generous for the JSON payloads these services accept, small enough that an unbounded or hostile body can't exhaust server memory.
Variables ¶
This section is empty.
Functions ¶
func AuthMiddleware ¶
func AuthMiddleware(verify func(token string) (userID, sessionID uuid.UUID, role identity.Role, err error)) gin.HandlerFunc
AuthMiddleware returns a gin middleware that authenticates the Authorization: Bearer <token> header. verify is the service-layer check, e.g. Service.VerifyAccessToken, that validates the token and returns the user id, the session id it belongs to, and the caller's staff role. Any error, such as a missing header, wrong scheme, or invalid/expired token, aborts the request with 401 and the standard Error body. On success all three are stashed for handlers to read via UserID / SessionID / Role, so RequireRole gates routes behind this middleware. A scheme with no session concept can return uuid.Nil for the session id.
func Bind ¶
Bind parses the request body into out and writes a 400 if it fails — either a malformed body or a binding-tag violation. Returns true when the handler can proceed, false when it should return immediately (Gin has already written the response).
The 400 body uses the Error shape defined in this package so the failure contract is consistent across endpoints.
Typical handler shape:
func login(c *gin.Context) {
var req LoginRequest
if !ginx.Bind(c, &req) {
return
}
// ...
}
func BodyLimit ¶
func BodyLimit(maxBytes int64) gin.HandlerFunc
BodyLimit wraps every request body in http.MaxBytesReader so a client can't stream an arbitrarily large (or never-ending) body to exhaust memory. When the limit is exceeded the body read fails, which Bind surfaces as a 400. Install once at engine setup, before the route handlers.
func CORS ¶
func CORS(cfg CORSConfig) gin.HandlerFunc
CORS returns a gin middleware enforcing cfg. Mount it first in a chain so a preflight is answered before auth/rate-limit, and so CORS headers ride along on error responses (401/429/5xx) too — without them a browser hides the real status behind an opaque CORS failure.
Behavior: a request with no Origin passes through untouched (not a CORS request). A disallowed origin gets no CORS headers — a preflight is answered 204 (the browser rejects it for lack of Allow-Origin), an actual request proceeds but the browser blocks the JS read. An allowed origin gets Access-Control-Allow-Origin ("*" only when credentials are off; otherwise the concrete origin is echoed with Vary: Origin, since "*" with credentials is forbidden by the spec).
func RequestLogger ¶
func RequestLogger() gin.HandlerFunc
RequestLogger emits one zap line per request with the standard fields (method, path, status, ms). Typed zap fields keep the hot path allocation-friendly. Install once at gin.Engine setup time alongside Recovery:
r := gin.New() r.Use(gin.Recovery(), ginx.RequestLogger())
Logging uses zap.L() so the caller is expected to have already installed a global logger via logger.New + zap.ReplaceGlobals.
func RequireRole ¶
func RequireRole(tier identity.Role) gin.HandlerFunc
RequireRole gates a route group on a minimum staff tier. Tiers are cumulative, so the check is role >= tier. Below the tier, or with no role on the context, the response is a 404 not a 403, so probing for admin routes looks identical to hitting routes that don't exist. It runs after whatever middleware authenticated the caller and stashed the role: VerifyGateway for services behind the gateway, AuthMiddleware once it threads the role claim.
func Role ¶
Role returns the authenticated caller's staff tier. ok is false when no auth middleware ran.
func SecurityHeaders ¶
func SecurityHeaders() gin.HandlerFunc
SecurityHeaders sets a baseline of defensive response headers on every response. These are cheap, broadly-safe defaults for a JSON API; HSTS and a Content-Security-Policy are left to the TLS-terminating edge, since they depend on the deployment's HTTPS and asset topology. Install at engine setup.
func SessionID ¶
SessionID returns the session id (the access token's session family) AuthMiddleware stashed on the context. It may be uuid.Nil if the auth scheme carries no session concept. ok is false only when no AuthMiddleware ran on the route.
func UserID ¶
UserID returns the authenticated user id AuthMiddleware stashed on the context. ok is false only when no AuthMiddleware ran on the route — a wiring bug — so handlers mounted behind AuthMiddleware can treat a false return as "impossible" and 500.
func VerifyGateway ¶
func VerifyGateway(verify func(token string) (subject string, role identity.Role, ok bool)) gin.HandlerFunc
VerifyGateway returns middleware that authenticates the gateway's signed assertion before a service trusts the caller. verify validates the assertion and returns the asserted subject (the user id, or "" for a public request), the caller's staff role, or ok=false to reject. On success the middleware:
- replaces any inbound X-User-Id with the verified subject, so X-User-Id is only ever the value cryptographically asserted by the gateway;
- replaces any inbound X-User-Role with the verified role (read via identity.RoleOf) and stashes the same role on the gin context, so RequireRole works for a service behind the gateway exactly as it does behind AuthMiddleware; and
- stashes the raw assertion on the request context (identity.WithGatewayToken) so the handler's own service-to-service calls can forward it.
/health and /ready bypass since orchestrator probes hit them directly, not through the gateway.
A nil verify DISABLES the pass-through check with a loud warning, so the control can roll out before keys are wired everywhere. In that mode the gateway falls back to injecting a raw X-User-Id and services trust it as before and no role reaches the context, so RequireRole fails closed (admin routes 404 for everyone) until keys are wired. Configure keys on the gateway AND every service together to enforce.
func WriteError ¶
WriteError translates err into the right HTTP status + Error body. Errors that satisfy APIError carry their own status + code; everything else falls through to 500 with a generic code so implementation detail (e.g. a wrapped SQL error) doesn't leak through the API.
Types ¶
type APIError ¶
APIError is the contract a service sentinel must satisfy for WriteError to translate it into a typed HTTP response. Each sentinel knows its own HTTP status and stable error code — the HTTP layer doesn't hardcode a sentinel list.
Example sentinel definition in a service package:
type sentinelError struct {
status int
code string
msg string
}
func (e *sentinelError) Error() string { return e.msg }
func (e *sentinelError) StatusCode() int { return e.status }
func (e *sentinelError) Code() string { return e.code }
type CORSConfig ¶
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
AllowCredentials bool
MaxAge int // preflight cache, seconds
}
CORSConfig configures the CORS middleware. AllowedOrigins is the only field that gates anything: an empty list means the middleware adds no CORS headers (callers that want to skip CORS entirely should just not mount it). "*" allows any origin. Methods/Headers default to a reasonable set when left empty.
func (CORSConfig) Validate ¶
func (c CORSConfig) Validate() error
Validate rejects an unsafe CORS configuration. A wildcard origin combined with credentials is forbidden by the CORS spec for literal "*"; reflecting the request origin instead (as the middleware would) silently bypasses that protection and lets ANY site make credentialed cross-origin requests. Callers should run this at startup and fail loud so an operator can't enable it by accident — list explicit origins when credentials are on. Any origin containing "*" is rejected (not just the literal "*"): a real CORS origin is a concrete scheme://host[:port], so a "*" anywhere is either the wildcard or a wildcard-pattern the middleware can't honour — both are misconfigurations.
type Error ¶
type Error struct {
// Code is a stable, machine-readable identifier. Values come
// from the service layer's sentinel errors (via APIError below)
// or from validator-driven 400s ("validation_failed").
Code string `json:"error"`
// Details is human-readable and optional. Useful for surfacing
// the validator's "field 'email' is not a valid address" in
// development; safe to suppress in production via an env toggle.
Details string `json:"details,omitempty"`
}
Error is the JSON shape returned for 4xx/5xx failures. The struct field is Code (not Error) to avoid a self-shadowed name inside the type definition; the JSON tag keeps the wire shape at {"error": "..."} so consumers see one consistent error envelope.
type Routes ¶
type Routes struct {
// contains filtered or unexported fields
}
Routes collects route-mounting closures and applies them to a gin.Engine in registration order. Use one instance per Go package that owns a related set of routes — typically at the package level:
// service/internal/routes/routes.go
package routes
var Mounts ginx.Routes
// service/internal/routes/auth.go
func init() {
Mounts.Add(func(r *gin.Engine) {
r.POST("/auth/login", login)
})
}
// service/internal/server/server.go
routes.Mounts.Register(engine)