Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ All notable changes to HyperCache are recorded here. The format follows

### Added

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

pre-commit:
@eval "$$(pyenv init -)" && \
pyenv activate pre-commit && \
pre-commit run -a trailing-whitespace && \
pre-commit run -a end-of-file-fixer && \
pre-commit run -a markdownlint && \
Expand Down
69 changes: 69 additions & 0 deletions cmd/hypercache-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ func registerClientRoutes(app *fiber.App, policy httpauth.Policy, nodeCtx *nodeC
app.Delete("/v1/cache/:key", write, func(c fiber.Ctx) error { return handleDelete(c, nodeCtx) })
app.Get("/v1/owners/:key", read, func(c fiber.Ctx) error { return handleOwners(c, nodeCtx) })
app.Get("/v1/me", read, handleMe)
app.Get("/v1/me/can", read, handleCan)

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

// canResponse is the body of GET /v1/me/can?capability=<name>.
// `Allowed` is the discrimination result; `Capability` echoes the
// caller's input so log scraping ties allow/deny to the asked
// capability without parsing the query string again.
type canResponse struct {
Capability string `json:"capability"`
Allowed bool `json:"allowed"`
}

// capability strings — closed set in the `cache.` namespace.
// Unknown values return 400 rather than silently false so callers
// detect typos instead of shipping broken authz logic to prod.
const (
capabilityCacheRead = "cache.read"
capabilityCacheWrite = "cache.write"
capabilityCacheAdmin = "cache.admin"
)

// isKnownCapability reports whether s is one of the three
// recognized capability strings. Switch-based so a future
// capability is one named const + one case.
func isKnownCapability(s string) bool {
switch s {
case capabilityCacheRead, capabilityCacheWrite, capabilityCacheAdmin:
return true
default:
return false
}
}

// handleCan implements GET /v1/me/can?capability=cache.write —
// per-capability authorization probe. Caller passes a capability
// string; the response says whether the resolved identity holds
// it. Cheaper than the speculative-write pattern (try the write,
// catch the 403), and stable across future scope-to-capability
// refactors (clients key off the capability string, not the
// internal scope shape).
//
// Requires the `read` scope — same threshold as /v1/me. Unknown
// capability values fail BAD_REQUEST so typos don't silently
// answer "not allowed" when the real issue is the caller's
// spelling.
func handleCan(c fiber.Ctx) error {
capability := c.Query("capability")
if capability == "" {
return jsonErr(c, fiber.StatusBadRequest, codeBadRequest, "missing 'capability' query parameter")
}

if !isKnownCapability(capability) {
return jsonErr(c, fiber.StatusBadRequest, codeBadRequest, "unknown capability '"+capability+"'")
}

identity, ok := c.Locals(httpauth.IdentityKey).(httpauth.Identity)
if !ok {
return jsonErr(
c,
fiber.StatusInternalServerError,
codeInternal,
"identity not resolved by middleware (wiring bug)",
)
}

return c.JSON(canResponse{
Capability: capability,
Allowed: identity.HasCapability(capability),
})
}

func main() { os.Exit(run()) }

// run is the testable main body — separated so deferred cleanup
Expand Down
147 changes: 144 additions & 3 deletions cmd/hypercache-server/me_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
want: meResponse{
ID: "ops-readonly",
Scopes: []string{"read"},
Capabilities: []string{"cache.read"},
Capabilities: []string{capabilityCacheRead},
},
},
{
Expand All @@ -47,7 +47,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
want: meResponse{
ID: "ops-rw",
Scopes: []string{"read", "write"},
Capabilities: []string{"cache.read", "cache.write"},
Capabilities: []string{capabilityCacheRead, capabilityCacheWrite},
},
},
{
Expand All @@ -59,7 +59,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
want: meResponse{
ID: "anonymous",
Scopes: []string{"read", "write", "admin"},
Capabilities: []string{"cache.read", "cache.write", "cache.admin"},
Capabilities: []string{capabilityCacheRead, capabilityCacheWrite, capabilityCacheAdmin},
},
},
}
Expand Down Expand Up @@ -172,3 +172,144 @@ func TestHandleMe_MissingLocals(t *testing.T) {
t.Fatalf("status: got %d, want 500", resp.StatusCode)
}
}

// TestHandleCan_AllowedAndDenied pins the canonical happy paths:
// an identity holding a capability gets allowed=true; the same
// identity probed against a capability it lacks gets allowed=false.
// Both produce 200 — "allowed=false" is a successful probe, not
// an authorization failure.
func TestHandleCan_AllowedAndDenied(t *testing.T) {
t.Parallel()

readWriteIdentity := httpauth.Identity{
ID: "ops-rw",
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite},
}

