middleware

package
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package middleware provides the commodity HTTP middlewares and Connect interceptors that every forge-generated service ships with.

Why a library

These used to be scaffolded into each project's pkg/middleware as static files. Field evidence across downstream repos showed the copies stayed byte-identical to the templates — zero project intent, pure photocopies of security-relevant code that drifted and never received fixes. The code now lives here, versioned with forge, and the project keeps ONE thin pkg/middleware file wiring the things projects actually customize: the auth validator, the identity enricher, the unauthenticated allow-list, and dev-claims behaviour (see forge/pkg/authn for that policy surface).

What's here

HTTP middlewares (outermost layer, wrap the whole mux):

  • CORSMiddleware — spec-correct CORS with wildcard/credentials guard rails.
  • SecurityHeadersMiddleware — OWASP security response headers (CSP, nosniff, Referrer-Policy, Permissions-Policy, HSTS).
  • RequestIDMiddleware — per-request correlation ID, trusted from the inbound X-Request-Id or minted fresh; shares pkg/observe's context key so Connect log records inherit the ID.
  • HTTPStack — recovery + logging + audit for plain HTTP routes (webhooks, OAuth callbacks, REST) that bypass the Connect chain.
  • HTTPAuth — Bearer-token auth for plain HTTP routes.
  • IdempotencyMiddleware — HTTP-level Idempotency-Key replay.

