Files
2026-09-23 13:31:12 +01:00

315 lines
9.5 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// resolvedServiceClient is the operation-scoped GitHub client selected for a
// workspace and purpose. The cache namespace must accompany the client so
// service-level caches never coalesce results across credentials.
type resolvedServiceClient struct {
Client Client
Principal AuthPrincipal
CacheScope string
Capabilities map[GitHubAppCapability]bool
RateTracker *RateTracker
credential *ResolvedCredential
}
// repositoryAccessChecker performs a read-only repository-root probe. It is
// intentionally separate from merge-method discovery so an authorization
// check cannot populate operation caches or consume operation-specific test
// seams.
type repositoryAccessChecker interface {
HasRepositoryAccess(context.Context, string, string) (bool, error)
}
const automationVisibilityConcurrency = 8
func requireGitHubCapability(resolved *resolvedServiceClient, capability GitHubAppCapability) error {
if resolved == nil {
return ErrGitHubNotConfigured
}
// Human PAT, CLI, and App-user OAuth permissions are ultimately enforced
// by GitHub for that user. Installation principals have an explicit,
// verifiable permission map and must fail before making the provider call.
if resolved.Principal.Kind == AuthPrincipalApp && !resolved.Capabilities[capability] {
return fmt.Errorf("%w: %s", ErrGitHubCapabilityDenied, capability)
}
return nil
}
func (s *Service) resolveServiceClient(
ctx context.Context,
workspaceID string,
userID string,
purpose CredentialPurpose,
owner string,
repo string,
) (*resolvedServiceClient, error) {
if strings.TrimSpace(workspaceID) == "" {
return nil, ErrGitHubWorkspaceRequired
}
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return nil, err
}
if s == nil || s.resolver == nil {
return nil, ErrGitHubNotConfigured
}
resolved, err := s.resolver.Resolve(ctx, ResolveCredentialRequest{
WorkspaceID: workspaceID,
UserID: userID,
Purpose: purpose,
RepoOwner: owner,
RepoName: repo,
})
if err != nil {
return nil, err
}
if resolved == nil || resolved.Client == nil {
return nil, ErrGitHubNotConfigured
}
return &resolvedServiceClient{
Client: resolved.Client,
Principal: resolved.Principal,
CacheScope: credentialCacheScope(resolved, purpose),
Capabilities: resolved.Capabilities,
RateTracker: resolved.RateTracker,
credential: resolved,
}, nil
}
// cacheScopeForPurpose keeps cache invalidation tied to the same resolved
// credential identity while selecting the namespace used by another operation
// purpose. Personal reads and writes intentionally have distinct namespaces.
func (r *resolvedServiceClient) cacheScopeForPurpose(purpose CredentialPurpose) string {
if r == nil || r.credential == nil {
return ""
}
return credentialCacheScope(r.credential, purpose)
}
func credentialCacheScope(resolved *ResolvedCredential, purpose CredentialPurpose) string {
principal := resolved.Principal
return fmt.Sprintf(
"%d:%s|%d:%s|%d:%s|%d:%s|%d:%s|%d|%d|%d:%s|%d|%d:%s",
len(principal.WorkspaceID), principal.WorkspaceID,
len(principal.UserID), principal.UserID,
len(principal.Source), principal.Source,
len(principal.Kind), principal.Kind,
len(principal.Login), principal.Login,
principal.InstallationID, resolved.CredentialGeneration,
len(resolved.AppRegistrationID), resolved.AppRegistrationID,
resolved.AppCredentialGeneration,
len(purpose), purpose,
)
}
func scopedCacheKey(scope, key string) string {
return fmt.Sprintf("%d:%s|%s", len(scope), scope, key)
}
func (s *Service) isRepoCachedAsMissingForScope(scope, owner, repo string) bool {
if s == nil || s.repoErrorCache == nil {
return false
}
v, ok := s.repoErrorCache.get(scopedCacheKey(scope, repoErrorCacheKey(owner, repo)))
if !ok {
return false
}
_, isErr := v.(cachedErr)
return isErr
}
func (s *Service) markRepoAsMissingForScope(scope, owner, repo string, gen uint64) {
if s == nil || s.repoErrorCache == nil {
return
}
s.repoErrorCache.setIfCurrentGeneration(
scopedCacheKey(scope, repoErrorCacheKey(owner, repo)),
cachedErr{err: ErrRepoNotResolvable},
gen,
)
}
func (s *Service) resolveAutomationClient(
ctx context.Context,
workspaceID string,
owner string,
repo string,
) (*resolvedServiceClient, error) {
return s.resolveServiceClient(ctx, workspaceID, "", CredentialPurposeAutomation, owner, repo)
}
func (s *Service) resolvePersonalReadClient(
ctx context.Context,
workspaceID string,
userID string,
owner string,
repo string,
) (*resolvedServiceClient, error) {
if err := s.ensurePersonalRepositoryBoundary(ctx, workspaceID, owner, repo); err != nil {
return nil, err
}
return s.resolveServiceClient(ctx, workspaceID, userID, CredentialPurposePersonalRead, owner, repo)
}
func (s *Service) resolvePersonalWriteClient(
ctx context.Context,
workspaceID string,
userID string,
owner string,
repo string,
) (*resolvedServiceClient, error) {
if err := s.ensurePersonalRepositoryBoundary(ctx, workspaceID, owner, repo); err != nil {
return nil, err
}
return s.resolveServiceClient(ctx, workspaceID, userID, CredentialPurposePersonalWrite, owner, repo)
}
func (s *Service) ensurePersonalRepositoryBoundary(
ctx context.Context,
workspaceID, owner, repo string,
) error {
if strings.TrimSpace(owner) == "" || strings.TrimSpace(repo) == "" {
return nil
}
if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil {
return err
}
automation, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo)
if err != nil {
return err
}
checker, ok := automation.Client.(repositoryAccessChecker)
if !ok {
return fmt.Errorf("verify automation repository access for %s/%s: unsupported client", owner, repo)
}
visible, err := checker.HasRepositoryAccess(ctx, owner, repo)
if err == nil && visible {
return nil
}
var apiErr *GitHubAPIError
if err == nil || isRepoNotResolvableErr(err) || (errors.As(err, &apiErr) && apiErr.StatusCode == 404) {
return fmt.Errorf("%w: automation connection cannot access %s/%s", ErrRepoNotResolvable, owner, repo)
}
return fmt.Errorf("verify automation repository access for %s/%s: %w", owner, repo, err)
}
// automationRepositoryVisibility returns the exact subset of repository
// identities visible to the workspace automation principal. Personal list
// and batch operations use it after provider search so a broad user token
// cannot reveal repositories outside an App installation.
func (s *Service) automationRepositoryVisibility(
ctx context.Context,
workspaceID string,
repositories []RepoFilter,
) (map[string]bool, error) {
resolved, err := s.resolveAutomationClient(ctx, workspaceID, "", "")
if err != nil {
return nil, err
}
checker, ok := resolved.Client.(repositoryAccessChecker)
if !ok {
return nil, errors.New("GitHub automation client cannot verify repository access")
}
unique := make(map[string]RepoFilter, len(repositories))
for _, repository := range repositories {
key := repositoryIdentityKey(repository.Owner, repository.Name)
if key != "/" && key != "" {
unique[key] = repository
}
}
visible := make(map[string]bool, len(unique))
var resultMu sync.Mutex
var firstErr error
sem := make(chan struct{}, automationVisibilityConcurrency)
var wg sync.WaitGroup
for key, repository := range unique {
wg.Add(1)
go func(key string, repository RepoFilter) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
resultMu.Lock()
if firstErr == nil {
firstErr = ctx.Err()
}
resultMu.Unlock()
return
}
defer func() { <-sem }()
allowed, checkErr := checker.HasRepositoryAccess(ctx, repository.Owner, repository.Name)
if checkErr != nil && !repositoryAccessDenied(checkErr) {
resultMu.Lock()
if firstErr == nil {
firstErr = checkErr
}
resultMu.Unlock()
return
}
resultMu.Lock()
visible[key] = checkErr == nil && allowed
resultMu.Unlock()
}(key, repository)
}
wg.Wait()
return visible, firstErr
}
func repositoryAccessDenied(err error) bool {
if err == nil || isRepoNotResolvableErr(err) {
return err != nil
}
var apiErr *GitHubAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 404
}
func repositoryIdentityKey(owner, repo string) string {
return strings.ToLower(strings.TrimSpace(owner)) + "/" + strings.ToLower(strings.TrimSpace(repo))
}
// ensureRepositoryInWorkspaceScope enforces Kandev's configured repository
// boundary before a provider call. GitHub App installation visibility is
// enforced by GitHub itself, so the two checks form an intersection.
func (s *Service) ensureRepositoryInWorkspaceScope(ctx context.Context, workspaceID, owner, repo string) error {
if strings.TrimSpace(owner) == "" || strings.TrimSpace(repo) == "" {
return nil
}
settings, err := s.GetWorkspaceSettings(ctx, workspaceID)
if err != nil {
return err
}
if repositoryInWorkspaceScope(settings, owner, repo) {
return nil
}
return ErrRepoNotResolvable
}
func repositoryInWorkspaceScope(settings *WorkspaceSettings, owner, repo string) bool {
if settings == nil || settings.RepoScopeMode == "" || settings.RepoScopeMode == RepoScopeModeAll {
return true
}
switch settings.RepoScopeMode {
case RepoScopeModeOrgs:
for _, org := range settings.RepoScopeOrgs {
if strings.EqualFold(strings.TrimSpace(org), strings.TrimSpace(owner)) {
return true
}
}
case RepoScopeModeRepos:
for _, candidate := range settings.RepoScopeRepos {
if strings.EqualFold(candidate.Owner, owner) && strings.EqualFold(candidate.Name, repo) {
return true
}
}
}
return false
}