Skip to content

Commit d260073

Browse files
committed
Harden hosted read cache scoping and invalidation
1 parent 61a745d commit d260073

12 files changed

Lines changed: 1224 additions & 99 deletions

cmd/wl/cmd_serve.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ import (
3131
"github.com/spf13/cobra"
3232
)
3333

34+
const (
35+
hostedPublicUpstreamOrg = "wasteland"
36+
hostedPublicUpstreamDB = "wl-commons"
37+
hostedPublicUpstream = hostedPublicUpstreamOrg + "/" + hostedPublicUpstreamDB
38+
)
39+
3440
var (
3541
queryScoreboardDetailEntries = commons.QueryScoreboardDetail
3642
queryScoreboardDumpData = commons.QueryScoreboardDump
@@ -44,7 +50,7 @@ var (
4450
return backend.NewRemoteDB(token, upstreamOrg, upstreamDB, forkOrg, forkDB, mode)
4551
}
4652
newHostedPublicDB = func() commons.DB {
47-
return backend.NewRemoteDB("", "hop", "wl-commons", "hop", "wl-commons", "")
53+
return backend.NewRemoteDB("", hostedPublicUpstreamOrg, hostedPublicUpstreamDB, hostedPublicUpstreamOrg, hostedPublicUpstreamDB, "")
4854
}
4955
newSelfHostedAPIServer = func(client *sdk.Client) selfHostedAPIServer {
5056
return api.New(client)
@@ -244,6 +250,7 @@ func runServe(cmd *cobra.Command, stdout, stderr io.Writer) error {
244250
client := sdk.New(sdk.ClientConfig{
245251
DB: db,
246252
RigHandle: cfg.RigHandle,
253+
Upstream: cfg.Upstream,
247254
Mode: cfg.ResolveMode(),
248255
Signing: cfg.Signing,
249256
HopURI: cfg.HopURI,
@@ -371,7 +378,7 @@ func runServeHosted(cmd *cobra.Command, stdout, _ io.Writer) error {
371378
// Build the API server with hosted workspace resolution.
372379
apiServer := api.NewHostedWorkspace(hosted.NewClientFunc(), hosted.NewWorkspaceFunc())
373380

374-
// Public read-only RemoteDB against hop/wl-commons (no token needed).
381+
// Public read-only RemoteDB against the canonical hosted upstream (no token needed).
375382
publicDB := newHostedPublicDB()
376383

377384
// Scoreboard cache.
@@ -392,12 +399,13 @@ func runServeHosted(cmd *cobra.Command, stdout, _ io.Writer) error {
392399

393400
// Anonymous client for unauthenticated public reads (browse, detail, etc.).
394401
// Uses a background-refreshing cache so no user request blocks on DoltHub.
395-
pendingCache := newPendingItemsCache("hop", "wl-commons", 2*time.Minute)
402+
pendingCache := newPendingItemsCache(hostedPublicUpstreamOrg, hostedPublicUpstreamDB, 2*time.Minute)
396403
defer pendingCache.Stop()
397404
anonClient := sdk.New(sdk.ClientConfig{
398405
DB: publicDB,
406+
Upstream: hostedPublicUpstream,
399407
Mode: federation.ModePR,
400-
LoadPendingDetail: pendingDetailLoader("hop", "wl-commons", federation.ModePR, ""),
408+
LoadPendingDetail: pendingDetailLoader(hostedPublicUpstreamOrg, hostedPublicUpstreamDB, federation.ModePR, ""),
401409
ListPendingItems: pendingCache.Get,
402410
})
403411
apiServer.SetPublicClient(anonClient)

cmd/wl/cmd_serve_additional_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ func TestResolvePort_InvalidEnvFallsBackToFlag(t *testing.T) {
6060
}
6161
}
6262

63+
func TestHostedPublicUpstream_IsCanonical(t *testing.T) {
64+
if hostedPublicUpstreamOrg != "wasteland" {
65+
t.Fatalf("hostedPublicUpstreamOrg = %q", hostedPublicUpstreamOrg)
66+
}
67+
if hostedPublicUpstreamDB != "wl-commons" {
68+
t.Fatalf("hostedPublicUpstreamDB = %q", hostedPublicUpstreamDB)
69+
}
70+
if hostedPublicUpstream != "wasteland/wl-commons" {
71+
t.Fatalf("hostedPublicUpstream = %q", hostedPublicUpstream)
72+
}
73+
}
74+
6375
func TestPendingItemsCache_GetAndStop(t *testing.T) {
6476
cache := &pendingItemsCache{
6577
cached: map[string][]sdk.PendingItem{

cmd/wl/sdk_factory.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ var newSDKClient = func(cfg *federation.Config, noPush bool) (*sdk.Client, error
1515
return sdk.New(sdk.ClientConfig{
1616
DB: db,
1717
RigHandle: cfg.RigHandle,
18+
Upstream: cfg.Upstream,
1819
Mode: cfg.ResolveMode(),
1920
Signing: cfg.Signing,
2021
HopURI: cfg.HopURI,

internal/api/cache_scope.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/gastownhall/wasteland/internal/sdk"
10+
)
11+
12+
const (
13+
localCacheUpstream = "local"
14+
publicCacheViewer = "anon"
15+
)
16+
17+
type readIdentityContextKey string
18+
19+
type readCacheScope struct {
20+
upstream string
21+
viewer string
22+
mode string
23+
impersonate string
24+
cacheable bool
25+
}
26+
27+
// ResolvedReadIdentity is the canonical hosted read identity used for cache
28+
// scoping after middleware or public-read fallback resolves the active upstream
29+
// and viewer bucket.
30+
type ResolvedReadIdentity struct {
31+
Upstream string
32+
Viewer string
33+
Public bool
34+
}
35+
36+
const resolvedReadIdentityContextKey readIdentityContextKey = "resolved-read-identity"
37+
38+
// WithResolvedReadIdentity annotates a request context with the canonical
39+
// hosted upstream and viewer identity for downstream cache scoping.
40+
func WithResolvedReadIdentity(ctx context.Context, identity ResolvedReadIdentity) context.Context {
41+
identity.Upstream = canonicalHostedCacheUpstream(identity.Upstream)
42+
identity.Viewer = strings.TrimSpace(identity.Viewer)
43+
return context.WithValue(ctx, resolvedReadIdentityContextKey, identity)
44+
}
45+
46+
// ResolvedReadIdentityFromContext returns the hosted read identity when one has
47+
// been established by middleware or the public-read fallback.
48+
func ResolvedReadIdentityFromContext(ctx context.Context) (ResolvedReadIdentity, bool) {
49+
identity, ok := ctx.Value(resolvedReadIdentityContextKey).(ResolvedReadIdentity)
50+
return identity, ok
51+
}
52+
53+
func (s *Server) readCacheScope(r *http.Request, client *sdk.Client) readCacheScope {
54+
scope := readCacheScope{
55+
mode: strings.TrimSpace(client.Mode()),
56+
impersonate: strings.TrimSpace(r.Header.Get("X-Impersonate")),
57+
cacheable: true,
58+
}
59+
60+
if !s.hosted {
61+
scope.upstream = strings.TrimSpace(client.Upstream())
62+
scope.viewer = strings.TrimSpace(client.RigHandle())
63+
return normalizeReadCacheScope(scope, false)
64+
}
65+
66+
if identity, ok := ResolvedReadIdentityFromContext(r.Context()); ok {
67+
if identity.Public {
68+
if identity.Upstream == "" {
69+
scope.cacheable = false
70+
return normalizeReadCacheScope(scope, true)
71+
}
72+
scope.upstream = identity.Upstream
73+
return normalizeReadCacheScope(scope, true)
74+
}
75+
if identity.Upstream == "" || identity.Viewer == "" {
76+
scope.cacheable = false
77+
return normalizeReadCacheScope(scope, true)
78+
}
79+
scope.upstream = identity.Upstream
80+
scope.viewer = identity.Viewer
81+
return normalizeReadCacheScope(scope, true)
82+
}
83+
84+
// Hosted reads without canonical identity should not populate or reuse
85+
// shared cache entries. This fails closed on miswired handlers instead of
86+
// trusting request-derived client state.
87+
scope.cacheable = false
88+
return normalizeReadCacheScope(scope, true)
89+
}
90+
91+
func normalizeReadCacheScope(scope readCacheScope, hosted bool) readCacheScope {
92+
if scope.upstream == "" {
93+
scope.upstream = localCacheUpstream
94+
}
95+
96+
if hosted {
97+
scope.viewer = canonicalHostedCacheViewer(scope.viewer)
98+
}
99+
100+
return scope
101+
}
102+
103+
func browseCacheKey(scope readCacheScope, r *http.Request) string {
104+
return strings.Join([]string{
105+
"browse",
106+
cacheKeyPart(scope.upstream),
107+
cacheKeyPart(scope.viewer),
108+
cacheKeyPart(scope.mode),
109+
cacheKeyPart(scope.impersonate),
110+
cacheKeyPart(canonicalBrowseKey(r)),
111+
}, ":")
112+
}
113+
114+
func browseCachePrefix(upstream string) string {
115+
return strings.Join([]string{"browse", cacheKeyPart(upstream)}, ":") + ":"
116+
}
117+
118+
func detailCacheKey(scope readCacheScope, wantedID string) string {
119+
return strings.Join([]string{
120+
"detail",
121+
cacheKeyPart(scope.upstream),
122+
cacheKeyPart(scope.viewer),
123+
cacheKeyPart(scope.mode),
124+
cacheKeyPart(scope.impersonate),
125+
cacheKeyPart(wantedID),
126+
}, ":")
127+
}
128+
129+
func detailCachePrefix(upstream string) string {
130+
return strings.Join([]string{"detail", cacheKeyPart(upstream)}, ":") + ":"
131+
}
132+
133+
func detailCacheSuffix(wantedID string) string {
134+
return ":" + cacheKeyPart(wantedID)
135+
}
136+
137+
func cacheKeyPart(value string) string {
138+
return url.QueryEscape(strings.TrimSpace(value))
139+
}
140+
141+
func canonicalHostedCacheUpstream(upstream string) string {
142+
upstream = strings.TrimSpace(upstream)
143+
switch upstream {
144+
case "hop/wl-commons":
145+
return "wasteland/wl-commons"
146+
default:
147+
return upstream
148+
}
149+
}
150+
151+
func canonicalHostedCacheViewer(viewer string) string {
152+
viewer = strings.TrimSpace(viewer)
153+
if viewer == "" {
154+
return publicCacheViewer
155+
}
156+
return "user:" + viewer
157+
}

0 commit comments

Comments
 (0)