Connect interceptors (project chain, after serverkit's canonical observe.DefaultMiddlewares):

Plus Redact, a struct→map helper for logging payloads without leaking PII.

Claims access

Several middlewares read the authenticated principal. The claims CONTEXT KEY is owned by the project (its pkg/middleware), so anything claims-aware takes the project's ClaimsFromContext as a callback — the same pattern pkg/tenant and pkg/authz use. Passing nil degrades gracefully (anonymous audit entries, IP-keyed rate limits).

The authentication and authorization mechanisms themselves live in forge/pkg/authn and forge/pkg/authz respectively.

Index

Constants

View Source
const IdempotencyKeyHeader = "Idempotency-Key"

IdempotencyKeyHeader is the canonical header carrying the client-supplied idempotency key. Methods annotated with idempotency_key=true in the proto options SHOULD require this header; if absent the request proceeds without deduplication.

View Source
const RequestIDHeader = observe.RequestIDHeader

RequestIDHeader is the canonical header carrying the per-request correlation ID on both the inbound request and the outbound response. It mirrors observe.RequestIDHeader so the HTTP layer (this middleware) and the Connect layer (observe's interceptors) stay in agreement.

Variables

This section is empty.

Functions

func AuditInterceptor

func AuditInterceptor(logger *slog.Logger, claimsFrom ClaimsLookup) connect.Interceptor

AuditInterceptor creates a Connect interceptor that produces audit log entries for every RPC call. Audit logs capture: who made the call (user ID, email), what procedure was called, when, the result (success/error code), the duration, and the OTel trace id when the request carries a span.

Audit records are emitted with log_type=audit on a child logger, so they can be routed to a dedicated audit sink (separate file, SIEM, compliance database) independent of operational logs. The record message is "audit.event" ([auditMessage]) — INFO on success, WARN on error.

claimsFrom is the project's ClaimsFromContext (the project owns the claims context key — see ClaimsLookup). When nil, or when no claims are present (unauthenticated request), the user is logged as "anonymous".

This is the slog-only constructor; it is fully backward compatible. For compliance-grade durable persistence (so projects can delete a hand-rolled DB-persisting interceptor), use AuditInterceptorWithSink to additionally fan each event out to an AuditSink.

func AuditInterceptorWithSink

func AuditInterceptorWithSink(logger *slog.Logger, claimsFrom ClaimsLookup, sink AuditSink) connect.Interceptor

AuditInterceptorWithSink is AuditInterceptor plus a durable sink. Every RPC is logged to slog exactly as the slog-only variant does AND dispatched to sink.Record on a fresh bounded-timeout context off the request path, so the sink never blocks the RPC response. Passing a nil sink yields behavior identical to AuditInterceptor.

func CORSMiddleware

func CORSMiddleware(allowOrigins []string, allowCredentials bool) func(http.Handler) http.Handler

CORSMiddleware returns an HTTP middleware that applies the CORS policy described by allowOrigins and allowCredentials.

Semantics (per the Fetch / CORS specs):

  • When the request has no Origin header, no CORS headers are written and the request is passed through. Emitting ACAO with an empty value (or "*") on a same-origin request is a well-known footgun: the browser never needed CORS on it in the first place, and some caches key on the ACAO value.

  • When allowOrigins contains "*" AND allowCredentials is true, this middleware panics. The combination is spec-invalid (browsers reject it) and must be caught at config validation time (the scaffolded config.Validate does). The panic is a belt-and- suspenders guard if validation is bypassed.

  • When allowOrigins contains "*" and credentials are disabled, the request Origin is echoed back rather than a literal "*". Echoing keeps the response compatible with future credentialed callers (no silent breakage) and still satisfies "*" semantically.

  • When specific origins are listed, an exact (case-insensitive) match on the Origin header is required. No match → no CORS headers (the browser will block the response).

Vary: Origin is always set so intermediate caches key on the Origin.

func ContextWithRequestID

func ContextWithRequestID(ctx context.Context, id string) context.Context

ContextWithRequestID returns a copy of ctx with the given request ID attached. Exposed so tests and non-HTTP entrypoints can propagate the value into code paths that read RequestIDFromContext.

func HTTPAuth

func HTTPAuth(
	authenticate func(token string) (*auth.Claims, error),
	withClaims func(ctx context.Context, claims *auth.Claims) context.Context,
) func(http.Handler) http.Handler

HTTPAuth returns HTTP middleware that validates Bearer tokens and populates the context with claims, mirroring the Connect auth interceptor for plain HTTP routes.

authenticate validates the raw token and returns the claims — pass the project's ValidateToken. withClaims stashes them on the context — pass the project's ContextWithClaims (the project owns the claims context key). If authenticate is nil (dev mode), all requests are allowed through untouched.

func HTTPStack

func HTTPStack(logger *slog.Logger, claimsFrom ClaimsLookup) func(http.Handler) http.Handler

HTTPStack returns HTTP middleware that applies recovery, logging, and audit to plain HTTP handlers. This provides the same cross-cutting concerns as the Connect interceptors for routes that cannot use the Connect protocol (e.g. webhooks, OAuth callbacks, REST endpoints).

claimsFrom is the project's ClaimsFromContext (see ClaimsLookup); nil produces anonymous audit entries.

Auth is NOT included because REST routes often have different auth requirements (e.g. webhook signature verification instead of JWT). Use HTTPAuth separately for routes that need Bearer authentication.

func IdempotencyInterceptor

func IdempotencyInterceptor(opts IdempotencyOptions) connect.Interceptor

IdempotencyInterceptor returns a Connect interceptor that deduplicates requests carrying an Idempotency-Key header. If the same key is seen within the TTL window, the cached response (or error) is returned without calling the handler again.

Keys are scoped per-procedure so the same key on different RPCs is treated as distinct. For the HTTP-layer equivalent (REST/webhook routes) see IdempotencyMiddleware.

When opts.CacheSize <= 0 the interceptor is disabled and nil is returned.

func IdempotencyMiddleware

func IdempotencyMiddleware(cfg IdempotencyConfig) func(http.Handler) http.Handler

IdempotencyMiddleware returns an http.Handler that deduplicates requests carrying an Idempotency-Key header. If a request with the same key has already been served (and is still in the cache), the cached response is replayed without invoking the downstream handler.

Usage:

mux := http.NewServeMux()
cfg := middleware.IdempotencyConfig{CacheSize: 500, TTL: 30 * time.Minute}
handler := middleware.IdempotencyMiddleware(cfg)(mux)

func RateLimitInterceptor

func RateLimitInterceptor(opts RateLimitOptions, claimsFrom ClaimsLookup) connect.Interceptor

RateLimitInterceptor returns a Connect interceptor that enforces a per-key token-bucket rate limit. Keys are derived in this order:

  1. authenticated claim subject (claims.UserID) via claimsFrom, if available
  2. peer IP from the Connect request/stream peer address

When opts.Rps <= 0 the interceptor is disabled and this function returns nil. Memory is bounded by an LRU cache of up to rateLimitCacheSize limiters; idle keys are evicted in LRU order so the interceptor is safe to expose to anonymous traffic. claimsFrom may be nil (all callers are then keyed by peer IP).

func Redact

func Redact(v any, fields ...string) map[string]any

Redact accepts a struct (or pointer to struct) and a set of field names to mask. It returns a map[string]any mirroring the struct's exported fields, with the named fields replaced by "[REDACTED]".

Non-struct inputs return nil. Unexported fields are silently skipped.

Example:

type User struct {
    ID    string
    Email string
    Name  string
}
out := middleware.Redact(User{ID: "1", Email: "a@b.c", Name: "Alice"}, "Email")
// out == map[string]any{"ID": "1", "Email": "[REDACTED]", "Name": "Alice"}

func RequestIDFromContext

func RequestIDFromContext(ctx context.Context) string

RequestIDFromContext returns the request ID attached to ctx, or "" if none is set. Useful for handlers and downstream logging sites that want to include the ID on custom log lines. Nil ctx returns "" (never panics) so callers in CLI/worker contexts can share the same helper.

The storage is pkg/observe's context key: a request ID set by this HTTP middleware is visible to observe.LoggingInterceptor (and vice versa) without a header round-trip.

func RequestIDMiddleware

func RequestIDMiddleware() func(http.Handler) http.Handler

RequestIDMiddleware is an HTTP middleware that ensures every request carries a correlation ID. It must be wired ahead of the logging middleware so log records inherit the generated ID.

Behavior:

  • If the inbound request has a non-empty RequestIDHeader, that value is trusted and reused. This lets edge proxies and upstream services stitch together a single trace across hops without coordination.

  • Otherwise a 16-byte crypto/rand hex token is minted — the same shape observe.RequestIDInterceptor mints at the Connect layer, so IDs look uniform whichever layer assigned them. crypto/rand keeps IDs unpredictable to third parties that might otherwise enumerate them.

  • The chosen ID is exposed to downstream middleware/handlers via the request context (RequestIDFromContext) and echoed on the response header so the client can log it for later correlation.

func SecurityHeadersMiddleware

func SecurityHeadersMiddleware(cfg SecurityHeadersConfig) func(http.Handler) http.Handler

SecurityHeadersMiddleware returns an HTTP middleware that sets OWASP-recommended security response headers:

  • Content-Security-Policy — locks down what the browser may load
  • X-Content-Type-Options: nosniff — prevents MIME sniffing
  • Referrer-Policy — limits Referer leakage
  • Permissions-Policy — opts out of powerful browser APIs
  • Strict-Transport-Security — forces HTTPS (production only)

Headers relevant to health/pprof endpoints (health checks don't need CSP stringency, but we still set nosniff/referrer/permissions there for consistency and defense in depth). Callers wire this in at the outermost HTTP layer so every response benefits — including /healthz, /readyz, /metrics, and REST/webhook routes that bypass the Connect interceptor chain.

When cfg.Enabled is false, this becomes a no-op.

Types

type AuditEvent

type AuditEvent struct {
	// Timestamp is the wall-clock time the RPC started.
	Timestamp time.Time
	// Procedure is the fully-qualified Connect procedure (e.g.
	// "/pkg.Service/Method").
	Procedure string
	// PeerAddress is the remote peer address.
	PeerAddress string
	// UserID is the authenticated user id, or "anonymous" when no
	// claims were present.
	UserID string
	// Email is the authenticated user email, empty when anonymous.
	Email string
	// Duration is the measured RPC duration.
	Duration time.Duration
	// Status is "ok" on success or "error" on failure.
	Status string
	// ErrorCode is the connect.Code string on failure, empty on success.
	ErrorCode string
	// ErrorMessage is the error string on failure, empty on success.
	ErrorMessage string
	// TraceID is the OTel trace id (hex), empty when the request carries
	// no span context.
	TraceID string
}

AuditEvent is the full, structured record of a single RPC, handed to an AuditSink for durable persistence. It carries every field forge emits to slog plus the OTel trace correlation id, so a sink can write a complete compliance record without re-deriving anything from the request context.

The field set is intentionally a superset that lets a thin adapter satisfy a richer store. Status is "ok" or "error"; ErrorCode is the connect.Code string (e.g. "permission_denied") and is empty on success; TraceID is the hex OTel trace id, empty when the request carries no span.

type AuditSink

type AuditSink interface {
	Record(ctx context.Context, e AuditEvent)
}

AuditSink is a durable destination for audit events (a database, an append-only file, a SIEM forwarder). The interceptor calls [Record] for every RPC AFTER it has emitted the slog record.

Record is invoked on a fresh, bounded-timeout context off the request path — the interceptor never blocks the RPC response on the sink. A sink implementation may therefore treat Record as synchronous: do its write, honor ctx cancellation, and surface its own errors via its own logger. Record has no error return precisely so the interceptor stays fire-and-forget; the sink owns failure handling.

A thin adapter satisfies AuditSink over any richer store. For example, control-plane's audit store has the signature:

// pkg/audit.Store
Log(ctx context.Context, entry Entry) error

which an adapter wraps directly:

type storeSink struct{ store pkgaudit.Store; log *slog.Logger }
func (s storeSink) Record(ctx context.Context, e middleware.AuditEvent) {
    entry := pkgaudit.Entry{
        Timestamp:    e.Timestamp,
        Procedure:    e.Procedure,
        PeerAddress:  e.PeerAddress,
        UserID:       e.UserID,
        Email:        e.Email,
        DurationMs:   int(e.Duration.Milliseconds()),
        Status:       e.Status,
        ErrorCode:    e.ErrorCode,
        ErrorMessage: e.ErrorMessage,
        Metadata:     map[string]string{"trace_id": e.TraceID},
    }
    if err := s.store.Log(ctx, entry); err != nil {
        s.log.Error("audit db write failed", "error", err, "procedure", e.Procedure)
    }
}

type ClaimsLookup

type ClaimsLookup func(ctx context.Context) (*auth.Claims, bool)

ClaimsLookup resolves the authenticated principal from a request context. The claims context key is owned by the project's pkg/middleware, so claims-aware interceptors in this library take the project's ClaimsFromContext as a callback — the same pattern pkg/tenant and pkg/authz use. nil is allowed and means "no claims available" (the interceptors degrade gracefully).

type IdempotencyConfig

type IdempotencyConfig struct {
	// CacheSize is the maximum number of idempotency keys to track.
	// Default: 1000.
	CacheSize int

	// TTL is how long a cached response is kept. After this duration the
	// key is evicted and a duplicate request is treated as new.
	// Default: 1 hour.
	TTL time.Duration
}

IdempotencyConfig controls the behavior of the idempotency middleware.

type IdempotencyOptions

type IdempotencyOptions struct {
	CacheSize int
	TTL       time.Duration
}

IdempotencyOptions configures the RPC idempotency interceptor.

  • CacheSize: maximum number of cached responses (default 1000)
  • TTL: how long a cached response is valid (default 1h)

CacheSize <= 0 disables the interceptor — IdempotencyInterceptor returns nil and callers should skip appending it to the chain.

type RateLimitOptions

type RateLimitOptions struct {
	Rps   int
	Burst int
}

RateLimitOptions tunes the per-key token-bucket rate limiter.

  • Rps: steady-state requests-per-second per key
  • Burst: maximum burst allowed before throttling (raised to Rps when lower)

Rps <= 0 disables rate limiting — RateLimitInterceptor returns nil in that case and callers should skip appending the interceptor to the chain.

type SecurityHeadersConfig

type SecurityHeadersConfig struct {
	// Enabled disables the middleware entirely when false. Allows operators
	// to opt out without unwinding the handler chain.
	Enabled bool

	// Production toggles headers that only make sense when served over
	// HTTPS in a real deployment (currently: Strict-Transport-Security).
	// Typically mirrors cfg.Environment != "development".
	Production bool

	// ContentSecurityPolicy overrides the default CSP. The default is
	// "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" which
	// is appropriate for an API server that does not serve HTML. If the
	// server also serves HTML (e.g. an admin UI), supply a CSP that
	// permits the required sources explicitly.
	ContentSecurityPolicy string

	// PermissionsPolicy overrides the default Permissions-Policy. The
	// default denies access to camera, microphone, geolocation,
	// interest-cohort (FLoC), payment, and USB.
	PermissionsPolicy string

	// ReferrerPolicy overrides the default Referrer-Policy
	// ("strict-origin-when-cross-origin").
	ReferrerPolicy string

	// HSTSMaxAge overrides the default Strict-Transport-Security max-age
	// (2 years, per MDN / OWASP guidance). Only emitted when Production is
	// true. Set to a negative value to disable HSTS even in production.
	HSTSMaxAge int
}

SecurityHeadersConfig controls which headers SecurityHeadersMiddleware sets and the exact values used. Zero-value fields fall back to sensible OWASP defaults suitable for a Connect/HTTP API server.

func DefaultSecurityHeadersConfig

func DefaultSecurityHeadersConfig() SecurityHeadersConfig

DefaultSecurityHeadersConfig returns a SecurityHeadersConfig with OWASP defaults suitable for an API server. Callers should toggle Enabled and Production based on their runtime config.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL