Skip to content

Commit e053f97

Browse files
authored
fix: guard nil debug request in bulk check (#3174)
1 parent 30a250c commit e053f97

5 files changed

Lines changed: 273 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
99
- Updated the Prometheus buckets for `grpc_server_handling_seconds` and `spicedb_datastore_query_latency` to be able to correlate them (https://github.com/authzed/spicedb/pull/3188)
1010

1111
### Fixed
12+
- Fixed a nil pointer dereference panic in `CheckBulkPermissions` that could occur under concurrent load when a tracing-enabled check shared a singleflight dispatch with a non-tracing bulk check. Debug-enabled checks are no longer singleflighted together with non-debug checks. (https://github.com/authzed/spicedb/pull/3174)
1213
- When SpiceDB loses a connection to a CockroachDB node, every read happening in the server blocks for a short period of time (https://github.com/authzed/spicedb/pull/3181)
1314
- LSP: hover and go-to-definition now resolve identifiers on the right-hand side of arrow expressions (`->`, `.any(...)`, `.all(...)`) (https://github.com/authzed/spicedb/pull/3157)
1415
- The `in_cidr` caveat now matches IPv4-mapped IPv6 addresses (e.g. `::ffff:10.1.2.3`) against IPv4 CIDRs, the same as the dotted form (https://github.com/authzed/spicedb/pull/3184)

internal/dispatch/singleflight/singleflight.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ func (d *Dispatcher) DispatchCheck(ctx context.Context, req *v1.DispatchCheckReq
8080
return d.delegate.DispatchCheck(ctx, req)
8181
}
8282

83+
// Debug-enabled checks must not share a flight with non-debug checks: the
84+
// dispatch key does not depend on req.Debug, so an identical concurrent
85+
// request with a different debug level would otherwise collapse into the
86+
// same flight. A non-debug leader would deny a tracing follower its trace,
87+
// and a debug leader would leak its trace to a non-debug follower (the
88+
// latter has panicked CheckBulkPermissions, see #3159). Dispatch directly
89+
// so the response carries exactly the debug info this caller asked for.
90+
if req.Debug != v1.DispatchCheckRequest_NO_DEBUG {
91+
singleFlightCount.WithLabelValues("DispatchCheck", "debug").Inc()
92+
return d.delegate.DispatchCheck(ctx, req)
93+
}
94+
8395
sharedResp, isShared, err := d.checkGroup.Do(ctx, keyString, func(innerCtx context.Context) (*v1.DispatchCheckResponse, error) {
8496
return d.delegate.DispatchCheck(innerCtx, req)
8597
})

internal/dispatch/singleflight/singleflight_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,82 @@ func TestSingleFlightDispatcherDetectsLoop(t *testing.T) {
130130
assertCounterWithLabel(t, reg, 2, "spicedb_dispatch_single_flight_total", "loop")
131131
}
132132

133+
// TestSingleFlightDispatcherDoesNotShareDebugChecks ensures debug-enabled checks
134+
// are dispatched directly rather than sharing a flight with otherwise-identical
135+
// non-debug checks. The dispatch key ignores req.Debug, so sharing would leak the
136+
// leader's debug info to a non-debug follower (the cause of #3159) or deny a debug
137+
// follower its trace.
138+
func TestSingleFlightDispatcherDoesNotShareDebugChecks(t *testing.T) {
139+
singleFlightCount = prometheus.NewCounterVec(singleFlightCountConfig, []string{"method", "shared"})
140+
reg := registerMetricInGatherer(singleFlightCount)
141+
142+
// The delegate returns debug info only when the request asked for it, so the
143+
// response carried back to each caller reveals whether a flight was shared
144+
// across debug levels.
145+
delegate := &debugAwareDispatcher{delay: 100 * time.Millisecond}
146+
disp := New(delegate, &keys.DirectKeyHandler{})
147+
148+
req := &v1.DispatchCheckRequest{
149+
ResourceRelation: tuple.RR("document", "view").ToCoreRR(),
150+
ResourceIds: []string{"foo", "bar"},
151+
Subject: tuple.ONRStringToCore("user", "tom", "..."),
152+
Metadata: &v1.ResolverMeta{
153+
AtRevision: "1234",
154+
TraversalBloom: v1.MustNewTraversalBloomFilter(defaultBloomFilterSize),
155+
SchemaHash: []byte(datalayer.NoSchemaHashForTesting),
156+
},
157+
}
158+
159+
var mu sync.Mutex
160+
var nonDebugResponses, debugResponses []*v1.DispatchCheckResponse
161+
162+
wg := sync.WaitGroup{}
163+
wg.Add(4)
164+
// Two non-debug checks should share a single flight.
165+
for range 2 {
166+
go func() {
167+
resp, _ := disp.DispatchCheck(t.Context(), req.CloneVT())
168+
mu.Lock()
169+
nonDebugResponses = append(nonDebugResponses, resp)
170+
mu.Unlock()
171+
wg.Done()
172+
}()
173+
}
174+
// Two debug-enabled checks, identical key, must each dispatch directly. Cover
175+
// both debug levels: WithTracing maps to basic debugging, while trace debugging
176+
// is the deeper variant; the bypass keys on != NO_DEBUG so both must apply.
177+
for _, debug := range []v1.DispatchCheckRequest_DebugSetting{
178+
v1.DispatchCheckRequest_ENABLE_BASIC_DEBUGGING,
179+
v1.DispatchCheckRequest_ENABLE_TRACE_DEBUGGING,
180+
} {
181+
go func() {
182+
debugReq := req.CloneVT()
183+
debugReq.Debug = debug
184+
resp, _ := disp.DispatchCheck(t.Context(), debugReq)
185+
mu.Lock()
186+
debugResponses = append(debugResponses, resp)
187+
mu.Unlock()
188+
wg.Done()
189+
}()
190+
}
191+
192+
wg.Wait()
193+
194+
// One dispatch for the shared non-debug pair, plus one per debug check.
195+
require.Equal(t, uint64(3), delegate.called.Load(), "should have dispatched 3 calls but did %d", delegate.called.Load())
196+
// Two label series: shared=true (the shared non-debug pair) and shared=debug.
197+
assertCounterWithLabel(t, reg, 2, "spicedb_dispatch_single_flight_total", "debug")
198+
199+
// A non-debug caller must never receive debug info, even when racing an
200+
// identical debug check; a debug caller must always receive its own.
201+
for _, resp := range nonDebugResponses {
202+
require.Nil(t, resp.GetMetadata().GetDebugInfo(), "non-debug caller received leaked debug info")
203+
}
204+
for _, resp := range debugResponses {
205+
require.NotNil(t, resp.GetMetadata().GetDebugInfo(), "debug caller lost its trace")
206+
}
207+
}
208+
133209
// this test makes sure that bloom filter information is carried from dispatcher to dispatcher
134210
func TestSingleFlightDispatcherDetectsLoopThroughDelegate(t *testing.T) {
135211
singleFlightCount = prometheus.NewCounterVec(singleFlightCountConfig, []string{"method", "shared"})
@@ -515,6 +591,26 @@ func bloomFilterForRequest(t *testing.T, keyHandler *keys.DirectKeyHandler, req
515591
return binaryBloom
516592
}
517593

594+
// debugAwareDispatcher returns debug info only when the request asked for it,
595+
// so callers can assert that a non-debug caller never receives debug info from a
596+
// flight shared with a debug caller. Only DispatchCheck is exercised.
597+
type debugAwareDispatcher struct {
598+
dispatch.Dispatcher
599+
delay time.Duration
600+
called atomic.Uint64
601+
}
602+
603+
func (d *debugAwareDispatcher) DispatchCheck(_ context.Context, req *v1.DispatchCheckRequest) (*v1.DispatchCheckResponse, error) {
604+
time.Sleep(d.delay)
605+
d.called.Add(1)
606+
607+
resp := &v1.DispatchCheckResponse{Metadata: &v1.ResponseMeta{DispatchCount: 1}}
608+
if req.Debug != v1.DispatchCheckRequest_NO_DEBUG {
609+
resp.Metadata.DebugInfo = &v1.DebugInformation{Check: &v1.CheckDebugTrace{}}
610+
}
611+
return resp, nil
612+
}
613+
518614
type mockDispatcher struct {
519615
f func()
520616
}

internal/services/v1/bulkcheck.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ func (bc *bulkChecker) checkBulkPermissions(ctx context.Context, req *v1.CheckBu
186186
// Find the debug info that matches the resource ID.
187187
var debugInfo *dispatchv1.DebugInformation
188188
for _, di := range debugInfos {
189+
if di.Check == nil || di.Check.Request == nil {
190+
continue
191+
}
189192
if slices.Contains(di.Check.Request.ResourceIds, resourceID) {
190193
debugInfo = di
191194
break
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package v1
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
10+
11+
"github.com/authzed/spicedb/internal/datastore/dsfortesting"
12+
"github.com/authzed/spicedb/internal/datastore/memdb"
13+
"github.com/authzed/spicedb/internal/dispatch"
14+
"github.com/authzed/spicedb/internal/testfixtures"
15+
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
16+
"github.com/authzed/spicedb/pkg/datalayer"
17+
"github.com/authzed/spicedb/pkg/middleware/consistency"
18+
dispatchv1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
19+
)
20+
21+
// debugInfoDispatcher is a dispatcher that returns a successful check result whose
22+
// metadata carries debug information with a nil Check.Request. This mirrors the
23+
// shape produced by combineResponseMetadata for combined trace nodes, which can
24+
// reach a non-tracing CheckBulkPermissions caller when an identical tracing-enabled
25+
// request shares its dispatch via singleflight.
26+
type debugInfoDispatcher struct {
27+
dispatch.Dispatcher
28+
}
29+
30+
func (d debugInfoDispatcher) DispatchCheck(_ context.Context, req *dispatchv1.DispatchCheckRequest) (*dispatchv1.DispatchCheckResponse, error) {
31+
return &dispatchv1.DispatchCheckResponse{
32+
ResultsByResourceId: memberResults(req.ResourceIds),
33+
Metadata: &dispatchv1.ResponseMeta{
34+
DispatchCount: 1,
35+
DebugInfo: &dispatchv1.DebugInformation{
36+
Check: &dispatchv1.CheckDebugTrace{
37+
SubProblems: []*dispatchv1.CheckDebugTrace{{IsCachedResult: true}},
38+
},
39+
},
40+
},
41+
}, nil
42+
}
43+
44+
// matchingDebugInfoDispatcher returns a successful check result whose debug
45+
// information carries a Check.Request matching the dispatched resource IDs. This
46+
// exercises the normal tracing path where CheckBulkPermissions synthesizes a
47+
// debug trace for each resource.
48+
type matchingDebugInfoDispatcher struct {
49+
dispatch.Dispatcher
50+
}
51+
52+
func (d matchingDebugInfoDispatcher) DispatchCheck(_ context.Context, req *dispatchv1.DispatchCheckRequest) (*dispatchv1.DispatchCheckResponse, error) {
53+
return &dispatchv1.DispatchCheckResponse{
54+
ResultsByResourceId: memberResults(req.ResourceIds),
55+
Metadata: &dispatchv1.ResponseMeta{
56+
DispatchCount: 1,
57+
DebugInfo: &dispatchv1.DebugInformation{
58+
Check: &dispatchv1.CheckDebugTrace{
59+
Request: &dispatchv1.DispatchCheckRequest{
60+
ResourceRelation: req.ResourceRelation,
61+
ResourceIds: req.ResourceIds,
62+
Subject: req.Subject,
63+
ResultsSetting: req.ResultsSetting,
64+
Debug: req.Debug,
65+
},
66+
Results: memberResults(req.ResourceIds),
67+
},
68+
},
69+
},
70+
}, nil
71+
}
72+
73+
func memberResults(resourceIDs []string) map[string]*dispatchv1.ResourceCheckResult {
74+
results := make(map[string]*dispatchv1.ResourceCheckResult, len(resourceIDs))
75+
for _, resourceID := range resourceIDs {
76+
results[resourceID] = &dispatchv1.ResourceCheckResult{
77+
Membership: dispatchv1.ResourceCheckResult_MEMBER,
78+
}
79+
}
80+
return results
81+
}
82+
83+
func runBulkCheck(t *testing.T, d dispatch.Dispatcher, withTracing bool) *v1.CheckBulkPermissionsResponse {
84+
t.Helper()
85+
require := require.New(t)
86+
87+
uninitialized, err := dsfortesting.NewMemDBDatastoreForTesting(t, 0, 0, memdb.DisableGC)
88+
require.NoError(err)
89+
90+
ds, _ := testfixtures.StandardDatastoreWithData(t, uninitialized)
91+
dl := datalayer.NewDataLayer(ds)
92+
93+
ctx := datalayer.ContextWithDataLayer(consistency.ContextWithHandle(t.Context()), dl)
94+
95+
req := &v1.CheckBulkPermissionsRequest{
96+
Items: []*v1.CheckBulkPermissionsRequestItem{
97+
{
98+
Resource: &v1.ObjectReference{
99+
ObjectType: "document",
100+
ObjectId: "masterplan",
101+
},
102+
Permission: "view",
103+
Subject: &v1.SubjectReference{
104+
Object: &v1.ObjectReference{
105+
ObjectType: "user",
106+
ObjectId: "eng_lead",
107+
},
108+
},
109+
},
110+
},
111+
WithTracing: withTracing,
112+
}
113+
114+
require.NoError(consistency.AddRevisionToContext(ctx, req, dl, "", consistency.TreatMismatchingTokensAsError))
115+
116+
bc := &bulkChecker{
117+
maxAPIDepth: 50,
118+
maxCaveatContextSize: 4096,
119+
maxConcurrency: 1,
120+
caveatTypeSet: caveattypes.Default.TypeSet,
121+
dispatch: d,
122+
dispatchChunkSize: 100,
123+
}
124+
125+
resp, err := bc.checkBulkPermissions(ctx, req)
126+
require.NoError(err)
127+
return resp
128+
}
129+
130+
// TestCheckBulkPermissionsWithNilDebugRequest ensures that a debug information entry
131+
// with a nil Check.Request does not panic CheckBulkPermissions when tracing was not
132+
// requested. See https://github.com/authzed/spicedb/issues/3159.
133+
func TestCheckBulkPermissionsWithNilDebugRequest(t *testing.T) {
134+
require := require.New(t)
135+
136+
resp := runBulkCheck(t, debugInfoDispatcher{}, false)
137+
require.Len(resp.Pairs, 1)
138+
139+
item := resp.Pairs[0].GetItem()
140+
require.NotNil(item)
141+
require.Equal(v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, item.Permissionship)
142+
require.Nil(item.DebugTrace)
143+
}
144+
145+
// TestCheckBulkPermissionsWithMatchingDebugRequest ensures that a debug information
146+
// entry whose Check.Request matches the resource is surfaced as a debug trace on the
147+
// result, exercising the positive branch of the resource-ID matching loop.
148+
func TestCheckBulkPermissionsWithMatchingDebugRequest(t *testing.T) {
149+
require := require.New(t)
150+
151+
resp := runBulkCheck(t, matchingDebugInfoDispatcher{}, true)
152+
require.Len(resp.Pairs, 1)
153+
154+
item := resp.Pairs[0].GetItem()
155+
require.NotNil(item)
156+
require.Equal(v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, item.Permissionship)
157+
require.NotNil(item.DebugTrace)
158+
require.NotNil(item.DebugTrace.Check)
159+
require.Equal("masterplan", item.DebugTrace.Check.Resource.GetObjectId())
160+
require.Equal("view", item.DebugTrace.Check.Permission)
161+
}

0 commit comments

Comments
 (0)