chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+137
View File
@@ -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)
}
+237
View File
@@ -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)
}
+129
View File
@@ -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
}
+194
View File
@@ -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)
}
}
})
}
}
+195
View File
@@ -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
}
+332
View File
@@ -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)
})
}
}