Skip to content

Commit 9c1961c

Browse files
authored
Merge pull request #118 from hyp3rd/feat/dist-mem-cache
feat(mgmt): add per-route scope enforcement on management HTTP port
2 parents 68d0938 + 4e27345 commit 9c1961c

7 files changed

Lines changed: 404 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010

11+
- **Per-route scope enforcement on the management HTTP port.**
12+
`WithMgmtControlAuth` is a new option that wraps the cluster-
13+
mutating control endpoints (`POST /evict`, `POST /clear`,
14+
`POST /trigger-expiration`) in a stricter auth gate than the
15+
observability surface. The hypercache-server binary now wires
16+
read-or-better on `/stats`/`/config`/`/cluster/*`/`/dist/*` and
17+
admin-only on the control routes (see `cmd/hypercache-server/
18+
main.go`). `/health` is intentionally NOT auth-wrapped — k8s
19+
liveness probes don't carry credentials, and a probe failure
20+
cascades into a pod-restart loop. Also new: `httpauth.Policy.Verify`,
21+
the "block-with-error" sibling of `Middleware()` that adapters
22+
(like `WithMgmtAuth`/`WithMgmtControlAuth`) use when they own
23+
their own next-handler dispatch. Existing `Middleware()` is now
24+
thin sugar over `Verify() + c.Next()` so the auth logic lives
25+
in exactly one place.
1126
- **`GET /v1/me` — resolved caller identity.** New scope-protected
1227
(`read`) route that reads the resolved `httpauth.Identity` from
1328
`c.Locals(httpauth.IdentityKey)` and returns

cmd/hypercache-server/main.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,33 @@ func buildHyperCache(ctx context.Context, cfg envConfig, logger *slog.Logger) (*
266266
)
267267
}
268268

269+
// Phase C2: light up scope enforcement on the management port.
270+
// /health stays public (k8s liveness probes carry no creds).
271+
// Read-or-better is required for the observability surface
272+
// (/stats, /config, /dist/*, /cluster/*); admin scope is
273+
// required for the cluster-mutating control routes (/evict,
274+
// /clear, /trigger-expiration). Closes a long-standing gap
275+
// where the mgmt port was fully unauthenticated server-side
276+
// while the monitor's proxy carried the only check.
277+
//
278+
// Closure captures cfg.AuthPolicy by value — Policy is value-
279+
// semantic and safe for concurrent use after construction;
280+
// see pkg/httpauth/policy.go.
281+
policy := cfg.AuthPolicy
282+
mgmtReadAuth := func(fiberCtx fiber.Ctx) error {
283+
return policy.Verify(fiberCtx, httpauth.ScopeRead)
284+
}
285+
mgmtAdminAuth := func(fiberCtx fiber.Ctx) error {
286+
return policy.Verify(fiberCtx, httpauth.ScopeAdmin)
287+
}
288+
269289
hcCfg.HyperCacheOptions = append(
270290
hcCfg.HyperCacheOptions,
271-
hypercache.WithManagementHTTP[backend.DistMemory](cfg.MgmtAddr),
291+
hypercache.WithManagementHTTP[backend.DistMemory](
292+
cfg.MgmtAddr,
293+
hypercache.WithMgmtAuth(mgmtReadAuth),
294+
hypercache.WithMgmtControlAuth(mgmtAdminAuth),
295+
),
272296
)
273297

274298
hc, err := hypercache.New(ctx, hypercache.GetDefaultManager(), hcCfg)

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ words:
7777
- distroless
7878
- EDITMSG
7979
- elif
80+
- Equalf
8081
- errcheck
8182
- errp
8283
- ewrap

management_http.go

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,22 @@ type ManagementHTTPOption func(*ManagementHTTPServer)
1818

