Skip to content

Commit d7f075d

Browse files
committed
enforce project scoping in the gerrit client
Every change query gets the allowlist clause injected around whatever the caller composed, so agent-supplied project terms can only narrow. Direct operations check the target's project first: project~number identifiers locally, bare identifiers via one resolving fetch; the refusal happens before the content request leaves the process. GetChange checks its own response instead of double-fetching. Refs: #11
1 parent ed3c40b commit d7f075d

4 files changed

Lines changed: 275 additions & 6 deletions

File tree

internal/gerritclient/changes.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package gerritclient
22

33
import (
44
"context"
5+
"slices"
6+
"strings"
57

68
"dev.gaijin.team/go/golib/e"
79
"dev.gaijin.team/go/golib/fields"
@@ -15,6 +17,9 @@ var (
1517
ErrListFiles = e.New("list change files")
1618
ErrGetDiff = e.New("get file diff")
1719
ErrListComments = e.New("list change comments")
20+
21+
// ErrProjectScope reports an operation refused by the project allowlist.
22+
ErrProjectScope = e.New("change is outside the configured project scope")
1823
)
1924

2025
// CurrentRevision addresses the latest patch set of a change.
@@ -47,9 +52,76 @@ func (c *Client) GetChange(ctx context.Context, id string) (*gerrit.ChangeInfo,
4752
return nil, ErrGetChange.Wrap(errEmptyResponse, fields.F("change", id))
4853
}
4954

55+
if !c.projectAllowed(info.Project) {
56+
return nil, c.scopeError(id)
57+
}
58+
5059
return info, nil
5160
}
5261

