mirror-github.paniser.workers.dev/github/github-mcp-server
Advanced tools
@@ -106,3 +106,3 @@ package ghmcp | ||
| } | ||
| repoAccessCache = lockdown.GetInstance(gqlClient, restClient, opts...) | ||
| repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, restClient, opts...) | ||
| } | ||
@@ -109,0 +109,0 @@ |
@@ -402,3 +402,3 @@ package github | ||
| // Create repo access cache | ||
| instance := lockdown.GetInstance(gqlClient, restClient, d.RepoAccessOpts...) | ||
| instance := lockdown.NewRepoAccessCache(gqlClient, restClient, d.RepoAccessOpts...) | ||
| return instance, nil | ||
@@ -405,0 +405,0 @@ } |
@@ -5,2 +5,3 @@ package lockdown | ||
| "encoding/json" | ||
| "errors" | ||
| "net/http" | ||
@@ -24,6 +25,12 @@ "net/http/httptest" | ||
| type repoMetadataQuery struct { | ||
| type viewerLoginQuery struct { | ||
| Viewer struct { | ||
| Login githubv4.String | ||
| } | ||
| } | ||
| type repoAccessQuery struct { | ||
| Viewer struct { | ||
| Login githubv4.String | ||
| } | ||
| Repository struct { | ||
@@ -53,7 +60,3 @@ IsPrivate githubv4.Boolean | ||
| func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) { | ||
| t.Helper() | ||
| var query repoMetadataQuery | ||
| func newMockGQLClient(viewerLogin string, isPrivate bool) (*githubv4.Client, *countingTransport) { | ||
| variables := map[string]any{ | ||
@@ -64,20 +67,29 @@ "owner": githubv4.String(testOwner), | ||
| response := githubv4mock.DataResponse(map[string]any{ | ||
| "viewer": map[string]any{ | ||
| "login": testUser, | ||
| }, | ||
| "repository": map[string]any{ | ||
| "isPrivate": false, | ||
| }, | ||
| }) | ||
| httpClient := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(query, variables, response)) | ||
| httpClient := githubv4mock.NewMockedHTTPClient( | ||
| githubv4mock.NewQueryMatcher( | ||
| viewerLoginQuery{}, | ||
| nil, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "viewer": map[string]any{"login": viewerLogin}, | ||
| }), | ||
| ), | ||
| githubv4mock.NewQueryMatcher( | ||
| repoAccessQuery{}, | ||
| variables, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "viewer": map[string]any{"login": viewerLogin}, | ||
| "repository": map[string]any{"isPrivate": isPrivate}, | ||
| }), | ||
| ), | ||
| ) | ||
| counting := &countingTransport{next: httpClient.Transport} | ||
| httpClient.Transport = counting | ||
| gqlClient := githubv4.NewClient(httpClient) | ||
| return gqlClient, counting | ||
| } | ||
| func newMockRESTServer(t *testing.T, permission string) *gogithub.Client { | ||
| t.Helper() | ||
| restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| resp := gogithub.RepositoryPermissionLevel{ | ||
| Permission: gogithub.Ptr("write"), | ||
| } | ||
| resp := gogithub.RepositoryPermissionLevel{Permission: gogithub.Ptr(permission)} | ||
| w.Header().Set("Content-Type", "application/json") | ||
@@ -89,4 +101,16 @@ _ = json.NewEncoder(w).Encode(resp) | ||
| require.NoError(t, err) | ||
| return restClient | ||
| } | ||
| return NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl)), counting | ||
| func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) { | ||
| t.Helper() | ||
| gqlClient, counting := newMockGQLClient(testUser, false) | ||
| restClient := newMockRESTServer(t, "write") | ||
| cache := NewRepoAccessCache( | ||
| gqlClient, | ||
| restClient, | ||
| WithTTL(ttl), | ||
| WithCacheName(t.Name()), | ||
| ) | ||
| return cache, counting | ||
| } | ||
@@ -100,3 +124,3 @@ | ||
| require.NoError(t, err) | ||
| require.Equal(t, testUser, info.ViewerLogin) | ||
| require.False(t, info.IsPrivate) | ||
| require.True(t, info.HasPushAccess) | ||
@@ -109,5 +133,93 @@ require.EqualValues(t, 1, transport.CallCount()) | ||
| require.NoError(t, err) | ||
| require.Equal(t, testUser, info.ViewerLogin) | ||
| require.False(t, info.IsPrivate) | ||
| require.True(t, info.HasPushAccess) | ||
| require.EqualValues(t, 2, transport.CallCount()) | ||
| } | ||
| func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) { | ||
| ctx := t.Context() | ||
| cacheName := t.Name() | ||
| restClient := newMockRESTServer(t, "read") | ||
| attackerGQL, _ := newMockGQLClient("attacker", false) | ||
| attackerCache := NewRepoAccessCache(attackerGQL, restClient, WithCacheName(cacheName)) | ||
| safe, err := attackerCache.IsSafeContent(ctx, "attacker", testOwner, testRepo) | ||
| require.NoError(t, err) | ||
| require.True(t, safe) | ||
| victimGQL, _ := newMockGQLClient("victim", false) | ||
| victimCache := NewRepoAccessCache(victimGQL, restClient, WithCacheName(cacheName)) | ||
| safe, err = victimCache.IsSafeContent(ctx, "attacker", testOwner, testRepo) | ||
| require.NoError(t, err) | ||
| require.False(t, safe, "attacker-authored content must not be safe for the victim") | ||
| safe, err = victimCache.IsSafeContent(ctx, "victim", testOwner, testRepo) | ||
| require.NoError(t, err) | ||
| require.True(t, safe) | ||
| } | ||
| type flakyTransport struct { | ||
| mu sync.Mutex | ||
| failN int | ||
| calls int | ||
| next http.RoundTripper | ||
| } | ||
| func (f *flakyTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| f.mu.Lock() | ||
| f.calls++ | ||
| shouldFail := f.calls <= f.failN | ||
| f.mu.Unlock() | ||
| if shouldFail { | ||
| return nil, errors.New("simulated transient failure") | ||
| } | ||
| return f.next.RoundTrip(req) | ||
| } | ||
| func TestRepoAccessCacheRetriesViewerLoginAfterTransientError(t *testing.T) { | ||
| ctx := t.Context() | ||
| httpClient := githubv4mock.NewMockedHTTPClient( | ||
| githubv4mock.NewQueryMatcher( | ||
| viewerLoginQuery{}, | ||
| nil, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "viewer": map[string]any{"login": testUser}, | ||
| }), | ||
| ), | ||
| ) | ||
| flaky := &flakyTransport{next: httpClient.Transport, failN: 1} | ||
| httpClient.Transport = flaky | ||
| gqlClient := githubv4.NewClient(httpClient) | ||
| cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name())) | ||
| _, err := cache.viewerLoginFor(ctx) | ||
| require.Error(t, err, "first call should surface the transient failure") | ||
| login, err := cache.viewerLoginFor(ctx) | ||
| require.NoError(t, err, "second call must retry, not return the cached error") | ||
| require.Equal(t, testUser, login) | ||
| } | ||
| func TestRepoAccessCacheRejectsEmptyViewerLogin(t *testing.T) { | ||
| ctx := t.Context() | ||
| httpClient := githubv4mock.NewMockedHTTPClient( | ||
| githubv4mock.NewQueryMatcher( | ||
| viewerLoginQuery{}, | ||
| nil, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "viewer": map[string]any{"login": ""}, | ||
| }), | ||
| ), | ||
| ) | ||
| gqlClient := githubv4.NewClient(httpClient) | ||
| cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name())) | ||
| _, err := cache.viewerLoginFor(ctx) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "empty") | ||
| } |
+86
-73
@@ -7,2 +7,3 @@ package lockdown | ||
| "log/slog" | ||
| "maps" | ||
| "strings" | ||
@@ -19,6 +20,7 @@ "sync" | ||
| // multiple tools can reuse the same access information safely across goroutines. | ||
| // In HTTP mode each request must construct its own instance so viewer-scoped | ||
| // lookups run under the requesting user's credentials. | ||
| type RepoAccessCache struct { | ||
| client *githubv4.Client | ||
| restClient *github.Client | ||
| mu sync.Mutex | ||
| cache *cache2go.CacheTable | ||
@@ -28,8 +30,10 @@ ttl time.Duration | ||
| trustedBotLogins map[string]struct{} | ||
| viewerMu sync.Mutex | ||
| viewerLogin string | ||
| } | ||
| type repoAccessCacheEntry struct { | ||
| isPrivate bool | ||
| knownUsers map[string]bool // normalized login -> has push access | ||
| viewerLogin string | ||
| isPrivate bool | ||
| knownUsers map[string]bool // normalized login -> has push access | ||
| } | ||
@@ -41,3 +45,2 @@ | ||
| HasPushAccess bool | ||
| ViewerLogin string | ||
| } | ||
@@ -50,7 +53,2 @@ | ||
| var ( | ||
| instance *RepoAccessCache | ||
| instanceMu sync.Mutex | ||
| ) | ||
| // RepoAccessOption configures RepoAccessCache at construction time. | ||
@@ -74,4 +72,4 @@ type RepoAccessOption func(*RepoAccessCache) | ||
| // WithCacheName overrides the cache table name used for storing entries. This option is intended for tests | ||
| // that need isolated cache instances. | ||
| // WithCacheName overrides the cache table name used for storing entries. | ||
| // Use this to isolate cache entries between tenants or in tests. | ||
| func WithCacheName(name string) RepoAccessOption { | ||
@@ -85,21 +83,4 @@ return func(c *RepoAccessCache) { | ||
| // GetInstance returns the singleton instance of RepoAccessCache. | ||
| // It initializes the instance on first call with the provided client and options. | ||
| // Subsequent calls ignore the client and options parameters and return the existing instance. | ||
| // This is the preferred way to access the cache in production code. | ||
| func GetInstance(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache { | ||
| instanceMu.Lock() | ||
| defer instanceMu.Unlock() | ||
| if instance == nil { | ||
| instance = newRepoAccessCache(client, restClient, opts...) | ||
| } | ||
| return instance | ||
| } | ||
| // NewRepoAccessCache creates a standalone cache instance, used for tests. | ||
| // NewRepoAccessCache creates a RepoAccessCache bound to the supplied clients. | ||
| func NewRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache { | ||
| return newRepoAccessCache(client, restClient, opts...) | ||
| } | ||
| func newRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache { | ||
| c := &RepoAccessCache{ | ||
@@ -123,9 +104,2 @@ client: client, | ||
| // SetLogger updates the logger used for cache diagnostics. | ||
| func (c *RepoAccessCache) SetLogger(logger *slog.Logger) { | ||
| c.mu.Lock() | ||
| c.logger = logger | ||
| c.mu.Unlock() | ||
| } | ||
| // CacheStats summarizes cache activity counters. | ||
@@ -161,8 +135,53 @@ type CacheStats struct { | ||
| if repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) { | ||
| if repoInfo.IsPrivate { | ||
| return true, nil | ||
| } | ||
| return repoInfo.HasPushAccess, nil | ||
| if repoInfo.HasPushAccess { | ||
| return true, nil | ||
| } | ||
| viewerLogin, err := c.viewerLoginFor(ctx) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| return viewerLogin == strings.ToLower(username), nil | ||
| } | ||
| func (c *RepoAccessCache) viewerLoginFor(ctx context.Context) (string, error) { | ||
| c.viewerMu.Lock() | ||
| defer c.viewerMu.Unlock() | ||
| if c.viewerLogin != "" { | ||
| return c.viewerLogin, nil | ||
| } | ||
| if c.client == nil { | ||
| return "", fmt.Errorf("nil GraphQL client") | ||
| } | ||
| var query struct { | ||
| Viewer struct { | ||
| Login githubv4.String | ||
| } | ||
| } | ||
| if err := c.client.Query(ctx, &query, nil); err != nil { | ||
| return "", fmt.Errorf("failed to query viewer login: %w", err) | ||
| } | ||
| login := strings.ToLower(string(query.Viewer.Login)) | ||
| if login == "" { | ||
| return "", fmt.Errorf("viewer login returned empty") | ||
| } | ||
| c.viewerLogin = login | ||
| return c.viewerLogin, nil | ||
| } | ||
| // setViewerLogin seeds the cached viewer login from a piggy-backed query response. | ||
| func (c *RepoAccessCache) setViewerLogin(login string) { | ||
| if login == "" { | ||
| return | ||
| } | ||
| c.viewerMu.Lock() | ||
| defer c.viewerMu.Unlock() | ||
| if c.viewerLogin == "" { | ||
| c.viewerLogin = strings.ToLower(login) | ||
| } | ||
| } | ||
| func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) { | ||
@@ -175,8 +194,6 @@ if c == nil { | ||
| userKey := strings.ToLower(username) | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| // Try to get entry from cache - this will keep the item alive if it exists | ||
| cacheItem, err := c.cache.Value(key) | ||
| if err == nil { | ||
| // Entries are immutable once added: the cache table is shared across instances, | ||
| // so we publish a fresh entry with a cloned knownUsers map on every miss. | ||
| if cacheItem, err := c.cache.Value(key); err == nil { | ||
| entry := cacheItem.Data().(*repoAccessCacheEntry) | ||
@@ -188,3 +205,2 @@ if cachedHasPush, known := entry.knownUsers[userKey]; known { | ||
| HasPushAccess: cachedHasPush, | ||
| ViewerLogin: entry.viewerLogin, | ||
| }, nil | ||
@@ -200,4 +216,9 @@ } | ||
| entry.knownUsers[userKey] = hasPush | ||
| c.cache.Add(key, c.ttl, entry) | ||
| users := make(map[string]bool, len(entry.knownUsers)+1) | ||
| maps.Copy(users, entry.knownUsers) | ||
| users[userKey] = hasPush | ||
| c.cache.Add(key, c.ttl, &repoAccessCacheEntry{ | ||
| isPrivate: entry.isPrivate, | ||
| knownUsers: users, | ||
| }) | ||
@@ -207,3 +228,2 @@ return RepoAccessInfo{ | ||
| HasPushAccess: hasPush, | ||
| ViewerLogin: entry.viewerLogin, | ||
| }, nil | ||
@@ -214,25 +234,28 @@ } | ||
| info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo) | ||
| isPrivate, viewerLogin, queryErr := c.queryRepoAccessInfo(ctx, owner, repo) | ||
| if queryErr != nil { | ||
| return RepoAccessInfo{}, queryErr | ||
| } | ||
| c.setViewerLogin(viewerLogin) | ||
| // Create new entry | ||
| entry := &repoAccessCacheEntry{ | ||
| knownUsers: map[string]bool{userKey: info.HasPushAccess}, | ||
| isPrivate: info.IsPrivate, | ||
| viewerLogin: info.ViewerLogin, | ||
| hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo) | ||
| if pushErr != nil { | ||
| return RepoAccessInfo{}, pushErr | ||
| } | ||
| c.cache.Add(key, c.ttl, entry) | ||
| c.cache.Add(key, c.ttl, &repoAccessCacheEntry{ | ||
| knownUsers: map[string]bool{userKey: hasPush}, | ||
| isPrivate: isPrivate, | ||
| }) | ||
| return RepoAccessInfo{ | ||
| IsPrivate: entry.isPrivate, | ||
| HasPushAccess: entry.knownUsers[userKey], | ||
| ViewerLogin: entry.viewerLogin, | ||
| IsPrivate: isPrivate, | ||
| HasPushAccess: hasPush, | ||
| }, nil | ||
| } | ||
| func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) { | ||
| // queryRepoAccessInfo fetches repository visibility and the viewer login in a single GraphQL round-trip. | ||
| func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, owner, repo string) (bool, string, error) { | ||
| if c.client == nil { | ||
| return RepoAccessInfo{}, fmt.Errorf("nil GraphQL client") | ||
| return false, "", fmt.Errorf("nil GraphQL client") | ||
| } | ||
@@ -255,18 +278,8 @@ | ||
| if err := c.client.Query(ctx, &query, variables); err != nil { | ||
| return RepoAccessInfo{}, fmt.Errorf("failed to query repository metadata: %w", err) | ||
| return false, "", fmt.Errorf("failed to query repository metadata: %w", err) | ||
| } | ||
| hasPush, err := c.checkPushAccess(ctx, username, owner, repo) | ||
| if err != nil { | ||
| return RepoAccessInfo{}, err | ||
| } | ||
| c.logDebug(ctx, fmt.Sprintf("queried repo access info for %s/%s: isPrivate=%t", owner, repo, bool(query.Repository.IsPrivate))) | ||
| c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s", | ||
| username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login)) | ||
| return RepoAccessInfo{ | ||
| IsPrivate: bool(query.Repository.IsPrivate), | ||
| HasPushAccess: hasPush, | ||
| ViewerLogin: string(query.Viewer.Login), | ||
| }, nil | ||
| return bool(query.Repository.IsPrivate), string(query.Viewer.Login), nil | ||
| } | ||
@@ -273,0 +286,0 @@ |
Sorry, the diff of this file is too big to display