Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit f369569

Browse files
committed
Filter out campaign.status.errors based on repo permissions
1 parent b333df7 commit f369569

File tree

5 files changed

+243
-3
lines changed

5 files changed

+243
-3
lines changed

enterprise/internal/campaigns/resolvers/campaigns.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"time"
99

1010
"github.com/graph-gophers/graphql-go"
11+
"github.com/sourcegraph/sourcegraph/cmd/frontend/db"
1112
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend"
1213
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend/graphqlutil"
1314
ee "github.com/sourcegraph/sourcegraph/enterprise/internal/campaigns"
15+
"github.com/sourcegraph/sourcegraph/internal/api"
1416
"github.com/sourcegraph/sourcegraph/internal/campaigns"
1517
)
1618

@@ -322,9 +324,78 @@ func (r *campaignResolver) Status(ctx context.Context) (graphqlbackend.Backgroun
322324
return nil, err
323325
}
324326

327+
if !canAdmin {
328+
// If the user doesn't have admin permissions for this campaign, we
329+
// don't need to filter out specific errors, but can simply exclude
330+
// _all_ errors.
331+
return r.store.GetCampaignStatus(ctx, ee.GetCampaignStatusOpts{
332+
ID: r.Campaign.ID,
333+
ExcludeErrors: true,
334+
})
335+
}
336+
337+
// TODO: Wow, this is horrible. We're loading way too many patches.
338+
// What we actually want is this:
339+
340+
// SELECT repo.id
341+
// FROM patches
342+
// JOIN repo ON repo.id = patches.repo_id
343+
// JOIN changeset_jobs ON changeset_jobs.patch_id = patches.id
344+
// WHERE patches.patch_set_id = patch.patch_set_id;
345+
346+
// Or we do:
347+
//
348+
// SELECT repo.id
349+
// FROM changeset_jobs
350+
// JOIN patches ON patches.id = changeset_jobs.patch_id
351+
// JOIN repo ON patches.repo_id = repo.id
352+
// WHERE changeset_jobs.campaign_id = <campaign_id>
353+
354+
// And then we put those repo IDs through `db.Repo.GetByIDs`, which
355+
// uses the authz filter what we then get back are the repos we have access
356+
// to.
357+
358+
// And then we need to filter out the error messages of changeset_jobs that
359+
// are attached to patches that are attached to filtered out repositories in the
360+
// `GetCampaignStatus` query below.
361+
362+
patches, _, err := r.store.ListPatches(ctx, ee.ListPatchesOpts{
363+
PatchSetID: r.Campaign.PatchSetID,
364+
Limit: -1,
365+
})
366+
if err != nil {
367+
return nil, err
368+
}
369+
370+
repoIDs := make([]api.RepoID, 0, len(patches))
371+
for _, p := range patches {
372+
repoIDs = append(repoIDs, p.RepoID)
373+
}
374+
375+
// 🚨 SECURITY: We use db.Repos.GetByIDs to filter out repositories the
376+
// user doesn't have access to.
377+
accessibleRepos, err := db.Repos.GetByIDs(ctx, repoIDs...)
378+
if err != nil {
379+
return nil, err
380+
}
381+
382+
accessibleRepoIDs := make(map[api.RepoID]struct{}, len(accessibleRepos))
383+
for _, r := range accessibleRepos {
384+
accessibleRepoIDs[r.ID] = struct{}{}
385+
}
386+
387+
// We now check which repositories in `repoIDs` are not in `accessibleRepoIDs`.
388+
// We have to filter the error messages associated with those out.
389+
excludedRepos := make([]api.RepoID, 0, len(accessibleRepoIDs))
390+
for _, id := range repoIDs {
391+
if _, ok := accessibleRepoIDs[id]; !ok {
392+
excludedRepos = append(excludedRepos, id)
393+
}
394+
}
395+
325396
return r.store.GetCampaignStatus(ctx, ee.GetCampaignStatusOpts{
326-
ID: r.Campaign.ID,
327-
ExcludeErrors: !canAdmin,
397+
ID: r.Campaign.ID,
398+
ExcludeErrorsInRepos: excludedRepos,
328399
})
329400
}
330401

