Skip to content

Commit 68d0938

Browse files
authored
Merge pull request #117 from hyp3rd/feat/dist-mem-cache
feat(server): add GET /v1/me identity introspection endpoint
2 parents 2ef80a4 + a33a309 commit 68d0938

8 files changed

Lines changed: 256 additions & 6 deletions

File tree

CHANGELOG.md

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

99
### Added
1010

11+
- **`GET /v1/me` — resolved caller identity.** New scope-protected
12+
(`read`) route that reads the resolved `httpauth.Identity` from
13+
`c.Locals(httpauth.IdentityKey)` and returns
14+
`{ id, scopes }` JSON. Mirrors the new schema documented at
15+
[`/v1/me`](cmd/hypercache-server/openapi.yaml). Unblocks the
16+
HyperCache Monitor's Phase C2 swap from the legacy
17+
`/v1/owners/__probe__` probe to a real introspection of the
18+
bound bearer token's grants. Anonymous mode (`AllowAnonymous: true`)
19+
returns `id: "anonymous"` with all three scopes — same identity
20+
the policy emits internally. Drift test
21+
([`openapi_test.go`](cmd/hypercache-server/openapi_test.go))
22+
and auth-coverage table
23+
([`auth_test.go`](cmd/hypercache-server/auth_test.go)) updated
24+
in lockstep.
1125
- **Client API auth v2: multi-token, scoped, mTLS-capable.** New
1226
[`pkg/httpauth/`](pkg/httpauth/) package with `Policy`,
1327
`TokenIdentity`, `CertIdentity`, `Scope` types and a