tests := []struct {
name string
capability string
wantAllowed bool
}{
{"read holder asks for read", capabilityCacheRead, true},
{"rw holder asks for write", capabilityCacheWrite, true},
{"rw holder asks for admin", capabilityCacheAdmin, false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

body := callCanWithIdentity(t, readWriteIdentity, tc.capability)
if body.Capability != tc.capability {
t.Errorf("capability echo: got %q, want %q", body.Capability, tc.capability)
}

if body.Allowed != tc.wantAllowed {
t.Errorf("allowed: got %v, want %v", body.Allowed, tc.wantAllowed)
}
})
}
}

// TestHandleCan_MissingCapabilityParam pins the input-validation
// posture: a request without the `capability` query param fails
// 400 with the canonical error envelope. We don't silently
// default to allowed=false — that would let typos pass as
// "you can't do it" when the real issue is the missing argument.
func TestHandleCan_MissingCapabilityParam(t *testing.T) {
t.Parallel()

app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals(httpauth.IdentityKey, httpauth.Identity{ID: "x", Scopes: []httpauth.Scope{httpauth.ScopeRead}})

return c.Next()
})
app.Get("/v1/me/can", handleCan)

req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/me/can", strings.NewReader(""))

resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}

defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400", resp.StatusCode)
}
}

// TestHandleCan_UnknownCapability pins that unrecognized
// capability strings fail 400, not silently allowed=false. A
// typo like `cache.reaad` should surface as a client error so
// the caller fixes their code rather than shipping a broken
// authz check.
func TestHandleCan_UnknownCapability(t *testing.T) {
t.Parallel()

app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals(httpauth.IdentityKey, httpauth.Identity{
ID: "x",
Scopes: []httpauth.Scope{httpauth.ScopeRead, httpauth.ScopeWrite, httpauth.ScopeAdmin},
})

return c.Next()
})
app.Get("/v1/me/can", handleCan)

req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/me/can?capability=cache.reaad", strings.NewReader(""))

resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}

defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400 (unknown capability)", resp.StatusCode)
}
}

// callCanWithIdentity drives /v1/me/can with a pre-populated
// IdentityKey local. Returns the decoded canResponse; failed
// status / decode trips the test fatally.
func callCanWithIdentity(t *testing.T, identity httpauth.Identity, capability string) canResponse {
t.Helper()

app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals(httpauth.IdentityKey, identity)

return c.Next()
})
app.Get("/v1/me/can", handleCan)

req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/me/can?capability="+capability, strings.NewReader(""))

resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}

defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
t.Fatalf("status: got %d, want 200", resp.StatusCode)
}

var got canResponse

err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("decode body: %v", err)
}

return got
}
50 changes: 50 additions & 0 deletions cmd/hypercache-server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,43 @@ paths:
$ref: "#/components/schemas/IdentityResponse"
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/me/can:
get:
operationId: canPerform
tags: [ meta ]
summary: Per-capability authorization probe.
description: |
Returns whether the resolved identity holds the requested
capability. Cheaper than the speculative-write pattern
(try the action, catch the 403) and stable across future
scope-to-capability refactors — clients should key off the
capability string, not the internal scope shape.

Unknown capability values return 400 BAD_REQUEST so typos
don't silently answer "not allowed" when the real issue
is the caller's spelling.

Requires the `read` scope — same threshold as `/v1/me`.
parameters:
- in: query
name: capability
required: true
schema:
type: string
enum: [ cache.read, cache.write, cache.admin ]
description: |
The capability string to probe. Must be one of the
three values in the closed `cache.*` namespace.
responses:
"200":
description: Probe result.
content:
application/json:
schema:
$ref: "#/components/schemas/CanResponse"
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/cache/batch/get:
post:
operationId: batchGet
Expand Down Expand Up @@ -522,6 +559,19 @@ components:
items:
type: string

CanResponse:
type: object
required: [ capability, allowed ]
properties:
capability:
type: string
description: Echoes the queried capability string.
allowed:
type: boolean
description: |
True when the resolved identity holds the requested
capability; false otherwise.

ItemEnvelope:
type: object
required: [ key, value, value_encoding, version, node, owners ]
Expand Down
1 change: 1 addition & 0 deletions cmd/hypercache-server/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func declaredMethodsForPath() map[string]map[string]struct{} {
"/v1/cache/:key": {fiber.MethodPut: {}, fiber.MethodGet: {}, fiber.MethodHead: {}, fiber.MethodDelete: {}},
"/v1/owners/:key": {fiber.MethodGet: {}},
"/v1/me": {fiber.MethodGet: {}},
"/v1/me/can": {fiber.MethodGet: {}},
"/v1/cache/batch/get": {fiber.MethodPost: {}},
"/v1/cache/batch/put": {fiber.MethodPost: {}},
"/v1/cache/batch/delete": {fiber.MethodPost: {}},
Expand Down
1 change: 1 addition & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ words:
- pyenv
- pygments
- pymdownx
- reaad
- recvcheck
- rediscluster
- Redocly
Expand Down
Loading
Loading