enterprise/internal/campaigns/resolvers/changesets.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func (r *changesetsConnectionResolver) PageInfo(ctx context.Context) (*graphqlut
8585
if err != nil {
8686
return nil, err
8787
}
88+
8889
return graphqlutil.HasNextPage(next != 0), nil
8990
}
9091

enterprise/internal/campaigns/resolvers/permissions_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"fmt"
77
"testing"
8+
"time"
89

910
"github.com/google/go-cmp/cmp"
1011
"github.com/sourcegraph/sourcegraph/cmd/frontend/authz"
@@ -30,6 +31,8 @@ func TestRepositoryPermissions(t *testing.T) {
3031
t.Skip()
3132
}
3233

34+
now := time.Now().UTC().Truncate(time.Microsecond)
35+
3336
// We need to enable read access so that non-site-admin users can access
3437
// the API and we can check for their admin rights.
3538
// This can be removed once we enable campaigns for all users and only
@@ -129,6 +132,24 @@ func TestRepositoryPermissions(t *testing.T) {
129132
t.Fatal(err)
130133
}
131134

135+
// Create 2 failed ChangesetJobs for the patchess to produce error messages
136+
// on the campaign.
137+
changesetJobs := make([]*campaigns.ChangesetJob, 0, 2)
138+
for _, p := range patches {
139+
job := &campaigns.ChangesetJob{
140+
CampaignID: campaign.ID,
141+
PatchID: p.ID,
142+
Error: fmt.Sprintf("error patch %d", p.ID),
143+
StartedAt: now,
144+
FinishedAt: now,
145+
}
146+
if err := store.CreateChangesetJob(ctx, job); err != nil {
147+
t.Fatal(err)
148+
}
149+
150+
changesetJobs = append(changesetJobs, job)
151+
}
152+
132153
// TODO: Do we also need failed ChangesetJobs to check for hidden errors
133154

134155
var response struct{ Node apitest.Campaign }
@@ -143,6 +164,14 @@ func TestRepositoryPermissions(t *testing.T) {
143164
t.Fatalf("campaign id is wrong. have %q, want %q", have, want)
144165
}
145166