cmd/hypercache-server/auth_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func TestBearerAuth_AllProtectedRoutes(t *testing.T) {
232232
{http.MethodHead, pathCacheKey},
233233
{http.MethodDelete, pathCacheKey},
234234
{http.MethodGet, "/v1/owners/k"},
235+
{http.MethodGet, "/v1/me"},
235236
{http.MethodPost, "/v1/cache/batch/get"},
236237
{http.MethodPost, "/v1/cache/batch/put"},
237238
{http.MethodPost, "/v1/cache/batch/delete"},
@@ -275,6 +276,7 @@ func TestScope_ReadOnlyToken(t *testing.T) {
275276
}{
276277
// Read scope: 200.
277278
{http.MethodGet, "/v1/owners/k", http.StatusOK},
279+
{http.MethodGet, "/v1/me", http.StatusOK},
278280
// Write scope: 403 (token has Read only).
279281
{http.MethodPut, pathCacheKey, http.StatusForbidden},
280282
{http.MethodDelete, pathCacheKey, http.StatusForbidden},

cmd/hypercache-server/main.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ func registerClientRoutes(app *fiber.App, policy httpauth.Policy, nodeCtx *nodeC
352352
app.Head("/v1/cache/:key", read, func(c fiber.Ctx) error { return handleHead(c, nodeCtx) })
353353
app.Delete("/v1/cache/:key", write, func(c fiber.Ctx) error { return handleDelete(c, nodeCtx) })
354354
app.Get("/v1/owners/:key", read, func(c fiber.Ctx) error { return handleOwners(c, nodeCtx) })
355+
app.Get("/v1/me", read, handleMe)
355356

356357
app.Post("/v1/cache/batch/get", read, func(c fiber.Ctx) error { return handleBatchGet(c, nodeCtx) })
357358
app.Post("/v1/cache/batch/put", write, func(c fiber.Ctx) error { return handleBatchPut(c, nodeCtx) })
@@ -1175,6 +1176,44 @@ func handleOwners(c fiber.Ctx, nodeCtx *nodeContext) error {
11751176
})
11761177
}
11771178

1179+
// meResponse is the body of GET /v1/me — the resolved caller identity
1180+
// after auth middleware ran. Mirrors httpauth.Identity but written as
1181+
// a wire type so the JSON tags are owned by the API surface, not the
1182+
// internal auth package.
1183+
type meResponse struct {
1184+
ID string `json:"id"`
1185+
Scopes []string `json:"scopes"`
1186+
}
1187+
1188+
// handleMe implements GET /v1/me — returns the calling principal's
1189+
// identity and granted scopes. Used by the monitor to introspect a
1190+
// bound bearer token without making a no-op probe against another
1191+
// route. The middleware has already populated IdentityKey on every
1192+
// request that reached this handler (anonymous mode included), so
1193+
// the type assertion is safe; a missing or wrong-typed Locals entry
1194+
// is a wiring bug and surfaces as a 500 rather than a silent default.
1195+
func handleMe(c fiber.Ctx) error {
1196+
identity, ok := c.Locals(httpauth.IdentityKey).(httpauth.Identity)
1197+
if !ok {
1198+
return jsonErr(
1199+
c,
1200+
fiber.StatusInternalServerError,
1201+
codeInternal,
1202+
"identity not resolved by middleware (wiring bug)",
1203+
)
1204+
}
1205+
1206+
scopes := make([]string, len(identity.Scopes))
1207+
for i, s := range identity.Scopes {
1208+
scopes[i] = string(s)
1209+
}
1210+
1211+
return c.JSON(meResponse{
1212+
ID: identity.ID,
1213+
Scopes: scopes,
1214+
})
1215+
}
1216+
11781217
func main() { os.Exit(run()) }
11791218

11801219
// run is the testable main body — separated so deferred cleanup

cmd/hypercache-server/me_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
9+
"github.com/goccy/go-json"
10+
fiber "github.com/gofiber/fiber/v3"
11+
12+
"github.com/hyp3rd/hypercache/pkg/httpauth"
13+
)
14+
15+
// TestHandleMe_BodyShape pins the wire shape of GET /v1/me. We mount
16+
// the handler behind a tiny inline middleware that stuffs a known
17+
// Identity into Locals — the same shape httpauth.Policy.Middleware
18+
// installs after a successful auth match — so the assertion is purely
19+
// about the response body, not the auth resolver. auth_test.go covers
20+
// the authentication contract; this test covers the rendering one.
21+
func TestHandleMe_BodyShape(t *testing.T) {
22+
t.Parallel()
23+
24+
cases := []struct {
25+
name string
26+
identity httpauth.Identity
27+
want meResponse
28+
}{
29+
{
30+
name: "read-only operator",
31+
identity: httpauth.Identity{
32+
ID: "ops-readonly",
33+
Scopes: []httpauth.Scope{httpauth.ScopeRead},
34+
},
35+
want: meResponse{ID: "ops-readonly", Scopes: []string{"read"}},
36+
},
37+
{
38+
name: "rw operator",
39+
identity: httpauth.Identity{
40+
ID: "ops-rw",
41+
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite},
42+
},
43+
want: meResponse{ID: "ops-rw", Scopes: []string{"read", "write"}},
44+
},
45+
{
46+
name: "anonymous (AllowAnonymous=true on the policy)",
47+
identity: httpauth.Identity{
48+
ID: "anonymous",
49+
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite, httpauth.ScopeAdmin},
50+
},
51+
want: meResponse{ID: "anonymous", Scopes: []string{"read", "write", "admin"}},
52+
},
53+
}
54+
55+
for _, tc := range cases {
56+
t.Run(tc.name, func(t *testing.T) {
57+
t.Parallel()
58+
59+
got := callMeWithIdentity(t, tc.identity)
60+
assertMeBody(t, got, tc.want)
61+
})
62+
}
63+
}
64+
65+
// callMeWithIdentity drives /v1/me through a fiber app with a single
66+
// middleware that pre-populates IdentityKey. Returns the decoded
67+
// response body. Failed status / decode trips the test fatally.
68+
func callMeWithIdentity(t *testing.T, identity httpauth.Identity) meResponse {
69+
t.Helper()
70+
71+
app := fiber.New()
72+
// Stand-in for httpauth.Policy.Middleware — installs the
73+
// Locals entry handleMe reads. Test owns the identity it
74+
// asserts against.
75+
app.Use(func(c fiber.Ctx) error {
76+
c.Locals(httpauth.IdentityKey, identity)
77+
78+
return c.Next()
79+
})
80+
app.Get("/v1/me", handleMe)
81+
82+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/me", strings.NewReader(""))
83+
84+
resp, err := app.Test(req)
85+
if err != nil {
86+
t.Fatalf("app.Test: %v", err)
87+
}
88+
89+
defer func() { _ = resp.Body.Close() }()
90+
91+
if resp.StatusCode != http.StatusOK {
92+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
93+
}
94+
95+
var got meResponse
96+
97+
err = json.NewDecoder(resp.Body).Decode(&got)
98+
if err != nil {
99+
t.Fatalf("decode body: %v", err)
100+
}
101+
102+
return got
103+
}
104+
105+
// assertMeBody compares a decoded meResponse against the expected
106+
// shape. ID is a single string compare; scopes are ordered slices
107+
// (handleMe preserves the Identity.Scopes order, so order is part
108+
// of the contract).
109+
func assertMeBody(t *testing.T, got, want meResponse) {
110+
t.Helper()
111+
112+
if got.ID != want.ID {
113+
t.Errorf("id: got %q, want %q", got.ID, want.ID)
114+
}
115+
116+
if len(got.Scopes) != len(want.Scopes) {
117+
t.Fatalf("scopes length: got %d, want %d (got=%v)", len(got.Scopes), len(want.Scopes), got.Scopes)
118+
}
119+
120+
for i, s := range want.Scopes {
121+
if got.Scopes[i] != s {
122+
t.Errorf("scopes[%d]: got %q, want %q", i, got.Scopes[i], s)
123+
}
124+
}
125+
}
126+
127+
// TestHandleMe_MissingLocals covers the wiring-bug path. If a future
128+
// refactor mounts handleMe without the auth middleware, the type
129+
// assertion in handleMe falls through to a 500 with codeInternal —
130+
// not a silent default identity. This test pins that fail-loud
131+
// behavior so a misconfiguration cannot silently degrade to
132+
// "everyone is anonymous, with all scopes.".
133+
func TestHandleMe_MissingLocals(t *testing.T) {
134+
t.Parallel()
135+
136+
app := fiber.New()
137+
// No middleware installs IdentityKey — handleMe should 500.
138+
app.Get("/v1/me", handleMe)
139+
140+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/me", strings.NewReader(""))
141+
142+
resp, err := app.Test(req)
143+
if err != nil {
144+
t.Fatalf("app.Test: %v", err)
145+
}
146+
147+
defer func() { _ = resp.Body.Close() }()
148+
149+
if resp.StatusCode != http.StatusInternalServerError {
150+
t.Fatalf("status: got %d, want 500", resp.StatusCode)
151+
}
152+
}

