Skip to content

Commit 42405d2

Browse files
committed
feat(auth): add GET /v1/me/can capability probe and OIDC token-refresh logging
Closes RFC 0003 open questions 5 & 6. - Add `GET /v1/me/can?capability=<name>` server endpoint that returns whether the resolved identity holds the requested capability. Unknown capability strings return 400 BAD_REQUEST rather than silently degrading to `allowed=false`, surfacing typos as client errors. Recognised capabilities are a closed set: `cache.read`, `cache.write`, `cache.admin`. - Add `Identity.HasCapability(name string)` to `pkg/httpauth/policy.go` as the single authoritative scope-to-capability check shared by both the server handler and the SDK. - Add `Client.Can(ctx, capability)` SDK method mirroring the new endpoint. Denial returns `(false, nil)`; spelling mistakes return `(false, ErrBadRequest)`, making the typo visible at the call site. - Add `loggingTokenSource` in `pkg/client/oidc_logging.go` wrapping the `oauth2.TokenSource` used by `WithOIDCClientCredentials`. Emits one `"oidc token rotated"` slog Info line per real rotation (expiry change); cached returns stay silent. Holds a `*Client` reference so `WithLogger` applied after `WithOIDCClientCredentials` still reaches the log surface. - Extend `openapi.yaml` with the `/v1/me/can` operation and `CanResponse` schema. - Add 9 new tests: 3 handler tests (`me_test.go`), 3 SDK tests (`client_test.go`), 3 unit tests (`oidc_logging_test.go`). - Update `docs/client-sdk.md` and `CHANGELOG.md` with new sections for capability probing and token-refresh visibility. - Fix `Makefile` `pre-commit` target to activate the pyenv virtualenv before running hooks.
1 parent 2904f21 commit 42405d2

15 files changed

Lines changed: 709 additions & 4 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ All notable changes to HyperCache are recorded here. The format follows
88

99
### Added
1010