167+
wantErrors := []string{
168+
fmt.Sprintf("error patch %d", patches[0].ID),
169+
fmt.Sprintf("error patch %d", patches[1].ID),
170+
}
171+
if diff := cmp.Diff(response.Node.Status.Errors, wantErrors); diff != "" {
172+
t.Fatalf("unexpected status errors (-want +got):\n%s", diff)
173+
}
174+
146175
changesetTypes := map[string]int{}
147176
for _, c := range response.Node.Changesets.Nodes {
148177
changesetTypes[c.Typename]++
@@ -162,10 +191,12 @@ func TestRepositoryPermissions(t *testing.T) {
162191
}
163192

164193
// Now we add the authzFilter and filter out 2 repositories
194+
165195
filteredRepoIDs := map[api.RepoID]bool{
166196
patches[0].RepoID: true,
167197
changesets[0].RepoID: true,
168198
}
199+
169200
db.MockAuthzFilter = func(ctx context.Context, repos []*types.Repo, p authz.Perms) ([]*types.Repo, error) {
170201
var filtered []*types.Repo
171202
for _, r := range repos {
@@ -185,6 +216,14 @@ func TestRepositoryPermissions(t *testing.T) {
185216
t.Fatalf("campaign id is wrong. have %q, want %q", have, want)
186217
}
187218

219+
wantErrors = []string{
220+
// patches[0] is filtered out
221+
fmt.Sprintf("error patch %d", patches[1].ID),
222+
}
223+
if diff := cmp.Diff(response.Node.Status.Errors, wantErrors); diff != "" {
224+
t.Fatalf("unexpected status errors (-want +got):\n%s", diff)
225+
}
226+
188227
changesetTypes = map[string]int{}
189228
for _, c := range response.Node.Changesets.Nodes {
190229
changesetTypes[c.Typename]++
@@ -203,13 +242,23 @@ func TestRepositoryPermissions(t *testing.T) {
203242
if diff := cmp.Diff(wantPatchTypes, patchTypes); diff != "" {
204243
t.Fatalf("unexpected patch types (-want +got):\n%s", diff)
205244
}
245+
246+
// TODO: Test that the diffStat on `patchset` doesn't include the filtered patches diff stats
247+
// TODO: Test that the diffStat on `campaign` doesn't include the filtered changesets diff stats
248+
// TODO: Test that ChangesetByID and PatchByID don't return the filtered out changesets/patches
206249
}
207250

208251
const queryCampaignPermLevels = `
209252
query {
210253
node(id: %q) {
211254
... on Campaign {
212255
id
256+
257+
status {
258+
state
259+
errors
260+
}
261+
213262
changesets(first: 100) {
214263
nodes {
215264
__typename

enterprise/internal/campaigns/store.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,6 +1744,13 @@ type GetCampaignStatusOpts struct {
17441744
// BackgroundProcessStatus returned by GetCampaignStatus won't be
17451745
// populated.
17461746
ExcludeErrors bool
1747+
1748+
// ExcludeErrorsInRepos filters out error messages from ChangesetJobs that
1749+
// are associated with Patches that have the given repository IDs set in
1750+
// `patches.repo_id`.
1751+
// This is used to filter out error messages from repositories the user
1752+
// doesn't have access to.
1753+
ExcludeErrorsInRepos []api.RepoID
17471754
}
17481755

17491756
// GetCampaignStatus gets the campaigns.BackgroundProcessStatus for a Campaign
@@ -1767,6 +1774,19 @@ func getCampaignStatusQuery(opts *GetCampaignStatusOpts) *sqlf.Query {
17671774
errorsPreds = append(errorsPreds, sqlf.Sprintf("FALSE"))
17681775
}
17691776

1777+
if len(opts.ExcludeErrorsInRepos) > 0 {
1778+
ids := make([]*sqlf.Query, 0, len(opts.ExcludeErrorsInRepos))
1779+
1780+
for _, repoID := range opts.ExcludeErrorsInRepos {
1781+
ids = append(ids, sqlf.Sprintf("%s", repoID))
1782+
}
1783+
1784+
joined := sqlf.Join(ids, ",")
1785+
1786+
errorsPreds = append(errorsPreds, sqlf.Sprintf("patches.repo_id NOT IN (%s)", joined))
1787+
errorsPreds = append(errorsPreds, sqlf.Sprintf("error != ''"))
1788+
}
1789+
17701790
if len(errorsPreds) == 0 {
17711791
errorsPreds = append(errorsPreds, sqlf.Sprintf("error != ''"))
17721792
}
@@ -1813,6 +1833,7 @@ SELECT
18131833
COUNT(*) FILTER (WHERE error != '') AS failed,
18141834
array_agg(error) FILTER (WHERE %s) AS errors
18151835
FROM changeset_jobs
1836+
JOIN patches ON patches.id = changeset_jobs.patch_id
18161837
WHERE %s
18171838
LIMIT 1
18181839
`

enterprise/internal/campaigns/store_test.go

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2679,8 +2679,19 @@ func testStoreChangesetJobs(t *testing.T, ctx context.Context, s *Store, _ repos
26792679

26802680
for campaignID, tc := range tests {
26812681
for i, j := range tc.jobs {
2682+
p := &cmpgn.Patch{
2683+
RepoID: api.RepoID(i),
2684+
PatchSetID: int64(campaignID),
2685+
BaseRef: "deadbeef",
2686+
Diff: "foobar",
2687+
}
2688+
2689+
if err := s.CreatePatch(ctx, p); err != nil {
2690+
t.Fatal(err)
2691+
}
2692+
26822693
j.CampaignID = int64(campaignID)
2683-
j.PatchID = int64(i)
2694+
j.PatchID = p.ID
26842695

26852696
err := s.CreateChangesetJob(ctx, j)
26862697
if err != nil {
@@ -2701,6 +2712,93 @@ func testStoreChangesetJobs(t *testing.T, ctx context.Context, s *Store, _ repos
27012712
}
27022713
}
27032714
})
2715+
t.Run("BackgroundProcessStatus_ErrorsOnlyInRepos", func(t *testing.T) {
2716+
var campaignID int64 = 123456
2717+
2718+
patches := []*cmpgn.Patch{
2719+
{RepoID: 444, PatchSetID: 888, BaseRef: "deadbeef", Diff: "foobar"},
2720+
{RepoID: 555, PatchSetID: 888, BaseRef: "deadbeef", Diff: "foobar"},
2721+
{RepoID: 666, PatchSetID: 888, BaseRef: "deadbeef", Diff: "foobar"},
2722+
}
2723+
for _, p := range patches {
2724+
if err := s.CreatePatch(ctx, p); err != nil {
2725+
t.Fatal(err)
2726+
}
2727+
}
2728+
2729+
jobs := []*cmpgn.ChangesetJob{
2730+
// completed, no errors
2731+
{PatchID: patches[0].ID, StartedAt: clock.now(), FinishedAt: clock.now(), ChangesetID: 23},
2732+
// completed, error
2733+
{PatchID: patches[1].ID, StartedAt: clock.now(), FinishedAt: clock.now(), Error: "error1"},
2734+
// completed, another error
2735+
{PatchID: patches[2].ID, StartedAt: clock.now(), FinishedAt: clock.now(), Error: "error2"},
2736+
}
2737+
2738+
for _, j := range jobs {
2739+
j.CampaignID = campaignID
2740+
2741+
err := s.CreateChangesetJob(ctx, j)
2742+
if err != nil {
2743+
t.Fatal(err)
2744+
}
2745+
}
2746+
2747+
opts := GetCampaignStatusOpts{
2748+
ID: campaignID,
2749+
ExcludeErrorsInRepos: []api.RepoID{patches[2].RepoID},
2750+
}
2751+
2752+
want := &cmpgn.BackgroundProcessStatus{
2753+
ProcessState: cmpgn.BackgroundProcessStateErrored,
2754+
Total: 3,
2755+
Completed: 3,
2756+
Failed: 2,
2757+
Pending: 0,
2758+
ProcessErrors: []string{"error1"},
2759+
// error2 should be excluded
2760+
}
2761+
2762+
status, err := s.GetCampaignStatus(ctx, opts)
2763+
if err != nil {
2764+
t.Fatal(err)
2765+
}
2766+
2767+
if diff := cmp.Diff(status, want); diff != "" {
2768+
t.Fatalf("wrong diff: %s", diff)
2769+
}
2770+
2771+
// Now we filter out all errors, but still want the ProcessState to be
2772+
// correct
2773+
opts = GetCampaignStatusOpts{
2774+
ID: campaignID,
2775+
ExcludeErrorsInRepos: []api.RepoID{
2776+
patches[0].RepoID,
2777+
patches[1].RepoID,
2778+
patches[2].RepoID,
2779+
},
2780+
}
2781+
2782+
want = &cmpgn.BackgroundProcessStatus{
2783+
// This should stay "Errored", even though no errors are returned.
2784+
ProcessState: cmpgn.BackgroundProcessStateErrored,
2785+
Total: 3,
2786+
Completed: 3,
2787+
Failed: 2,
2788+
Pending: 0,
2789+
ProcessErrors: nil,
2790+
// error1 and error2 should be excluded
2791+
}
2792+
2793+
status, err = s.GetCampaignStatus(ctx, opts)
2794+
if err != nil {
2795+
t.Fatal(err)
2796+
}
2797+
2798+
if diff := cmp.Diff(status, want); diff != "" {
2799+
t.Fatalf("wrong diff: %s", diff)
2800+
}
2801+
})
27042802

27052803
t.Run("ResetFailedChangesetJobs", func(t *testing.T) {
27062804
campaignID := 9999

0 commit comments

Comments
 (0)