chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
)
|
||||
|
||||
// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes.
|
||||
const OAuthScopesHeader = "X-OAuth-Scopes"
|
||||
|
||||
// DefaultFetchTimeout is the default timeout for scope fetching requests.
|
||||
const DefaultFetchTimeout = 10 * time.Second
|
||||
|
||||
// FetcherOptions configures the scope fetcher.
|
||||
type FetcherOptions struct {
|
||||
// HTTPClient is the HTTP client to use for requests.
|
||||
// If nil, a default client with DefaultFetchTimeout is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// APIHost is the GitHub API host (e.g., "https://api.github.com").
|
||||
// Defaults to "https://api.github.com" if empty.
|
||||
APIHost utils.APIHostResolver
|
||||
}
|
||||
|
||||
type FetcherInterface interface {
|
||||
FetchTokenScopes(ctx context.Context, token string) ([]string, error)
|
||||
}
|
||||
|
||||
// Fetcher retrieves token scopes from GitHub's API.
|
||||
// It uses an HTTP HEAD request to minimize bandwidth since we only need headers.
|
||||
type Fetcher struct {
|
||||
client *http.Client
|
||||
apiHost utils.APIHostResolver
|
||||
}
|
||||
|
||||
// NewFetcher creates a new scope fetcher with the given options.
|
||||
func NewFetcher(apiHost utils.APIHostResolver, opts FetcherOptions) *Fetcher {
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: DefaultFetchTimeout}
|
||||
}
|
||||
|
||||
return &Fetcher{
|
||||
client: client,
|
||||
apiHost: apiHost,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD
|
||||
// request to the GitHub API and parsing the X-OAuth-Scopes header.
|
||||
//
|
||||
// Returns:
|
||||
// - []string: List of scopes (empty if no scopes or fine-grained PAT)
|
||||
// - error: Any HTTP or parsing error
|
||||
//
|
||||
// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty
|
||||
// slice is returned for those tokens.
|
||||
func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) {
|
||||
apiHostURL, err := f.apiHost.BaseRESTURL(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get API host URL: %w", err)
|
||||
}
|
||||
|
||||
// Use a lightweight endpoint that requires authentication
|
||||
endpoint, err := url.JoinPath(apiHostURL.String(), "/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct API URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer "+token)
|
||||
req.Header.Set(headers.AcceptHeader, "application/vnd.github+json")
|
||||
req.Header.Set(headers.GitHubAPIVersionHeader, "2022-11-28")
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch scopes: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("invalid or expired token")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil
|
||||
}
|
||||
|
||||
// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes.
|
||||
// The header contains comma-separated scope names.
|
||||
// Returns an empty slice for empty or missing header.
|
||||
func ParseScopeHeader(header string) []string {
|
||||
if header == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
parts := strings.Split(header, ",")
|
||||
scopes := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
scope := strings.TrimSpace(part)
|
||||
if scope != "" {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// FetchTokenScopes is a convenience function that creates a default fetcher
|
||||
// and fetches the token scopes.
|
||||
func FetchTokenScopes(ctx context.Context, token string) ([]string, error) {
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create default API host: %w", err)
|
||||
}
|
||||
|
||||
return NewFetcher(apiHost, FetcherOptions{}).FetchTokenScopes(ctx, token)
|
||||
}
|
||||
|
||||
// FetchTokenScopesWithHost is a convenience function that creates a fetcher
|
||||
// for a specific API host and fetches the token scopes.
|
||||
func FetchTokenScopesWithHost(ctx context.Context, token string, apiHost utils.APIHostResolver) ([]string, error) {
|
||||
return NewFetcher(apiHost, FetcherOptions{}).FetchTokenScopes(ctx, token)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testAPIHostResolver struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (t testAPIHostResolver) BaseRESTURL(_ context.Context) (*url.URL, error) {
|
||||
return url.Parse(t.baseURL)
|
||||
}
|
||||
func (t testAPIHostResolver) GraphqlURL(_ context.Context) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t testAPIHostResolver) UploadURL(_ context.Context) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t testAPIHostResolver) RawURL(_ context.Context) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t testAPIHostResolver) AuthorizationServerURL(_ context.Context) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestParseScopeHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty header",
|
||||
header: "",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single scope",
|
||||
header: "repo",
|
||||
expected: []string{"repo"},
|
||||
},
|
||||
{
|
||||
name: "multiple scopes",
|
||||
header: "repo, user, gist",
|
||||
expected: []string{"repo", "user", "gist"},
|
||||
},
|
||||
{
|
||||
name: "scopes with extra whitespace",
|
||||
header: " repo , user , gist ",
|
||||
expected: []string{"repo", "user", "gist"},
|
||||
},
|
||||
{
|
||||
name: "scopes without spaces",
|
||||
header: "repo,user,gist",
|
||||
expected: []string{"repo", "user", "gist"},
|
||||
},
|
||||
{
|
||||
name: "scopes with colons",
|
||||
header: "read:org, write:org, admin:org",
|
||||
expected: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
{
|
||||
name: "empty parts are filtered",
|
||||
header: "repo,,gist",
|
||||
expected: []string{"repo", "gist"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseScopeHeader(tt.header)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_FetchTokenScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
expectedScopes []string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "successful fetch with multiple scopes",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-OAuth-Scopes", "repo, user, gist")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
expectedScopes: []string{"repo", "user", "gist"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "successful fetch with single scope",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-OAuth-Scopes", "repo")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
expectedScopes: []string{"repo"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "fine-grained PAT returns empty scopes",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Fine-grained PATs don't return X-OAuth-Scopes
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
expectedScopes: []string{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unauthorized token",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
expectError: true,
|
||||
errorContains: "invalid or expired token",
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
},
|
||||
expectError: true,
|
||||
errorContains: "unexpected status code: 500",
|
||||
},
|
||||
{
|
||||
name: "verifies authorization header is set",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-OAuth-Scopes", "repo")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
expectedScopes: []string{"repo"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "verifies request method is HEAD",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-OAuth-Scopes", "repo")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
expectedScopes: []string{"repo"},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(tt.handler)
|
||||
defer server.Close()
|
||||
apiHost := testAPIHostResolver{baseURL: server.URL}
|
||||
fetcher := NewFetcher(apiHost, FetcherOptions{})
|
||||
|
||||
scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token")
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedScopes, scopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_DefaultOptions(t *testing.T) {
|
||||
apiHost := testAPIHostResolver{baseURL: "https://api.github.com"}
|
||||
fetcher := NewFetcher(apiHost, FetcherOptions{})
|
||||
|
||||
// Verify default API host is set
|
||||
apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.github.com", apiURL.String())
|
||||
|
||||
// Verify default HTTP client is set with timeout
|
||||
assert.NotNil(t, fetcher.client)
|
||||
assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout)
|
||||
}
|
||||
|
||||
func TestFetcher_CustomHTTPClient(t *testing.T) {
|
||||
customClient := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
apiHost := testAPIHostResolver{baseURL: "https://api.github.com"}
|
||||
fetcher := NewFetcher(apiHost, FetcherOptions{
|
||||
HTTPClient: customClient,
|
||||
})
|
||||
|
||||
assert.Equal(t, customClient, fetcher.client)
|
||||
}
|
||||
|
||||
func TestFetcher_CustomAPIHost(t *testing.T) {
|
||||
apiHost := testAPIHostResolver{baseURL: "https://api.github.enterprise.com"}
|
||||
fetcher := NewFetcher(apiHost, FetcherOptions{})
|
||||
|
||||
apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.github.enterprise.com", apiURL.String())
|
||||
}
|
||||
|
||||
func TestFetcher_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
apiHost := testAPIHostResolver{baseURL: server.URL}
|
||||
fetcher := NewFetcher(apiHost, FetcherOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
_, err := fetcher.FetchTokenScopes(ctx, "test-token")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package scopes
|
||||
|
||||
import "github.com/github/github-mcp-server/pkg/inventory"
|
||||
|
||||
// ToolScopeMap maps tool names to their scope requirements.
|
||||
type ToolScopeMap map[string]*ToolScopeInfo
|
||||
|
||||
// ToolScopeInfo contains scope information for a single tool.
|
||||
type ToolScopeInfo struct {
|
||||
// RequiredScopes contains the scopes that are directly required by this tool.
|
||||
RequiredScopes []string
|
||||
|
||||
// AcceptedScopes contains all scopes that satisfy the requirements (including parent scopes).
|
||||
AcceptedScopes []string
|
||||
}
|
||||
|
||||
// globalToolScopeMap is populated from inventory when SetToolScopeMapFromInventory is called
|
||||
var globalToolScopeMap ToolScopeMap
|
||||
|
||||
// SetToolScopeMapFromInventory builds and stores a tool scope map from an inventory.
|
||||
// This should be called after building the inventory to make scopes available for middleware.
|
||||
func SetToolScopeMapFromInventory(inv *inventory.Inventory) {
|
||||
globalToolScopeMap = GetToolScopeMapFromInventory(inv)
|
||||
}
|
||||
|
||||
// SetGlobalToolScopeMap sets the global tool scope map directly.
|
||||
// This is useful for testing when you don't have a full inventory.
|
||||
func SetGlobalToolScopeMap(m ToolScopeMap) {
|
||||
globalToolScopeMap = m
|
||||
}
|
||||
|
||||
// GetToolScopeMap returns the global tool scope map.
|
||||
// Returns an empty map if SetToolScopeMapFromInventory hasn't been called yet.
|
||||
func GetToolScopeMap() (ToolScopeMap, error) {
|
||||
if globalToolScopeMap == nil {
|
||||
return make(ToolScopeMap), nil
|
||||
}
|
||||
return globalToolScopeMap, nil
|
||||
}
|
||||
|
||||
// GetToolScopeInfo returns scope information for a specific tool from the global scope map.
|
||||
func GetToolScopeInfo(toolName string) (*ToolScopeInfo, error) {
|
||||
m, err := GetToolScopeMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m[toolName], nil
|
||||
}
|
||||
|
||||
// GetToolScopeMapFromInventory builds a tool scope map from an inventory.
|
||||
// This extracts scope information from ServerTool.RequiredScopes and ServerTool.AcceptedScopes.
|
||||
func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap {
|
||||
result := make(ToolScopeMap)
|
||||
|
||||
// Get all tools from the inventory (both enabled and disabled)
|
||||
// We need all tools for scope checking purposes
|
||||
allTools := inv.AllTools()
|
||||
for i := range allTools {
|
||||
tool := &allTools[i]
|
||||
if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 {
|
||||
result[tool.Tool.Name] = &ToolScopeInfo{
|
||||
RequiredScopes: tool.RequiredScopes,
|
||||
AcceptedScopes: tool.AcceptedScopes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HasAcceptedScope checks if any of the provided user scopes satisfy the tool's requirements.
|
||||
func (t *ToolScopeInfo) HasAcceptedScope(userScopes ...string) bool {
|
||||
if t == nil || len(t.AcceptedScopes) == 0 {
|
||||
return true // No scopes required
|
||||
}
|
||||
|
||||
userScopeSet := make(map[string]bool)
|
||||
for _, scope := range userScopes {
|
||||
userScopeSet[scope] = true
|
||||
}
|
||||
|
||||
for _, scope := range t.AcceptedScopes {
|
||||
if userScopeSet[scope] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MissingScopes returns the required scopes that are not present in the user's scopes.
|
||||
func (t *ToolScopeInfo) MissingScopes(userScopes ...string) []string {
|
||||
if t == nil || len(t.RequiredScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a set of user scopes for O(1) lookup
|
||||
userScopeSet := make(map[string]bool, len(userScopes))
|
||||
for _, s := range userScopes {
|
||||
userScopeSet[s] = true
|
||||
}
|
||||
|
||||
// Check if any accepted scope is present
|
||||
hasAccepted := false
|
||||
for _, scope := range t.AcceptedScopes {
|
||||
if userScopeSet[scope] {
|
||||
hasAccepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasAccepted {
|
||||
return nil // User has sufficient scopes
|
||||
}
|
||||
|
||||
// Return required scopes as the minimum needed
|
||||
missing := make([]string, len(t.RequiredScopes))
|
||||
copy(missing, t.RequiredScopes)
|
||||
return missing
|
||||
}
|
||||
|
||||
// GetRequiredScopesSlice returns the required scopes as a slice of strings.
|
||||
func (t *ToolScopeInfo) GetRequiredScopesSlice() []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
scopes := make([]string, len(t.RequiredScopes))
|
||||
copy(scopes, t.RequiredScopes)
|
||||
return scopes
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetToolScopeMap(t *testing.T) {
|
||||
// Reset and set up a test map
|
||||
SetGlobalToolScopeMap(ToolScopeMap{
|
||||
"test_tool": &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
})
|
||||
|
||||
m, err := GetToolScopeMap()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, m)
|
||||
require.Greater(t, len(m), 0, "expected at least one tool in the scope map")
|
||||
|
||||
testTool, ok := m["test_tool"]
|
||||
require.True(t, ok, "expected test_tool to be in the scope map")
|
||||
assert.Contains(t, testTool.RequiredScopes, "read:org")
|
||||
assert.Contains(t, testTool.AcceptedScopes, "read:org")
|
||||
assert.Contains(t, testTool.AcceptedScopes, "admin:org")
|
||||
}
|
||||
|
||||
func TestGetToolScopeInfo(t *testing.T) {
|
||||
// Set up test scope map
|
||||
SetGlobalToolScopeMap(ToolScopeMap{
|
||||
"search_orgs": &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
})
|
||||
|
||||
info, err := GetToolScopeInfo("search_orgs")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
|
||||
// Non-existent tool should return nil
|
||||
info, err = GetToolScopeInfo("nonexistent_tool")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, info)
|
||||
}
|
||||
|
||||
func TestToolScopeInfo_HasAcceptedScope(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scopeInfo *ToolScopeInfo
|
||||
userScopes []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "has exact required scope",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"read:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "has parent scope (admin:org grants read:org)",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"admin:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "has parent scope (write:org grants read:org)",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"write:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "missing required scope",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"repo"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no scope required",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{},
|
||||
AcceptedScopes: []string{},
|
||||
},
|
||||
userScopes: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nil scope info",
|
||||
scopeInfo: nil,
|
||||
userScopes: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "repo scope for tool requiring repo",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"repo"},
|
||||
AcceptedScopes: []string{"repo"},
|
||||
},
|
||||
userScopes: []string{"repo"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "missing repo scope",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"repo"},
|
||||
AcceptedScopes: []string{"repo"},
|
||||
},
|
||||
userScopes: []string{"public_repo"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.scopeInfo.HasAcceptedScope(tc.userScopes...)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolScopeInfo_MissingScopes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scopeInfo *ToolScopeInfo
|
||||
userScopes []string
|
||||
expectedLen int
|
||||
expectedScopes []string
|
||||
}{
|
||||
{
|
||||
name: "has required scope - no missing",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"read:org"},
|
||||
expectedLen: 0,
|
||||
expectedScopes: nil,
|
||||
},
|
||||
{
|
||||
name: "missing scope",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{"read:org"},
|
||||
AcceptedScopes: []string{"read:org", "write:org", "admin:org"},
|
||||
},
|
||||
userScopes: []string{"repo"},
|
||||
expectedLen: 1,
|
||||
expectedScopes: []string{"read:org"},
|
||||
},
|
||||
{
|
||||
name: "no scope required - no missing",
|
||||
scopeInfo: &ToolScopeInfo{
|
||||
RequiredScopes: []string{},
|
||||
AcceptedScopes: []string{},
|
||||
},
|
||||
userScopes: []string{},
|
||||
expectedLen: 0,
|
||||
expectedScopes: nil,
|
||||
},
|
||||
{
|
||||
name: "nil scope info - no missing",
|
||||
scopeInfo: nil,
|
||||
userScopes: []string{},
|
||||
expectedLen: 0,
|
||||
expectedScopes: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
missing := tc.scopeInfo.MissingScopes(tc.userScopes...)
|
||||
assert.Len(t, missing, tc.expectedLen)
|
||||
if tc.expectedScopes != nil {
|
||||
for _, expected := range tc.expectedScopes {
|
||||
assert.Contains(t, missing, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Scope represents a GitHub OAuth scope.
|
||||
// These constants define all OAuth scopes used by the GitHub MCP server tools.
|
||||
// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// NoScope indicates no scope is required (public access).
|
||||
NoScope Scope = ""
|
||||
|
||||
// Repo grants full control of private repositories
|
||||
Repo Scope = "repo"
|
||||
|
||||
// PublicRepo grants access to public repositories
|
||||
PublicRepo Scope = "public_repo"
|
||||
|
||||
// ReadOrg grants read-only access to organization membership, teams, and projects
|
||||
ReadOrg Scope = "read:org"
|
||||
|
||||
// WriteOrg grants write access to organization membership and teams
|
||||
WriteOrg Scope = "write:org"
|
||||
|
||||
// AdminOrg grants full control of organizations and teams
|
||||
AdminOrg Scope = "admin:org"
|
||||
|
||||
// Gist grants write access to gists
|
||||
Gist Scope = "gist"
|
||||
|
||||
// Notifications grants access to notifications
|
||||
Notifications Scope = "notifications"
|
||||
|
||||
// ReadProject grants read-only access to projects
|
||||
ReadProject Scope = "read:project"
|
||||
|
||||
// Project grants full control of projects
|
||||
Project Scope = "project"
|
||||
|
||||
// SecurityEvents grants read and write access to security events
|
||||
SecurityEvents Scope = "security_events"
|
||||
|
||||
// User grants read/write access to profile info
|
||||
User Scope = "user"
|
||||
|
||||
// ReadUser grants read-only access to profile info
|
||||
ReadUser Scope = "read:user"
|
||||
|
||||
// UserEmail grants read access to user email addresses
|
||||
UserEmail Scope = "user:email"
|
||||
|
||||
// ReadPackages grants read access to packages
|
||||
ReadPackages Scope = "read:packages"
|
||||
|
||||
// WritePackages grants write access to packages
|
||||
WritePackages Scope = "write:packages"
|
||||
)
|
||||
|
||||
// ScopeHierarchy defines parent-child relationships between scopes.
|
||||
// A parent scope implicitly grants access to all child scopes.
|
||||
// For example, "repo" grants access to "public_repo" and "security_events".
|
||||
var ScopeHierarchy = map[Scope][]Scope{
|
||||
Repo: {PublicRepo, SecurityEvents},
|
||||
AdminOrg: {WriteOrg, ReadOrg},
|
||||
WriteOrg: {ReadOrg},
|
||||
Project: {ReadProject},
|
||||
WritePackages: {ReadPackages},
|
||||
User: {ReadUser, UserEmail},
|
||||
}
|
||||
|
||||
// ScopeSet represents a set of OAuth scopes.
|
||||
type ScopeSet map[Scope]bool
|
||||
|
||||
// NewScopeSet creates a new ScopeSet from the given scopes.
|
||||
func NewScopeSet(scopes ...Scope) ScopeSet {
|
||||
set := make(ScopeSet)
|
||||
for _, scope := range scopes {
|
||||
set[scope] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// ToSlice converts a ScopeSet to a slice of Scope values.
|
||||
func (s ScopeSet) ToSlice() []Scope {
|
||||
scopes := make([]Scope, 0, len(s))
|
||||
for scope := range s {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
// Sort for deterministic output
|
||||
slices.Sort(scopes)
|
||||
return scopes
|
||||
}
|
||||
|
||||
// ToStringSlice converts a ScopeSet to a slice of string values.
|
||||
// The returned slice is sorted for deterministic output.
|
||||
func (s ScopeSet) ToStringSlice() []string {
|
||||
scopes := make([]string, 0, len(s))
|
||||
for scope := range s {
|
||||
scopes = append(scopes, string(scope))
|
||||
}
|
||||
sort.Strings(scopes)
|
||||
return scopes
|
||||
}
|
||||
|
||||
// ToStringSlice converts a slice of Scopes to a slice of strings.
|
||||
func ToStringSlice(scopes ...Scope) []string {
|
||||
result := make([]string, len(scopes))
|
||||
for i, scope := range scopes {
|
||||
result[i] = string(scope)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExpandScopes takes a list of required scopes and returns all accepted scopes
|
||||
// including parent scopes from the hierarchy.
|
||||
// For example, if "public_repo" is required, "repo" is also accepted since
|
||||
// having the "repo" scope grants access to "public_repo".
|
||||
// The returned slice is sorted for deterministic output.
|
||||
func ExpandScopes(required ...Scope) []string {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
accepted := make(map[string]bool)
|
||||
|
||||
// Add required scopes
|
||||
for _, scope := range required {
|
||||
accepted[string(scope)] = true
|
||||
}
|
||||
|
||||
// Add parent scopes that grant access to required scopes
|
||||
for parent, children := range ScopeHierarchy {
|
||||
for _, child := range children {
|
||||
if accepted[string(child)] {
|
||||
accepted[string(parent)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice and sort for deterministic output
|
||||
result := make([]string, 0, len(accepted))
|
||||
for scope := range accepted {
|
||||
result = append(result, scope)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// expandScopeSet returns a set of all scopes granted by the given scopes,
|
||||
// including child scopes from the hierarchy.
|
||||
// For example, if "repo" is provided, the result includes "repo", "public_repo",
|
||||
// and "security_events" since "repo" grants access to those child scopes.
|
||||
func expandScopeSet(scopes []string) map[string]bool {
|
||||
expanded := make(map[string]bool, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
expanded[scope] = true
|
||||
// Add child scopes granted by this scope
|
||||
if children, ok := ScopeHierarchy[Scope(scope)]; ok {
|
||||
for _, child := range children {
|
||||
expanded[string(child)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement.
|
||||
// A tool's acceptedScopes includes both the required scopes AND parent scopes
|
||||
// that implicitly grant the required permissions (via ExpandScopes).
|
||||
//
|
||||
// For PAT filtering: if ANY of the acceptedScopes are granted by the token
|
||||
// (directly or via scope hierarchy), the tool should be visible.
|
||||
//
|
||||
// Returns true if the tool should be visible to the token holder.
|
||||
func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool {
|
||||
// No scopes required = always allowed
|
||||
if len(acceptedScopes) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Expand token scopes to include child scopes they grant
|
||||
grantedScopes := expandScopeSet(tokenScopes)
|
||||
|
||||
// Check if any accepted scope is granted by the token
|
||||
for _, accepted := range acceptedScopes {
|
||||
if grantedScopes[accepted] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExpandScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
required []Scope
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "nil returns nil",
|
||||
required: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "empty returns nil",
|
||||
required: []Scope{},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "repo scope returns just repo",
|
||||
required: []Scope{Repo},
|
||||
expected: []string{"repo"},
|
||||
},
|
||||
{
|
||||
name: "public_repo also accepts repo (parent)",
|
||||
required: []Scope{PublicRepo},
|
||||
expected: []string{"public_repo", "repo"},
|
||||
},
|
||||
{
|
||||
name: "security_events also accepts repo (parent)",
|
||||
required: []Scope{SecurityEvents},
|
||||
expected: []string{"repo", "security_events"},
|
||||
},
|
||||
{
|
||||
name: "read:org also accepts write:org and admin:org (parents)",
|
||||
required: []Scope{ReadOrg},
|
||||
expected: []string{"admin:org", "read:org", "write:org"},
|
||||
},
|
||||
{
|
||||
name: "write:org also accepts admin:org (parent)",
|
||||
required: []Scope{WriteOrg},
|
||||
expected: []string{"admin:org", "write:org"},
|
||||
},
|
||||
{
|
||||
name: "admin:org returns just admin:org (no parent)",
|
||||
required: []Scope{AdminOrg},
|
||||
expected: []string{"admin:org"},
|
||||
},
|
||||
{
|
||||
name: "read:project also accepts project (parent)",
|
||||
required: []Scope{ReadProject},
|
||||
expected: []string{"project", "read:project"},
|
||||
},
|
||||
{
|
||||
name: "project returns just project (no parent)",
|
||||
required: []Scope{Project},
|
||||
expected: []string{"project"},
|
||||
},
|
||||
{
|
||||
name: "gist returns just gist (no parent)",
|
||||
required: []Scope{Gist},
|
||||
expected: []string{"gist"},
|
||||
},
|
||||
{
|
||||
name: "notifications returns just notifications (no parent)",
|
||||
required: []Scope{Notifications},
|
||||
expected: []string{"notifications"},
|
||||
},
|
||||
{
|
||||
name: "read:packages also accepts write:packages (parent)",
|
||||
required: []Scope{ReadPackages},
|
||||
expected: []string{"read:packages", "write:packages"},
|
||||
},
|
||||
{
|
||||
name: "read:user also accepts user (parent)",
|
||||
required: []Scope{ReadUser},
|
||||
expected: []string{"read:user", "user"},
|
||||
},
|
||||
{
|
||||
name: "multiple scopes combine correctly",
|
||||
required: []Scope{PublicRepo, ReadOrg},
|
||||
expected: []string{"admin:org", "public_repo", "read:org", "repo", "write:org"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExpandScopes(tt.required...)
|
||||
|
||||
// Sort both for consistent comparison
|
||||
if result != nil {
|
||||
sort.Strings(result)
|
||||
}
|
||||
if tt.expected != nil {
|
||||
sort.Strings(tt.expected)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSlice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes []Scope
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty returns empty",
|
||||
scopes: []Scope{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single scope",
|
||||
scopes: []Scope{Repo},
|
||||
expected: []string{"repo"},
|
||||
},
|
||||
{
|
||||
name: "multiple scopes",
|
||||
scopes: []Scope{Repo, Gist, ReadOrg},
|
||||
expected: []string{"repo", "gist", "read:org"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ToStringSlice(tt.scopes...)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeHierarchy(t *testing.T) {
|
||||
// Verify the hierarchy is correctly defined
|
||||
assert.Contains(t, ScopeHierarchy[Repo], PublicRepo)
|
||||
assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents)
|
||||
assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg)
|
||||
assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg)
|
||||
assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg)
|
||||
assert.Contains(t, ScopeHierarchy[Project], ReadProject)
|
||||
assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages)
|
||||
assert.Contains(t, ScopeHierarchy[User], ReadUser)
|
||||
assert.Contains(t, ScopeHierarchy[User], UserEmail)
|
||||
}
|
||||
|
||||
func TestExpandScopeSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes []string
|
||||
expected map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "empty scopes",
|
||||
scopes: []string{},
|
||||
expected: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "repo expands to include public_repo and security_events",
|
||||
scopes: []string{"repo"},
|
||||
expected: map[string]bool{
|
||||
"repo": true,
|
||||
"public_repo": true,
|
||||
"security_events": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "admin:org expands to include write:org and read:org",
|
||||
scopes: []string{"admin:org"},
|
||||
expected: map[string]bool{
|
||||
"admin:org": true,
|
||||
"write:org": true,
|
||||
"read:org": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write:org expands to include read:org",
|
||||
scopes: []string{"write:org"},
|
||||
expected: map[string]bool{
|
||||
"write:org": true,
|
||||
"read:org": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user expands to include read:user and user:email",
|
||||
scopes: []string{"user"},
|
||||
expected: map[string]bool{
|
||||
"user": true,
|
||||
"read:user": true,
|
||||
"user:email": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scope without children stays as-is",
|
||||
scopes: []string{"gist"},
|
||||
expected: map[string]bool{
|
||||
"gist": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple scopes combine correctly",
|
||||
scopes: []string{"repo", "gist"},
|
||||
expected: map[string]bool{
|
||||
"repo": true,
|
||||
"public_repo": true,
|
||||
"security_events": true,
|
||||
"gist": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := expandScopeSet(tt.scopes)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRequiredScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenScopes []string
|
||||
acceptedScopes []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no accepted scopes - always allowed",
|
||||
tokenScopes: []string{},
|
||||
acceptedScopes: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nil accepted scopes - always allowed",
|
||||
tokenScopes: []string{"repo"},
|
||||
acceptedScopes: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token has exact required scope",
|
||||
tokenScopes: []string{"repo"},
|
||||
acceptedScopes: []string{"repo"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token has parent scope that grants access",
|
||||
tokenScopes: []string{"repo"},
|
||||
acceptedScopes: []string{"public_repo"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token has parent scope for security_events",
|
||||
tokenScopes: []string{"repo"},
|
||||
acceptedScopes: []string{"security_events"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token has admin:org which grants read:org",
|
||||
tokenScopes: []string{"admin:org"},
|
||||
acceptedScopes: []string{"read:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token has write:org which grants read:org",
|
||||
tokenScopes: []string{"write:org"},
|
||||
acceptedScopes: []string{"read:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "token missing required scope",
|
||||
tokenScopes: []string{"gist"},
|
||||
acceptedScopes: []string{"repo"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "token has child but not parent - fails",
|
||||
tokenScopes: []string{"public_repo"},
|
||||
acceptedScopes: []string{"repo"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple token scopes - one matches",
|
||||
tokenScopes: []string{"gist", "repo"},
|
||||
acceptedScopes: []string{"public_repo"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple accepted scopes - token has one",
|
||||
tokenScopes: []string{"repo"},
|
||||
acceptedScopes: []string{"repo", "admin:org"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty token scopes - fails when scopes required",
|
||||
tokenScopes: []string{},
|
||||
acceptedScopes: []string{"repo"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "user scope grants read:user",
|
||||
tokenScopes: []string{"user"},
|
||||
acceptedScopes: []string{"read:user"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "user scope grants user:email",
|
||||
tokenScopes: []string{"user"},
|
||||
acceptedScopes: []string{"user:email"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "write:packages grants read:packages",
|
||||
tokenScopes: []string{"write:packages"},
|
||||
acceptedScopes: []string{"read:packages"},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user