cmd/hypercache-server/openapi.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,31 @@ paths:
248248
"400": { $ref: "#/components/responses/BadRequest" }
249249
"401": { $ref: "#/components/responses/Unauthorized" }
250250

251+
/v1/me:
252+
get:
253+
operationId: getIdentity
254+
tags: [ meta ]
255+
summary: Resolved caller identity.
256+
description: |
257+
Returns the identity resolved from the request credentials
258+
(bearer token, mTLS cert, or `anonymous` when AllowAnonymous
259+
is enabled). Includes the granted scopes so callers can
260+
introspect their permissions without trial-and-error against
261+
scope-protected routes.
262+
263+
Requires the `read` scope — operators in pure-write or pure-
264+
admin token configurations do not need to introspect their
265+
own identity for normal cache use; the monitor's login flow
266+
is the primary consumer.
267+
responses:
268+
"200":
269+
description: Resolved identity + granted scopes.
270+
content:
271+
application/json:
272+
schema:
273+
$ref: "#/components/schemas/IdentityResponse"
274+
"401": { $ref: "#/components/responses/Unauthorized" }
275+
251276
/v1/cache/batch/get:
252277
post:
253278
operationId: batchGet
@@ -468,6 +493,23 @@ components:
468493
items: { type: string }
469494
node: { type: string }
470495

496+
IdentityResponse:
497+
type: object
498+
required: [ id, scopes ]
499+
properties:
500+
id:
501+
type: string
502+
description: |
503+
Identity label from the auth config (Tokens[].ID,
504+
CertIdentities[].SubjectCN, or `anonymous` when
505+
AllowAnonymous is enabled).
506+
scopes:
507+
type: array
508+
description: Permission scopes granted to this identity.
509+
items:
510+
type: string
511+
enum: [ read, write, admin ]
512+
471513
ItemEnvelope:
472514
type: object
473515
required: [ key, value, value_encoding, version, node, owners ]

cmd/hypercache-server/openapi_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ func declaredMethodsForPath() map[string]map[string]struct{} {
9696
"/v1/openapi.yaml": {fiber.MethodGet: {}},
9797
"/v1/cache/:key": {fiber.MethodPut: {}, fiber.MethodGet: {}, fiber.MethodHead: {}, fiber.MethodDelete: {}},
9898
"/v1/owners/:key": {fiber.MethodGet: {}},
99+
"/v1/me": {fiber.MethodGet: {}},
99100
"/v1/cache/batch/get": {fiber.MethodPost: {}},
100101
"/v1/cache/batch/put": {fiber.MethodPost: {}},
101102
"/v1/cache/batch/delete": {fiber.MethodPost: {}},

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ require (
3737
github.com/valyala/fasthttp v1.71.0 // indirect
3838
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
3939
go.uber.org/atomic v1.11.0 // indirect
40-
golang.org/x/crypto v0.50.0 // indirect
41-
golang.org/x/net v0.53.0 // indirect
40+
golang.org/x/crypto v0.51.0 // indirect
41+
golang.org/x/net v0.54.0 // indirect
4242
golang.org/x/sys v0.44.0 // indirect
4343
golang.org/x/text v0.37.0 // indirect
4444
)

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
8585
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
8686
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
8787
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
88-
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
89-
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
90-
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
91-
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
88+
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
89+
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
90+
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
91+
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
9292
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
9393
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
9494
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=

0 commit comments

Comments
 (0)