585 lines
17 KiB
Go
585 lines
17 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const credentialCacheRefreshMargin = 5 * time.Minute
|
|
|
|
var errCredentialResolutionInvalidated = errors.New("GitHub credential resolution invalidated")
|
|
|
|
type workspaceConnectionReader interface {
|
|
GetWorkspaceConnection(ctx context.Context, workspaceID string) (*WorkspaceConnection, error)
|
|
GetUserConnection(ctx context.Context, workspaceID, userID string) (*UserConnection, error)
|
|
}
|
|
|
|
type authSecretReader interface {
|
|
Reveal(ctx context.Context, id string) (string, error)
|
|
}
|
|
|
|
type installationCredentialProvider interface {
|
|
ResolveInstallation(
|
|
ctx context.Context,
|
|
connection *WorkspaceConnection,
|
|
req ResolveCredentialRequest,
|
|
) (*ResolvedCredential, error)
|
|
}
|
|
|
|
type appCredentialGenerationProvider interface {
|
|
AppCredentialGeneration(registrationID string) (int64, bool)
|
|
}
|
|
|
|
type userCredentialProvider interface {
|
|
ResolveUser(
|
|
ctx context.Context,
|
|
connection *UserConnection,
|
|
req ResolveCredentialRequest,
|
|
) (*ResolvedCredential, error)
|
|
}
|
|
|
|
type automationCredentialProvider interface {
|
|
ResolveAutomation(
|
|
ctx context.Context,
|
|
connection *WorkspaceConnection,
|
|
req ResolveCredentialRequest,
|
|
) (*ResolvedCredential, error)
|
|
}
|
|
|
|
type legacyCredentialFactory func(ctx context.Context) (Client, string, error)
|
|
type legacyTransportCredentialFactory func(ctx context.Context) (Client, string, string, error)
|
|
type ghAccountTokenResolver func(ctx context.Context, host, login string) (string, error)
|
|
|
|
type credentialCacheKey struct {
|
|
workspaceID string
|
|
source ConnectionSource
|
|
generation int64
|
|
appRegistrationID string
|
|
appCredentialGeneration int64
|
|
purpose CredentialPurpose
|
|
repoOwner string
|
|
repoName string
|
|
}
|
|
|
|
type credentialCacheEpoch struct {
|
|
all uint64
|
|
workspace uint64
|
|
}
|
|
|
|
type credentialCacheEntry struct {
|
|
resolved *ResolvedCredential
|
|
cachedAt time.Time
|
|
}
|
|
|
|
// CredentialResolver owns credential selection for all GitHub operations.
|
|
// Its cache is keyed by workspace and generation so replacement or revocation
|
|
// cannot reuse a client created for another principal.
|
|
type CredentialResolver struct {
|
|
connections workspaceConnectionReader
|
|
secrets authSecretReader
|
|
app installationCredentialProvider
|
|
users userCredentialProvider
|
|
automation automationCredentialProvider
|
|
legacy legacyCredentialFactory
|
|
legacyGit legacyTransportCredentialFactory
|
|
ghToken ghAccountTokenResolver
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
cache map[credentialCacheKey]credentialCacheEntry
|
|
allEpoch uint64
|
|
workspaceEpochs map[string]uint64
|
|
}
|
|
|
|
func NewCredentialResolver(connections workspaceConnectionReader, secrets authSecretReader) *CredentialResolver {
|
|
return &CredentialResolver{
|
|
connections: connections,
|
|
secrets: secrets,
|
|
legacy: func(ctx context.Context) (Client, string, error) {
|
|
return nil, AuthMethodNone, ErrGitHubNotConfigured
|
|
},
|
|
legacyGit: func(ctx context.Context) (Client, string, string, error) {
|
|
return nil, AuthMethodNone, "", ErrGitHubNotConfigured
|
|
},
|
|
ghToken: ResolveGHAccountToken,
|
|
now: time.Now,
|
|
cache: make(map[credentialCacheKey]credentialCacheEntry),
|
|
workspaceEpochs: make(map[string]uint64),
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) SetLegacyFactory(factory legacyCredentialFactory) {
|
|
if factory != nil {
|
|
r.legacy = factory
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) SetLegacyTransportFactory(factory legacyTransportCredentialFactory) {
|
|
if factory != nil {
|
|
r.legacyGit = factory
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) SetInstallationProvider(provider installationCredentialProvider) {
|
|
r.app = provider
|
|
}
|
|
|
|
func (r *CredentialResolver) SetUserProvider(provider userCredentialProvider) {
|
|
r.users = provider
|
|
}
|
|
|
|
func (r *CredentialResolver) SetAutomationProvider(provider automationCredentialProvider) {
|
|
r.automation = provider
|
|
}
|
|
|
|
func (r *CredentialResolver) Resolve(ctx context.Context, req ResolveCredentialRequest) (*ResolvedCredential, error) {
|
|
if req.WorkspaceID == "" {
|
|
return nil, ErrGitHubWorkspaceRequired
|
|
}
|
|
if !validCredentialPurpose(req.Purpose) {
|
|
return nil, fmt.Errorf("invalid GitHub credential purpose %q", req.Purpose)
|
|
}
|
|
for {
|
|
epoch := r.epoch(req.WorkspaceID)
|
|
resolved, err := r.resolveAtEpoch(ctx, req, epoch)
|
|
if !r.epochCurrent(req.WorkspaceID, epoch) || errors.Is(err, errCredentialResolutionInvalidated) {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
runtime.Gosched()
|
|
continue
|
|
}
|
|
return resolved, err
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveAtEpoch(
|
|
ctx context.Context,
|
|
req ResolveCredentialRequest,
|
|
epoch credentialCacheEpoch,
|
|
) (*ResolvedCredential, error) {
|
|
connection, err := r.connections.GetWorkspaceConnection(ctx, req.WorkspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load GitHub workspace connection: %w", err)
|
|
}
|
|
if connection == nil {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
|
|
if req.Purpose == CredentialPurposePersonalRead || req.Purpose == CredentialPurposePersonalWrite {
|
|
if personal, resolveErr := r.resolvePersonal(ctx, req, connection); personal != nil || resolveErr != nil {
|
|
return personal, resolveErr
|
|
}
|
|
}
|
|
return r.resolveAutomation(ctx, req, connection, epoch)
|
|
}
|
|
|
|
func validCredentialPurpose(purpose CredentialPurpose) bool {
|
|
switch purpose {
|
|
case CredentialPurposeAutomation, CredentialPurposePersonalRead,
|
|
CredentialPurposePersonalWrite, CredentialPurposeGitTransport:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) resolvePersonal(
|
|
ctx context.Context,
|
|
req ResolveCredentialRequest,
|
|
automation *WorkspaceConnection,
|
|
) (*ResolvedCredential, error) {
|
|
personal, err := r.resolveConfiguredPersonal(ctx, req)
|
|
if personal != nil {
|
|
return personal, err
|
|
}
|
|
if err != nil && (req.Purpose != CredentialPurposePersonalWrite || !errors.Is(err, ErrGitHubConnectionInvalid)) {
|
|
return nil, err
|
|
}
|
|
|
|
// A human PAT/CLI automation identity can supply authenticated-viewer
|
|
// semantics. An App installation cannot impersonate a person.
|
|
if automation.Source == ConnectionSourcePAT || automation.Source == ConnectionSourceGHCLI || automation.Source == ConnectionSourceLegacyShared {
|
|
return nil, nil
|
|
}
|
|
if req.Purpose == CredentialPurposePersonalWrite {
|
|
// Manual mutations may intentionally fall back to the App. The returned
|
|
// principal lets the caller disclose that attribution.
|
|
return nil, nil
|
|
}
|
|
return nil, ErrGitHubPersonalRequired
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveConfiguredPersonal(
|
|
ctx context.Context,
|
|
req ResolveCredentialRequest,
|
|
) (*ResolvedCredential, error) {
|
|
if req.UserID == "" || r.users == nil {
|
|
return nil, nil
|
|
}
|
|
connection, err := r.connections.GetUserConnection(ctx, req.WorkspaceID, req.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load personal GitHub connection: %w", err)
|
|
}
|
|
if connection == nil {
|
|
return nil, nil
|
|
}
|
|
if connection.Status != ConnectionStatusActive {
|
|
return nil, ErrGitHubConnectionInvalid
|
|
}
|
|
resolved, err := r.users.ResolveUser(ctx, connection, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolved.Principal.WorkspaceID = req.WorkspaceID
|
|
resolved.Principal.UserID = req.UserID
|
|
resolved.CredentialGeneration = connection.CredentialGeneration
|
|
return resolved, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveAutomation(
|
|
ctx context.Context,
|
|
req ResolveCredentialRequest,
|
|
connection *WorkspaceConnection,
|
|
epoch credentialCacheEpoch,
|
|
) (*ResolvedCredential, error) {
|
|
if connection.Status != ConnectionStatusActive {
|
|
return nil, ErrGitHubConnectionInvalid
|
|
}
|
|
if connection.Source == ConnectionSourceGitHubAppInstallation &&
|
|
strings.TrimSpace(connection.AppRegistrationID) == "" {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
key, err := r.automationCacheKey(connection, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cached := r.cached(key); cached != nil {
|
|
return cached, nil
|
|
}
|
|
|
|
resolved, err := r.resolveAutomationSource(ctx, connection, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resolved == nil || resolved.Client == nil {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
resolved.Principal.WorkspaceID = req.WorkspaceID
|
|
resolved.CredentialGeneration = connection.CredentialGeneration
|
|
if err := applyAppCredentialMetadata(connection, resolved, &key); err != nil {
|
|
return nil, err
|
|
}
|
|
if !resolved.ExpiresAt.IsZero() && !resolved.ExpiresAt.After(r.now()) {
|
|
return nil, ErrInstallationTokenExpired
|
|
}
|
|
if !r.storeCached(key, resolved, epoch) {
|
|
return nil, errCredentialResolutionInvalidated
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) automationCacheKey(
|
|
connection *WorkspaceConnection,
|
|
req ResolveCredentialRequest,
|
|
) (credentialCacheKey, error) {
|
|
key := credentialCacheKey{
|
|
workspaceID: req.WorkspaceID, source: connection.Source,
|
|
generation: connection.CredentialGeneration, appRegistrationID: connection.AppRegistrationID,
|
|
purpose: req.Purpose, repoOwner: strings.ToLower(strings.TrimSpace(req.RepoOwner)),
|
|
repoName: strings.ToLower(strings.TrimSpace(req.RepoName)),
|
|
}
|
|
if connection.Source != ConnectionSourceGitHubAppInstallation {
|
|
return key, nil
|
|
}
|
|
provider, ok := r.app.(appCredentialGenerationProvider)
|
|
if !ok {
|
|
return key, nil
|
|
}
|
|
generation, found := provider.AppCredentialGeneration(connection.AppRegistrationID)
|
|
if !found {
|
|
return credentialCacheKey{}, ErrGitHubNotConfigured
|
|
}
|
|
key.appCredentialGeneration = generation
|
|
return key, nil
|
|
}
|
|
|
|
func applyAppCredentialMetadata(
|
|
connection *WorkspaceConnection,
|
|
resolved *ResolvedCredential,
|
|
key *credentialCacheKey,
|
|
) error {
|
|
if connection.Source != ConnectionSourceGitHubAppInstallation {
|
|
return nil
|
|
}
|
|
if resolved.AppRegistrationID == "" {
|
|
resolved.AppRegistrationID = connection.AppRegistrationID
|
|
}
|
|
if resolved.Principal.AppRegistrationID == "" {
|
|
resolved.Principal.AppRegistrationID = resolved.AppRegistrationID
|
|
}
|
|
if resolved.Principal.AppRegistrationID != connection.AppRegistrationID ||
|
|
resolved.AppRegistrationID != connection.AppRegistrationID {
|
|
return ErrGitHubNotConfigured
|
|
}
|
|
resolved.Principal.AppCredentialGeneration = resolved.AppCredentialGeneration
|
|
key.appCredentialGeneration = resolved.AppCredentialGeneration
|
|
return nil
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveAutomationSource(
|
|
ctx context.Context,
|
|
connection *WorkspaceConnection,
|
|
req ResolveCredentialRequest,
|
|
) (*ResolvedCredential, error) {
|
|
if r.automation != nil {
|
|
return r.automation.ResolveAutomation(ctx, connection, req)
|
|
}
|
|
switch connection.Source {
|
|
case ConnectionSourcePAT:
|
|
return r.resolvePAT(ctx, connection)
|
|
case ConnectionSourceGHCLI:
|
|
return r.resolveGHCLI(ctx, connection)
|
|
case ConnectionSourceGitHubAppInstallation:
|
|
if r.app == nil {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
return r.app.ResolveInstallation(ctx, connection, req)
|
|
case ConnectionSourceLegacyShared:
|
|
return r.resolveLegacy(ctx, connection, req.Purpose)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported GitHub connection source %q", connection.Source)
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) resolvePAT(ctx context.Context, connection *WorkspaceConnection) (*ResolvedCredential, error) {
|
|
if r.secrets == nil {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
token, err := r.secrets.Reveal(ctx, WorkspacePATSecretKey(connection.WorkspaceID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reveal workspace GitHub PAT: %w", err)
|
|
}
|
|
tracker := NewRateTracker(nil, nil)
|
|
client := NewTokenClient(token, TokenPrincipal{
|
|
Kind: TokenCredentialPAT,
|
|
PrincipalID: "login:" + connection.Login,
|
|
Login: connection.Login,
|
|
}).WithRateTracker(tracker)
|
|
return &ResolvedCredential{
|
|
Client: client,
|
|
Capabilities: allTokenCapabilities(),
|
|
credential: token,
|
|
Principal: AuthPrincipal{
|
|
Kind: AuthPrincipalHuman,
|
|
Source: ConnectionSourcePAT,
|
|
Login: connection.Login,
|
|
},
|
|
RateTracker: tracker,
|
|
}, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveGHCLI(ctx context.Context, connection *WorkspaceConnection) (*ResolvedCredential, error) {
|
|
token, err := r.ghToken(ctx, connection.GitHubHost, connection.Login)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tracker := NewRateTracker(nil, nil)
|
|
client := NewTokenClient(token, TokenPrincipal{
|
|
Kind: TokenCredentialCLI,
|
|
PrincipalID: "login:" + connection.Login,
|
|
Login: connection.Login,
|
|
}).WithRateTracker(tracker)
|
|
return &ResolvedCredential{
|
|
Client: client,
|
|
Capabilities: allTokenCapabilities(),
|
|
credential: token,
|
|
Principal: AuthPrincipal{
|
|
Kind: AuthPrincipalHuman,
|
|
Source: ConnectionSourceGHCLI,
|
|
Login: connection.Login,
|
|
},
|
|
RateTracker: tracker,
|
|
}, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) resolveLegacy(
|
|
ctx context.Context,
|
|
connection *WorkspaceConnection,
|
|
purpose CredentialPurpose,
|
|
) (*ResolvedCredential, error) {
|
|
var (
|
|
client Client
|
|
method string
|
|
credential string
|
|
err error
|
|
)
|
|
if purpose == CredentialPurposeGitTransport {
|
|
client, method, credential, err = r.legacyGit(ctx)
|
|
} else {
|
|
client, method, err = r.legacy(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if client == nil || method == AuthMethodNone ||
|
|
(purpose == CredentialPurposeGitTransport && strings.TrimSpace(credential) == "") {
|
|
return nil, ErrGitHubNotConfigured
|
|
}
|
|
login, _ := client.GetAuthenticatedUser(ctx)
|
|
return &ResolvedCredential{
|
|
Client: client,
|
|
Capabilities: allTokenCapabilities(),
|
|
credential: credential,
|
|
Principal: AuthPrincipal{
|
|
Kind: AuthPrincipalHuman,
|
|
Source: ConnectionSourceLegacyShared,
|
|
Login: login,
|
|
},
|
|
RateTracker: NewRateTracker(nil, nil),
|
|
}, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) cached(key credentialCacheKey) *ResolvedCredential {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
entry, ok := r.cache[key]
|
|
if !ok || entry.resolved == nil {
|
|
return nil
|
|
}
|
|
if key.source == ConnectionSourceGHCLI && !entry.cachedAt.Add(credentialCacheRefreshMargin).After(r.now()) {
|
|
delete(r.cache, key)
|
|
return nil
|
|
}
|
|
resolved := entry.resolved
|
|
if !resolved.ExpiresAt.IsZero() && !resolved.ExpiresAt.After(r.now().Add(credentialCacheRefreshMargin)) {
|
|
delete(r.cache, key)
|
|
return nil
|
|
}
|
|
return cloneResolvedCredential(resolved)
|
|
}
|
|
|
|
func (r *CredentialResolver) storeCached(
|
|
key credentialCacheKey,
|
|
resolved *ResolvedCredential,
|
|
epoch credentialCacheEpoch,
|
|
) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.epochCurrentLocked(key.workspaceID, epoch) {
|
|
return false
|
|
}
|
|
for existing := range r.cache {
|
|
if existing.workspaceID == key.workspaceID && existing.generation < key.generation {
|
|
delete(r.cache, existing)
|
|
}
|
|
}
|
|
r.cache[key] = credentialCacheEntry{resolved: cloneResolvedCredential(resolved), cachedAt: r.now()}
|
|
return true
|
|
}
|
|
|
|
func cloneResolvedCredential(resolved *ResolvedCredential) *ResolvedCredential {
|
|
if resolved == nil {
|
|
return nil
|
|
}
|
|
cloned := *resolved
|
|
if resolved.Capabilities != nil {
|
|
cloned.Capabilities = make(map[GitHubAppCapability]bool, len(resolved.Capabilities))
|
|
for capability, allowed := range resolved.Capabilities {
|
|
cloned.Capabilities[capability] = allowed
|
|
}
|
|
}
|
|
return &cloned
|
|
}
|
|
|
|
var allGitHubCapabilities = []GitHubAppCapability{
|
|
CapabilityRepositoryRead,
|
|
CapabilityGitRead,
|
|
CapabilityGitWrite,
|
|
CapabilityPullRequestRead,
|
|
CapabilityPullRequestWrite,
|
|
CapabilityIssueRead,
|
|
CapabilityIssueWrite,
|
|
CapabilityChecksRead,
|
|
CapabilityStatusesRead,
|
|
CapabilityActionsRead,
|
|
CapabilityBranchProtectionRead,
|
|
CapabilityMembersRead,
|
|
CapabilityWorkflowsWrite,
|
|
}
|
|
|
|
func allTokenCapabilities() map[GitHubAppCapability]bool {
|
|
capabilities := make(map[GitHubAppCapability]bool, len(allGitHubCapabilities))
|
|
for _, capability := range allGitHubCapabilities {
|
|
capabilities[capability] = true
|
|
}
|
|
return capabilities
|
|
}
|
|
|
|
func (r *CredentialResolver) InvalidateWorkspace(workspaceID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.workspaceEpochs[workspaceID]++
|
|
for key := range r.cache {
|
|
if key.workspaceID == workspaceID {
|
|
delete(r.cache, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) InvalidateAll() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.allEpoch++
|
|
clear(r.cache)
|
|
}
|
|
|
|
func (r *CredentialResolver) InvalidateAppRegistration(registrationID string) {
|
|
if strings.TrimSpace(registrationID) == "" {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for key := range r.cache {
|
|
if key.appRegistrationID == registrationID {
|
|
delete(r.cache, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *CredentialResolver) appCredentialGeneration(registrationID string) (int64, error) {
|
|
if strings.TrimSpace(registrationID) == "" || r.app == nil {
|
|
return 0, ErrGitHubNotConfigured
|
|
}
|
|
provider, ok := r.app.(appCredentialGenerationProvider)
|
|
if !ok {
|
|
return 0, ErrGitHubNotConfigured
|
|
}
|
|
generation, found := provider.AppCredentialGeneration(registrationID)
|
|
if !found || generation <= 0 {
|
|
return 0, ErrGitHubNotConfigured
|
|
}
|
|
return generation, nil
|
|
}
|
|
|
|
func (r *CredentialResolver) epoch(workspaceID string) credentialCacheEpoch {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return credentialCacheEpoch{all: r.allEpoch, workspace: r.workspaceEpochs[workspaceID]}
|
|
}
|
|
|
|
func (r *CredentialResolver) epochCurrent(workspaceID string, expected credentialCacheEpoch) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.epochCurrentLocked(workspaceID, expected)
|
|
}
|
|
|
|
func (r *CredentialResolver) epochCurrentLocked(workspaceID string, expected credentialCacheEpoch) bool {
|
|
return r.allEpoch == expected.all && r.workspaceEpochs[workspaceID] == expected.workspace
|
|
}
|