1919
// ManagementHTTPServer holds Fiber app and settings.
2020
type ManagementHTTPServer struct {
21-
addr string
22-
app *fiber.App
23-
readTimeout time.Duration
24-
writeTimeout time.Duration
25-
idleTimeout time.Duration
26-
bodyLimit int
27-
concurrency int
28-
authFunc func(fiber.Ctx) error
21+
addr string
22+
app *fiber.App
23+
readTimeout time.Duration
24+
writeTimeout time.Duration
25+
idleTimeout time.Duration
26+
bodyLimit int
27+
concurrency int
28+
authFunc func(fiber.Ctx) error
29+
// controlAuthFunc is an optional stricter auth gate applied
30+
// only to the cluster-mutating control endpoints (/evict,
31+
// /clear, /trigger-expiration). When set, it runs INSTEAD OF
32+
// authFunc on those routes — typically configured to require
33+
// admin scope while authFunc requires read. When nil, the
34+
// control routes fall back to authFunc, preserving the
35+
// pre-Phase-C2 single-gate behavior.
36+
controlAuthFunc func(fiber.Ctx) error
2937
ln net.Listener
3038
started bool
3139
listenerDeadline time.Duration
@@ -49,11 +57,28 @@ type ManagementHTTPServer struct {
4957
serveErr atomic.Pointer[error]
5058
}
5159

52-
// WithMgmtAuth sets an auth function (return error to block).
60+
// WithMgmtAuth sets an auth function applied to every authenticated
61+
// route on the management port (return error to block). /health is
62+
// exempt — k8s liveness probes do not carry credentials.
63+
//
64+
// Pair with WithMgmtControlAuth for finer scope on the cluster-
65+
// mutating endpoints (/evict, /clear, /trigger-expiration); without
66+
// it, those routes fall back to this same gate.
5367
func WithMgmtAuth(fn func(fiber.Ctx) error) ManagementHTTPOption {
5468
return func(s *ManagementHTTPServer) { s.authFunc = fn }
5569
}
5670

71+
// WithMgmtControlAuth sets a stricter auth function applied only to
72+
// the cluster-mutating control endpoints — /evict, /clear,
73+
// /trigger-expiration. Use this with httpauth.Policy.Verify(c,
74+
// httpauth.ScopeAdmin) so a token granted only read or write
75+
// scope cannot trigger destructive operations through the mgmt
76+
// port. When nil, control routes inherit authFunc's gate (the
77+
// pre-Phase-C2 single-gate behavior).
78+
func WithMgmtControlAuth(fn func(fiber.Ctx) error) ManagementHTTPOption {
79+
return func(s *ManagementHTTPServer) { s.controlAuthFunc = fn }
80+
}
81+
5782
// WithMgmtReadTimeout sets read timeout.
5883
func WithMgmtReadTimeout(d time.Duration) ManagementHTTPOption {
5984
return func(s *ManagementHTTPServer) { s.readTimeout = d }
@@ -254,20 +279,42 @@ func (s *ManagementHTTPServer) Shutdown(ctx context.Context) error {
254279
// mountRoutes.
255280
func (s *ManagementHTTPServer) mountRoutes(hc managementCache) { // split into helpers to satisfy funlen
256281
useAuth := s.wrapAuth
282+
useControlAuth := s.wrapControlAuth
257283
s.registerBasic(useAuth, hc)
258284
s.registerDistributed(useAuth, hc)
259285
s.registerCluster(useAuth, hc)
260-
s.registerControl(useAuth, hc)
286+
s.registerControl(useControlAuth, hc)
261287
}
262288

263289
// wrapAuth returns an auth-wrapped handler if authFunc provided.
264290
func (s *ManagementHTTPServer) wrapAuth(handler fiber.Handler) fiber.Handler {
265-
if s.authFunc == nil {
291+
return wrapWithGate(s.authFunc, handler)
292+
}
293+
294+
// wrapControlAuth returns a handler wrapped with the stricter
295+
// control-route auth when controlAuthFunc is set, otherwise it
296+
// falls back to wrapAuth. This preserves the pre-Phase-C2
297+
// single-gate behavior for operators who haven't opted into
298+
// admin-scope enforcement on the mgmt port.
299+
func (s *ManagementHTTPServer) wrapControlAuth(handler fiber.Handler) fiber.Handler {
300+
if s.controlAuthFunc != nil {
301+
return wrapWithGate(s.controlAuthFunc, handler)
302+
}
303+
304+
return s.wrapAuth(handler)
305+
}
306+
307+
// wrapWithGate applies an auth-gate function before invoking the
308+
// underlying handler. Nil gate is a passthrough — same shape as
309+
// before WithMgmtAuth was wired, used by deployments that haven't
310+
// configured any auth on the mgmt port.
311+
func wrapWithGate(gate func(fiber.Ctx) error, handler fiber.Handler) fiber.Handler {
312+
if gate == nil {
266313
return handler
267314
}
268315

269316
return func(fiberCtx fiber.Ctx) error {
270-
authErr := s.authFunc(fiberCtx)
317+
authErr := gate(fiberCtx)
271318
if authErr != nil {
272319
return authErr
273320
}
@@ -277,7 +324,12 @@ func (s *ManagementHTTPServer) wrapAuth(handler fiber.Handler) fiber.Handler {
277324
}
278325

279326
func (s *ManagementHTTPServer) registerBasic(useAuth func(fiber.Handler) fiber.Handler, hc managementCache) {
280-
s.app.Get("/health", useAuth(func(fiberCtx fiber.Ctx) error { return fiberCtx.SendString("ok") }))
327+
// /health is intentionally NOT wrapped in useAuth — k8s
328+
// liveness/readiness probes do not carry credentials, and
329+
// a probe failure cascades into a pod-restart loop. Mirrors
330+
// the client-API binary's `/healthz` exemption (see
331+
// cmd/hypercache-server/main.go:registerClientRoutes).
332+
s.app.Get("/health", func(fiberCtx fiber.Ctx) error { return fiberCtx.SendString("ok") })
281333
s.app.Get("/stats", useAuth(func(fiberCtx fiber.Ctx) error { return fiberCtx.JSON(hc.GetStats()) }))
282334
s.app.Get("/config", useAuth(func(fiberCtx fiber.Ctx) error {
283335
cfg := map[string]any{

pkg/httpauth/policy.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,19 +210,48 @@ func (p Policy) Validate() error {
210210
// that want any-authenticated-caller semantics.
211211
func (p Policy) Middleware(required Scope) fiber.Handler {
212212
return func(c fiber.Ctx) error {
213-
identity, ok := p.resolve(c)
214-
if !ok {
215-
return c.SendStatus(fiber.StatusUnauthorized)
213+
err := p.Verify(c, required)
214+
if err != nil {
215+
return err
216216
}
217217

218-
if required != "" && !identity.HasScope(required) {
219-
return c.SendStatus(fiber.StatusForbidden)
220-
}
218+
return c.Next()
219+
}
220+
}
221221

222-
c.Locals(IdentityKey, identity)
222+
// Verify resolves credentials, asserts the required scope, and
223+
// stores the resolved Identity in c.Locals(IdentityKey). Returns
224+
// nil on success; on failure returns a *fiber.Error carrying
225+
// status 401 (no credentials matched) or 403 (credentials matched
226+
// but scope is missing). Fiber's default error handler emits the
227+
// canonical text body for the status code.
228+
//
229+
// Use Verify when integrating with code that owns its own next-
230+
// handler dispatch — e.g. ManagementHTTPServer.WithMgmtAuth and
231+
// WithMgmtControlAuth, which short-circuit on a non-nil return
232+
// from the gate function and never call the wrapped handler.
233+
// Middleware() is thin sugar over Verify() + Next() so the auth
234+
// logic lives in exactly one place.
235+
//
236+
// CRITICAL: do NOT switch to `c.SendStatus(...)` here. SendStatus
237+
// returns nil on success, which would silently fall through to
238+
// the wrapped handler in wrapWithGate-style adapters and the
239+
// downstream handler would write its own success status over the
240+
// 401 body. Returning a *fiber.Error keeps both Middleware and
241+
// the gate adapters fail-closed.
242+
func (p Policy) Verify(c fiber.Ctx, required Scope) error {
243+
identity, ok := p.resolve(c)
244+
if !ok {
245+
return fiber.NewError(fiber.StatusUnauthorized)
246+
}
223247

224-
return c.Next()
248+
if required != "" && !identity.HasScope(required) {
249+
return fiber.NewError(fiber.StatusForbidden)
225250
}
251+
252+
c.Locals(IdentityKey, identity)
253+
254+
return nil
226255
}
227256

228257
// resolve walks the credential resolution chain in priority order:

pkg/httpauth/policy_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,114 @@ func TestPolicy_HasScope(t *testing.T) {
366366
t.Errorf("Write should match Write")
367367
}
368368
}
369+
370+
// TestPolicy_Verify covers the public Verify() entry point — the
371+
// "block-with-error" sibling of Middleware that adapters
372+
// (ManagementHTTPServer.WithMgmtControlAuth, etc.) use when they
373+
// own their own next-handler dispatch. Same semantics as
374+
// Middleware: 401 on missing/invalid creds, 403 on wrong scope,
375+
// nil + Identity in Locals on success. The shared resolve() means
376+
// any future divergence between Middleware and Verify would be a
377+
// security bug; this test pins parity.
378+
func TestPolicy_Verify(t *testing.T) {
379+
t.Parallel()
380+
381+
p := Policy{
382+
Tokens: []TokenIdentity{
383+
{ID: "ro", Token: "ro-token", Scopes: []Scope{ScopeRead}},
384+
{ID: "admin", Token: "admin-token", Scopes: []Scope{ScopeRead, ScopeWrite, ScopeAdmin}},
385+
},
386+
}
387+
388+
cases := []struct {
389+
name string
390+
header string
391+
scope Scope
392+
want int
393+
}{
394+
{"no creds → 401", "", ScopeRead, http.StatusUnauthorized},
395+
{"bad bearer → 401", "Bearer wrong", ScopeRead, http.StatusUnauthorized},
396+
{"read scope on read route → 200", "Bearer ro-token", ScopeRead, http.StatusOK},
397+
{"read scope on admin route → 403", "Bearer ro-token", ScopeAdmin, http.StatusForbidden},
398+
{"admin scope on admin route → 200", "Bearer admin-token", ScopeAdmin, http.StatusOK},
399+
{"empty scope (any-authenticated) → 200 with creds", "Bearer ro-token", "", http.StatusOK},
400+
{"empty scope still 401 without creds", "", "", http.StatusUnauthorized},
401+
}
402+
403+
for _, tc := range cases {
404+
t.Run(tc.name, func(t *testing.T) {
405+
t.Parallel()
406+
407+
app := fiber.New()
408+
// Mount Verify in the wrapAuth-style adapter shape:
409+
// auth happens, then handler runs only on nil error.
410+
// This is exactly how ManagementHTTPServer wires it.
411+
scope := tc.scope
412+
app.Get("/protected", func(c fiber.Ctx) error {
413+
err := p.Verify(c, scope)
414+
if err != nil {
415+
return err
416+
}
417+
418+
return c.SendString("ok")
419+
})
420+
421+
got := doStatus(t, app, tc.header)
422+
if got != tc.want {
423+
t.Fatalf("status: got %d, want %d", got, tc.want)
424+
}
425+
})
426+
}
427+
}
428+
429+
// TestPolicy_Verify_StoresIdentityInLocals pins the side-effect
430+
// contract: a successful Verify populates IdentityKey before
431+
// returning. Adapters that read c.Locals(IdentityKey) — e.g. any
432+
// future audit-attribution handler on the mgmt port — depend on
433+
// it. Without this assertion a future refactor could regress
434+
// Verify into "scope-check only" silently.
435+
func TestPolicy_Verify_StoresIdentityInLocals(t *testing.T) {
436+
t.Parallel()
437+
438+
p := Policy{
439+
Tokens: []TokenIdentity{
440+
{ID: "audit-target", Token: "tok", Scopes: []Scope{ScopeRead}},
441+
},
442+
}
443+
444+
app := fiber.New()
445+
app.Get("/who", func(c fiber.Ctx) error {
446+
err := p.Verify(c, ScopeRead)
447+
if err != nil {
448+
return err
449+
}
450+
451+
v := c.Locals(IdentityKey)
452+
453+
id, ok := v.(Identity)
454+
if !ok {
455+
return c.Status(http.StatusInternalServerError).SendString("no identity")
456+
}
457+
458+
return c.SendString(id.ID)
459+
})
460+
461+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/who", strings.NewReader(""))
462+
req.Header.Set("Authorization", "Bearer tok")
463+
464+
resp, err := app.Test(req)
465+
if err != nil {
466+
t.Fatalf("app.Test: %v", err)
467+
}
468+
469+
defer func() { _ = resp.Body.Close() }()
470+
471+
body := make([]byte, 64)
472+
473+
n, _ := resp.Body.Read(body)
474+
475+
got := string(body[:n])
476+
if got != "audit-target" {
477+
t.Fatalf("locals identity ID = %q, want %q", got, "audit-target")
478+
}
479+
}

0 commit comments

Comments
 (0)