Files
kandev/apps/backend/internal/github/auth_resolver.go
T
2026-07-27 18:10:12 +01:00

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
}