11+
- **Token-refresh visibility for the OIDC source.** Closes RFC 0003 open question 6: the
12+
`WithOIDCClientCredentials` source now wraps its `oauth2.TokenSource` with a logger that emits one
13+
`"oidc token rotated"` Info line per real rotation (expiry change), staying silent on cached returns.
14+
Operators debugging "why are my requests suddenly 401?" now see token age in the structured log alongside
15+
the other lifecycle events. The wrapper holds the `*Client` by reference rather than capturing
16+
`c.logger` at construction time, so `WithLogger` applied AFTER `WithOIDCClientCredentials` still reaches
17+
the rotation log surface. Three unit tests in [`pkg/client/oidc_logging_test.go`](pkg/client/oidc_logging_test.go)
18+
cover the rotation-logs case, the cached-returns-stay-silent case, and the nil-Client defensive path.
19+
- **`GET /v1/me/can` capability probe + `Client.Can(ctx, capability)` SDK method.** Closes RFC 0003 open
20+
question 5: callers can now check "do I have write?" without the speculative-write pattern (try the
21+
action, catch the 403). The server endpoint validates against a closed set of capability strings
22+
(`cache.read` / `cache.write` / `cache.admin`); unknown values return 400 BAD_REQUEST so typos surface as
23+
client errors rather than silently degrading to allowed=false. The SDK method mirrors this:
24+
`(true, nil)` / `(false, nil)` for the allow/deny answers; `errors.Is(err, ErrBadRequest)` for the
25+
spelling-mistake path. `Identity.HasCapability` added to [`pkg/httpauth/policy.go`](pkg/httpauth/policy.go)
26+
as the single authoritative check used by both the server handler and the SDK. Three handler tests in
27+
[`cmd/hypercache-server/me_test.go`](cmd/hypercache-server/me_test.go) cover allowed/denied/unknown;
28+
three SDK tests in [`pkg/client/client_test.go`](pkg/client/client_test.go) cover the parallel surface.
29+
OpenAPI spec ([`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml)) gains the
30+
`/v1/me/can` operation + `CanResponse` schema. New "Probing a single capability with `Can`" and
31+
"Token-refresh visibility" sections in [`docs/client-sdk.md`](docs/client-sdk.md).
1132
- **Chaos hooks for resilience testing (Phase 7).** New
1233
[`backend.WithDistChaos(*Chaos)`](pkg/backend/dist_chaos.go) option transparently wraps the dist transport
1334
with configurable fault injection — drop rate and latency injection, both with per-call probability rolls

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ docs-serve: docs-build
216216
PYENV_VERSION=mkdocs mkdocs serve
217217

218218
pre-commit:
219+
@eval "$$(pyenv init -)" && \
220+
pyenv activate pre-commit && \
219221
pre-commit run -a trailing-whitespace && \
220222
pre-commit run -a end-of-file-fixer && \
221223
pre-commit run -a markdownlint && \

cmd/hypercache-server/main.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ func registerClientRoutes(app *fiber.App, policy httpauth.Policy, nodeCtx *nodeC
465465
app.Delete("/v1/cache/:key", write, func(c fiber.Ctx) error { return handleDelete(c, nodeCtx) })
466466
app.Get("/v1/owners/:key", read, func(c fiber.Ctx) error { return handleOwners(c, nodeCtx) })
467467
app.Get("/v1/me", read, handleMe)
468+
app.Get("/v1/me/can", read, handleCan)
468469

469470
app.Post("/v1/cache/batch/get", read, func(c fiber.Ctx) error { return handleBatchGet(c, nodeCtx) })
470471
app.Post("/v1/cache/batch/put", write, func(c fiber.Ctx) error { return handleBatchPut(c, nodeCtx) })
@@ -1335,6 +1336,74 @@ func handleMe(c fiber.Ctx) error {
13351336
})
13361337
}
13371338

1339+
// canResponse is the body of GET /v1/me/can?capability=<name>.
1340+
// `Allowed` is the discrimination result; `Capability` echoes the
1341+
// caller's input so log scraping ties allow/deny to the asked
1342+
// capability without parsing the query string again.
1343+
type canResponse struct {
1344+
Capability string `json:"capability"`
1345+
Allowed bool `json:"allowed"`
1346+
}
1347+
1348+
// capability strings — closed set in the `cache.` namespace.
1349+
// Unknown values return 400 rather than silently false so callers
1350+
// detect typos instead of shipping broken authz logic to prod.
1351+
const (
1352+
capabilityCacheRead = "cache.read"
1353+
capabilityCacheWrite = "cache.write"
1354+
capabilityCacheAdmin = "cache.admin"
1355+
)
1356+
1357+
// isKnownCapability reports whether s is one of the three
1358+
// recognized capability strings. Switch-based so a future
1359+
// capability is one named const + one case.
1360+
func isKnownCapability(s string) bool {
1361+
switch s {
1362+
case capabilityCacheRead, capabilityCacheWrite, capabilityCacheAdmin:
1363+
return true
1364+
default:
1365+
return false
1366+
}
1367+
}
1368+
1369+
// handleCan implements GET /v1/me/can?capability=cache.write —
1370+
// per-capability authorization probe. Caller passes a capability
1371+
// string; the response says whether the resolved identity holds
1372+
// it. Cheaper than the speculative-write pattern (try the write,
1373+
// catch the 403), and stable across future scope-to-capability
1374+
// refactors (clients key off the capability string, not the
1375+
// internal scope shape).
1376+
//
1377+
// Requires the `read` scope — same threshold as /v1/me. Unknown
1378+
// capability values fail BAD_REQUEST so typos don't silently
1379+
// answer "not allowed" when the real issue is the caller's
1380+
// spelling.
1381+
func handleCan(c fiber.Ctx) error {
1382+
capability := c.Query("capability")
1383+
if capability == "" {
1384+
return jsonErr(c, fiber.StatusBadRequest, codeBadRequest, "missing 'capability' query parameter")
1385+
}
1386+
1387+
if !isKnownCapability(capability) {
1388+
return jsonErr(c, fiber.StatusBadRequest, codeBadRequest, "unknown capability '"+capability+"'")
1389+
}
1390+
1391+
identity, ok := c.Locals(httpauth.IdentityKey).(httpauth.Identity)
1392+
if !ok {
1393+
return jsonErr(
1394+
c,
1395+
fiber.StatusInternalServerError,
1396+
codeInternal,
1397+
"identity not resolved by middleware (wiring bug)",
1398+
)
1399+
}
1400+
1401+
return c.JSON(canResponse{
1402+
Capability: capability,
1403+
Allowed: identity.HasCapability(capability),
1404+
})
1405+
}
1406+
13381407
func main() { os.Exit(run()) }
13391408

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

cmd/hypercache-server/me_test.go

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
3535
want: meResponse{
3636
ID: "ops-readonly",
3737
Scopes: []string{"read"},
38-
Capabilities: []string{"cache.read"},
38+
Capabilities: []string{capabilityCacheRead},
3939
},
4040
},
4141
{
@@ -47,7 +47,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
4747
want: meResponse{
4848
ID: "ops-rw",
4949
Scopes: []string{"read", "write"},
50-
Capabilities: []string{"cache.read", "cache.write"},
50+
Capabilities: []string{capabilityCacheRead, capabilityCacheWrite},
5151
},
5252
},
5353
{
@@ -59,7 +59,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
5959
want: meResponse{
6060
ID: "anonymous",
6161
Scopes: []string{"read", "write", "admin"},
62-
Capabilities: []string{"cache.read", "cache.write", "cache.admin"},
62+
Capabilities: []string{capabilityCacheRead, capabilityCacheWrite, capabilityCacheAdmin},
6363
},
6464
},
6565
}
@@ -172,3 +172,144 @@ func TestHandleMe_MissingLocals(t *testing.T) {
172172
t.Fatalf("status: got %d, want 500", resp.StatusCode)
173173
}
174174
}
175+
176+
// TestHandleCan_AllowedAndDenied pins the canonical happy paths:
177+
// an identity holding a capability gets allowed=true; the same
178+
// identity probed against a capability it lacks gets allowed=false.
179+
// Both produce 200 — "allowed=false" is a successful probe, not
180+
// an authorization failure.
181+
func TestHandleCan_AllowedAndDenied(t *testing.T) {
182+
t.Parallel()
183+
184+
readWriteIdentity := httpauth.Identity{
185+
ID: "ops-rw",
186+
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite},
187+
}
188+
189+
tests := []struct {
190+
name string
191+
capability string
192+
wantAllowed bool
193+
}{
194+
{"read holder asks for read", capabilityCacheRead, true},
195+
{"rw holder asks for write", capabilityCacheWrite, true},
196+
{"rw holder asks for admin", capabilityCacheAdmin, false},
197+
}
198+
199+
for _, tc := range tests {
200+
t.Run(tc.name, func(t *testing.T) {
201+
t.Parallel()
202+
203+
body := callCanWithIdentity(t, readWriteIdentity, tc.capability)
204+
if body.Capability != tc.capability {
205+
t.Errorf("capability echo: got %q, want %q", body.Capability, tc.capability)
206+
}
207+
208+
if body.Allowed != tc.wantAllowed {
209+
t.Errorf("allowed: got %v, want %v", body.Allowed, tc.wantAllowed)
210+
}
211+
})
212+
}
213+
}
214+
215+
// TestHandleCan_MissingCapabilityParam pins the input-validation
216+
// posture: a request without the `capability` query param fails
217+
// 400 with the canonical error envelope. We don't silently
218+
// default to allowed=false — that would let typos pass as
219+
// "you can't do it" when the real issue is the missing argument.
220+
func TestHandleCan_MissingCapabilityParam(t *testing.T) {
221+
t.Parallel()
222+
223+
app := fiber.New()
224+
app.Use(func(c fiber.Ctx) error {
225+
c.Locals(httpauth.IdentityKey, httpauth.Identity{ID: "x", Scopes: []httpauth.Scope{httpauth.ScopeRead}})
226+
227+
return c.Next()
228+
})
229+
app.Get("/v1/me/can", handleCan)
230+
231+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/me/can", strings.NewReader(""))
232+
233+
resp, err := app.Test(req)
234+
if err != nil {
235+
t.Fatalf("app.Test: %v", err)
236+
}
237+
238+
defer func() { _ = resp.Body.Close() }()
239+
240+
if resp.StatusCode != http.StatusBadRequest {
241+
t.Fatalf("status: got %d, want 400", resp.StatusCode)
242+
}
243+
}
244+
245+
// TestHandleCan_UnknownCapability pins that unrecognized
246+
// capability strings fail 400, not silently allowed=false. A
247+
// typo like `cache.reaad` should surface as a client error so
248+
// the caller fixes their code rather than shipping a broken
249+
// authz check.
250+
func TestHandleCan_UnknownCapability(t *testing.T) {
251+
t.Parallel()
252+
253+
app := fiber.New()
254+
app.Use(func(c fiber.Ctx) error {
255+
c.Locals(httpauth.IdentityKey, httpauth.Identity{
256+
ID: "x",
257+
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite, httpauth.ScopeAdmin},
258+
})
259+
260+
return c.Next()
261+
})
262+
app.Get("/v1/me/can", handleCan)
263+
264+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
265+
"/v1/me/can?capability=cache.reaad", strings.NewReader(""))
266+
267+
resp, err := app.Test(req)
268+
if err != nil {
269+
t.Fatalf("app.Test: %v", err)
270+
}
271+
272+
defer func() { _ = resp.Body.Close() }()
273+
274+
if resp.StatusCode != http.StatusBadRequest {
275+
t.Fatalf("status: got %d, want 400 (unknown capability)", resp.StatusCode)
276+
}
277+
}
278+
279+
// callCanWithIdentity drives /v1/me/can with a pre-populated
280+
// IdentityKey local. Returns the decoded canResponse; failed
281+
// status / decode trips the test fatally.
282+
func callCanWithIdentity(t *testing.T, identity httpauth.Identity, capability string) canResponse {
283+
t.Helper()
284+
285+
app := fiber.New()
286+
app.Use(func(c fiber.Ctx) error {
287+
c.Locals(httpauth.IdentityKey, identity)
288+
289+
return c.Next()
290+
})
291+
app.Get("/v1/me/can", handleCan)
292+
293+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
294+
"/v1/me/can?capability="+capability, strings.NewReader(""))
295+
296+
resp, err := app.Test(req)
297+
if err != nil {
298+
t.Fatalf("app.Test: %v", err)
299+
}
300+
301+
defer func() { _ = resp.Body.Close() }()
302+
303+
if resp.StatusCode != http.StatusOK {
304+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
305+
}
306+
307+
var got canResponse
308+
309+
err = json.NewDecoder(resp.Body).Decode(&got)
310+
if err != nil {
311+
t.Fatalf("decode body: %v", err)
312+
}
313+
314+
return got
315+
}

cmd/hypercache-server/openapi.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,43 @@ paths:
274274
$ref: "#/components/schemas/IdentityResponse"
275275
"401": { $ref: "#/components/responses/Unauthorized" }
276276

277+
/v1/me/can:
278+
get:
279+
operationId: canPerform
280+
tags: [ meta ]
281+
summary: Per-capability authorization probe.
282+
description: |
283+
Returns whether the resolved identity holds the requested
284+
capability. Cheaper than the speculative-write pattern
285+
(try the action, catch the 403) and stable across future
286+
scope-to-capability refactors — clients should key off the
287+
capability string, not the internal scope shape.
288+
289+
Unknown capability values return 400 BAD_REQUEST so typos
290+
don't silently answer "not allowed" when the real issue
291+
is the caller's spelling.
292+
293+
Requires the `read` scope — same threshold as `/v1/me`.
294+
parameters:
295+
- in: query
296+
name: capability
297+
required: true
298+
schema:
299+
type: string
300+
enum: [ cache.read, cache.write, cache.admin ]
301+
description: |
302+
The capability string to probe. Must be one of the
303+
three values in the closed `cache.*` namespace.
304+
responses:
305+
"200":
306+
description: Probe result.
307+
content:
308+
application/json:
309+
schema:
310+
$ref: "#/components/schemas/CanResponse"
311+
"400": { $ref: "#/components/responses/BadRequest" }
312+
"401": { $ref: "#/components/responses/Unauthorized" }
313+
277314
/v1/cache/batch/get:
278315
post:
279316
operationId: batchGet
@@ -522,6 +559,19 @@ components:
522559
items:
523560
type: string
524561

562+
CanResponse:
563+
type: object
564+
required: [ capability, allowed ]
565+
properties:
566+
capability:
567+
type: string
568+
description: Echoes the queried capability string.
569+
allowed:
570+
type: boolean
571+
description: |
572+
True when the resolved identity holds the requested
573+
capability; false otherwise.
574+
525575
ItemEnvelope:
526576
type: object
527577
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
@@ -97,6 +97,7 @@ func declaredMethodsForPath() map[string]map[string]struct{} {
9797
"/v1/cache/:key": {fiber.MethodPut: {}, fiber.MethodGet: {}, fiber.MethodHead: {}, fiber.MethodDelete: {}},
9898
"/v1/owners/:key": {fiber.MethodGet: {}},
9999
"/v1/me": {fiber.MethodGet: {}},
100+
"/v1/me/can": {fiber.MethodGet: {}},
100101
"/v1/cache/batch/get": {fiber.MethodPost: {}},
101102
"/v1/cache/batch/put": {fiber.MethodPost: {}},
102103
"/v1/cache/batch/delete": {fiber.MethodPost: {}},

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ words:
215215
- pyenv
216216
- pygments
217217
- pymdownx
218+
- reaad
218219
- recvcheck
219220
- rediscluster
220221
- Redocly

0 commit comments

Comments
 (0)