62+
// projectAllowed reports whether a project passes the allowlist; an empty
63+
// allowlist admits everything.
64+
func (c *Client) projectAllowed(project string) bool {
65+
return len(c.projects) == 0 || slices.Contains(c.projects, project)
66+
}
67+
68+
func (c *Client) scopeError(changeID string) error {
69+
return ErrProjectScope.WithFields(
70+
fields.F("change", changeID),
71+
fields.F("projects", strings.Join(c.projects, ",")),
72+
)
73+
}
74+
75+
// scopedQuery forces the project allowlist into a change query regardless of
76+
// what the caller composed; agent-supplied clauses can only narrow further.
77+
func (c *Client) scopedQuery(query string) string {
78+
if len(c.projects) == 0 {
79+
return query
80+
}
81+
82+
clauses := make([]string, 0, len(c.projects))
83+
for _, p := range c.projects {
84+
clauses = append(clauses, "project:"+p)
85+
}
86+
87+
scope := "(" + strings.Join(clauses, " OR ") + ")"
88+
89+
if strings.TrimSpace(query) == "" {
90+
return scope
91+
}
92+
93+
return scope + " (" + query + ")"
94+
}
95+
96+
// checkProjectScope refuses operations on changes outside the allowlist
97+
// before any request for their content leaves the process. A project~number
98+
// identifier is checked directly; a bare identifier costs one resolving
99+
// fetch.
100+
func (c *Client) checkProjectScope(ctx context.Context, changeID string) error {
101+
if len(c.projects) == 0 {
102+
return nil
103+
}
104+
105+
if project, _, ok := strings.Cut(changeID, "~"); ok {
106+
if !c.projectAllowed(project) {
107+
return c.scopeError(changeID)
108+
}
109+
110+
return nil
111+
}
112+
113+
info, resp, err := c.gerrit.Changes.GetChange(ctx, changeID, nil)
114+
if err != nil {
115+
return ErrGetChange.Wrap(apiError(resp, err), fields.F("change", changeID))
116+
}
117+
118+
if info == nil || !c.projectAllowed(info.Project) {
119+
return c.scopeError(changeID)
120+
}
121+
122+
return nil
123+
}
124+
53125
// QueryResult is one page of a change query.
54126
type QueryResult struct {
55127
Changes []gerrit.ChangeInfo
@@ -62,7 +134,7 @@ type QueryResult struct {
62134
func (c *Client) QueryChanges(ctx context.Context, query string, limit, start int) (*QueryResult, error) {
63135
opt := &gerrit.QueryChangeOptions{}
64136

65-
opt.Query = []string{query}
137+
opt.Query = []string{c.scopedQuery(query)}
66138
opt.Limit = limit
67139
opt.Start = start
68140
opt.AdditionalFields = []string{"DETAILED_ACCOUNTS"}
@@ -89,6 +161,10 @@ func (c *Client) ListFiles(ctx context.Context, changeID, revision string) (map[
89161
revision = CurrentRevision
90162
}
91163

164+
if err := c.checkProjectScope(ctx, changeID); err != nil {
165+
return nil, ErrListFiles.Wrap(err)
166+
}
167+
92168
files, resp, err := c.gerrit.Changes.ListFiles(ctx, changeID, revision, nil)
93169
if err != nil {
94170
return nil, ErrListFiles.Wrap(apiError(resp, err),
@@ -105,6 +181,10 @@ func (c *Client) GetDiff(ctx context.Context, changeID, revision, path string) (
105181
revision = CurrentRevision
106182
}
107183

184+
if err := c.checkProjectScope(ctx, changeID); err != nil {
185+
return nil, ErrGetDiff.Wrap(err)
186+
}
187+
108188
diff, resp, err := c.gerrit.Changes.GetDiff(ctx, changeID, revision, path, nil)
109189
if err != nil {
110190
return nil, ErrGetDiff.Wrap(apiError(resp, err),
@@ -121,6 +201,10 @@ func (c *Client) GetDiff(ctx context.Context, changeID, revision, path string) (
121201
// ListChangeComments lists all published comments of a change across
122202
// revisions, grouped by file path.
123203
func (c *Client) ListChangeComments(ctx context.Context, changeID string) (map[string][]gerrit.CommentInfo, error) {
204+
if err := c.checkProjectScope(ctx, changeID); err != nil {
205+
return nil, ErrListComments.Wrap(err)
206+
}
207+
124208
comments, resp, err := c.gerrit.Changes.ListChangeComments(ctx, changeID)
125209
if err != nil {
126210
return nil, ErrListComments.Wrap(apiError(resp, err), fields.F("change", changeID))

internal/gerritclient/gerritclient.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ var (
3636
type Client struct {
3737
gerrit *gerrit.Client
3838
self gerrit.AccountInfo
39+
// projects, when non-empty, confines every operation to the listed
40+
// Gerrit projects (see docs/glossary.md: Project scoping).
41+
projects []string
3942
}
4043

4144
// New builds an authenticated client and validates the credentials against
@@ -58,7 +61,7 @@ func New(ctx context.Context, cfg *config.Config) (*Client, error) {
5861
return nil, ErrCredentials.Wrap(errEmptyResponse)
5962
}
6063

61-
return &Client{gerrit: g, self: *self}, nil
64+
return &Client{gerrit: g, self: *self, projects: cfg.Projects}, nil
6265
}
6366

6467
// Self reports the authenticated account as validated at startup.

internal/gerritclient/gerritclient_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import (
1515

1616
func testConfig(url string) *config.Config {
1717
return &config.Config{
18-
GerritURL: url,
19-
Username: "bot",
20-
Token: "s3cret",
21-
Groups: []config.Group{config.GroupRead},
18+
GerritURL: url,
19+
Username: "bot",
20+
Token: "s3cret",
21+
Groups: []config.Group{config.GroupRead},
22+
IncludeTools: nil,
23+
ExcludeTools: nil,
24+
Projects: nil,
2225
}
2326
}
2427

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package gerritclient_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"sync"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
14+
)
15+
16+
const scopedSelfJSON = ")]}'\n" + `{"_account_id":42,"name":"Review Bot","username":"bot"}`
17+
18+
// scopedClient builds a client with a project allowlist against a recording
19+
// fake Gerrit. requests collects every API path hit besides the self check.
20+
func scopedClient(t *testing.T, handler http.HandlerFunc) (*gerritclient.Client, *[]string) {
21+
t.Helper()
22+
23+
var (
24+
mu sync.Mutex
25+
requests []string
26+
)
27+
28+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
if r.URL.Path == "/a/accounts/self" {
30+
_, _ = w.Write([]byte(scopedSelfJSON))
31+
32+
return
33+
}
34+
35+
mu.Lock()
36+
37+
requests = append(requests, r.URL.String())
38+
39+
mu.Unlock()
40+
41+
handler(w, r)
42+
}))
43+
t.Cleanup(srv.Close)
44+
45+
cfg := testConfig(srv.URL)
46+
47+
cfg.Projects = []string{"core", "infra"}
48+
49+
client, err := gerritclient.New(t.Context(), cfg)
50+
require.NoError(t, err)
51+
52+
return client, &requests
53+
}
54+
55+
func Test_ProjectScoping(t *testing.T) {
56+
t.Parallel()
57+
58+
t.Run("query gets the allowlist clause injected", func(t *testing.T) {
59+
t.Parallel()
60+
61+
var gotQuery string
62+
63+
client, _ := scopedClient(t, func(w http.ResponseWriter, r *http.Request) {
64+
gotQuery = r.URL.Query().Get("q")
65+
66+
_, _ = w.Write([]byte(")]}'\n[]"))
67+
})
68+
69+
_, err := client.QueryChanges(t.Context(), "status:open project:secret", 10, 0)
70+
require.NoError(t, err)
71+
72+
assert.Equal(t, "(project:core OR project:infra) (status:open project:secret)", gotQuery)
73+
})
74+
75+
t.Run("empty query becomes the allowlist clause", func(t *testing.T) {
76+
t.Parallel()
77+
78+
var gotQuery string
79+
80+
client, _ := scopedClient(t, func(w http.ResponseWriter, r *http.Request) {
81+
gotQuery = r.URL.Query().Get("q")
82+
83+
_, _ = w.Write([]byte(")]}'\n[]"))
84+
})
85+
86+
_, err := client.QueryChanges(t.Context(), "", 10, 0)
87+
require.NoError(t, err)
88+
89+
assert.Equal(t, "(project:core OR project:infra)", gotQuery)
90+
})
91+
92+
t.Run("get_change refuses out-of-scope project", func(t *testing.T) {
93+
t.Parallel()
94+
95+
client, _ := scopedClient(t, func(w http.ResponseWriter, _ *http.Request) {
96+
_, _ = w.Write([]byte(")]}'\n{\"_number\":9,\"project\":\"secret\",\"branch\":\"main\"}"))
97+
})
98+
99+
_, err := client.GetChange(t.Context(), "9")
100+
101+
require.Error(t, err)
102+
require.ErrorIs(t, err, gerritclient.ErrProjectScope)
103+
})
104+
105+
t.Run("scoped id refused without any request", func(t *testing.T) {
106+
t.Parallel()
107+
108+
client, requests := scopedClient(t, func(w http.ResponseWriter, _ *http.Request) {
109+
_, _ = w.Write([]byte(")]}'\n{}"))
110+
})
111+
112+
_, err := client.ListFiles(t.Context(), "secret~123", "")
113+
114+
require.Error(t, err)
115+
require.ErrorIs(t, err, gerritclient.ErrProjectScope)
116+
assert.Empty(t, *requests, "no API request may leave the process")
117+
})
118+
119+
t.Run("bare id resolves project then refuses", func(t *testing.T) {
120+
t.Parallel()
121+
122+
client, requests := scopedClient(t, func(w http.ResponseWriter, r *http.Request) {
123+
if strings.HasSuffix(r.URL.Path, "/comments") {
124+
t.Error("comments endpoint must not be reached")
125+
}
126+
127+
_, _ = w.Write([]byte(")]}'\n{\"_number\":9,\"project\":\"secret\",\"branch\":\"main\"}"))
128+
})
129+
130+
_, err := client.ListChangeComments(t.Context(), "9")
131+
132+
require.Error(t, err)
133+
require.ErrorIs(t, err, gerritclient.ErrProjectScope)
134+
require.Len(t, *requests, 1, "exactly the resolving fetch")
135+
})
136+
137+
t.Run("in-scope scoped id proceeds directly", func(t *testing.T) {
138+
t.Parallel()
139+
140+
client, requests := scopedClient(t, func(w http.ResponseWriter, _ *http.Request) {
141+
_, _ = w.Write([]byte(")]}'\n{}"))
142+
})
143+
144+
files, err := client.ListFiles(t.Context(), "core~123", "")
145+
require.NoError(t, err)
146+
147+
assert.NotNil(t, files)
148+
require.Len(t, *requests, 1)
149+
assert.Contains(t, (*requests)[0], "/files")
150+
})
151+
152+
t.Run("unscoped client passes everything through", func(t *testing.T) {
153+
t.Parallel()
154+
155+
var gotQuery string
156+
157+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158+
if r.URL.Path == "/a/accounts/self" {
159+
_, _ = w.Write([]byte(scopedSelfJSON))
160+
161+
return
162+
}
163+
164+
gotQuery = r.URL.Query().Get("q")
165+
166+
_, _ = w.Write([]byte(")]}'\n[]"))
167+
}))
168+
t.Cleanup(srv.Close)
169+
170+
client, err := gerritclient.New(t.Context(), testConfig(srv.URL))
171+
require.NoError(t, err)
172+
173+
_, err = client.QueryChanges(t.Context(), "status:open", 10, 0)
174+
require.NoError(t, err)
175+
require.NotErrorIs(t, err, gerritclient.ErrProjectScope)
176+
177+
assert.Equal(t, "status:open", gotQuery)
178+
})
179+
}

0 commit comments

Comments
 (0)