Files
kandev/apps/backend/internal/github/credential_broker.go
T

412 lines
12 KiB
Go

package github
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
"time"
)
const (
defaultCredentialLeaseTTL = 12 * time.Hour
credentialLeaseBytes = 32
maxCredentialLeasesPerWorkspace = 10_000
gitHubTokenUsername = "x-access-token"
)
var (
ErrCredentialLeaseInvalid = errors.New("GitHub credential lease is invalid")
ErrCredentialLeaseExpired = errors.New("GitHub credential lease is expired")
ErrCredentialLeaseRevoked = errors.New("GitHub credential lease was revoked")
ErrCredentialLeaseLimit = errors.New("GitHub credential lease limit reached")
ErrCredentialScopeDenied = errors.New("GitHub credential scope denied")
)
// BrokerScopeAuthorizer verifies task/workspace/repository ownership. It is
// called both when a lease is issued and each time it is redeemed.
type BrokerScopeAuthorizer interface {
AuthorizeGitHubRepository(
ctx context.Context,
workspaceID, taskID, sessionID, repositoryID, owner, repo string,
) error
}
type CredentialLeaseRequest struct {
WorkspaceID string
TaskID string
SessionID string
RepositoryID string
Owner string
Repo string
Host string
TTL time.Duration
}
type CredentialLease struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
type BrokerCredentialRequest struct {
Lease string
TaskID string
SessionID string
RepositoryID string
Owner string
Repo string
Host string
}
type BrokerCredential struct {
Username string `json:"username"`
Password string `json:"password"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Principal AuthPrincipal `json:"principal"`
}
type credentialLeaseRecord struct {
WorkspaceID string
TaskID string
SessionID string
RepositoryID string
Owner string
Repo string
Host string
CredentialGeneration int64
AppRegistrationID string
AppCredentialGeneration int64
TTL time.Duration
ExpiresAt time.Time
}
// CredentialBroker exchanges opaque, task-scoped leases for renewable
// automation credentials. Only lease hashes and non-secret scope metadata are
// retained; raw leases and GitHub tokens are never persisted in the broker.
type CredentialBroker struct {
connections workspaceConnectionReader
resolver *CredentialResolver
authorizer BrokerScopeAuthorizer
mu sync.Mutex
leases map[[sha256.Size]byte]credentialLeaseRecord
now func() time.Time
}
func NewCredentialBroker(
connections workspaceConnectionReader,
resolver *CredentialResolver,
authorizer BrokerScopeAuthorizer,
) *CredentialBroker {
return &CredentialBroker{
connections: connections,
resolver: resolver,
authorizer: authorizer,
leases: make(map[[sha256.Size]byte]credentialLeaseRecord),
now: time.Now,
}
}
func (b *CredentialBroker) Issue(ctx context.Context, req CredentialLeaseRequest) (*CredentialLease, error) {
if err := validateCredentialLeaseScope(
req.WorkspaceID, req.TaskID, req.SessionID, req.RepositoryID, req.Owner, req.Repo, req.Host,
); err != nil {
return nil, err
}
if b == nil || b.connections == nil || b.resolver == nil || b.authorizer == nil {
return nil, ErrGitHubNotConfigured
}
if err := b.authorizer.AuthorizeGitHubRepository(
ctx, req.WorkspaceID, req.TaskID, req.SessionID, req.RepositoryID, req.Owner, req.Repo,
); err != nil {
return nil, fmt.Errorf("%w: %v", ErrCredentialScopeDenied, err)
}
connection, appCredentialGeneration, err := b.issueConnection(ctx, req.WorkspaceID)
if err != nil {
return nil, err
}
ttl := req.TTL
if ttl <= 0 || ttl > defaultCredentialLeaseTTL {
ttl = defaultCredentialLeaseTTL
}
raw := make([]byte, credentialLeaseBytes)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("create GitHub credential lease: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
hash := sha256.Sum256([]byte(token))
now := b.now()
expiresAt := now.UTC().Add(ttl)
b.mu.Lock()
defer b.mu.Unlock()
b.sweepExpiredLocked(now)
if len(b.leases) >= maxCredentialLeasesPerWorkspace &&
b.workspaceLeaseCountLocked(req.WorkspaceID) >= maxCredentialLeasesPerWorkspace {
return nil, fmt.Errorf("%w for workspace %s", ErrCredentialLeaseLimit, req.WorkspaceID)
}
b.leases[hash] = credentialLeaseRecord{
WorkspaceID: req.WorkspaceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RepositoryID: req.RepositoryID,
Owner: strings.ToLower(req.Owner),
Repo: strings.ToLower(req.Repo),
Host: strings.ToLower(req.Host),
CredentialGeneration: connection.CredentialGeneration,
AppRegistrationID: connection.AppRegistrationID,
AppCredentialGeneration: appCredentialGeneration,
TTL: ttl,
ExpiresAt: expiresAt,
}
return &CredentialLease{Token: token, ExpiresAt: expiresAt}, nil
}
func (b *CredentialBroker) issueConnection(
ctx context.Context,
workspaceID string,
) (*WorkspaceConnection, int64, error) {
connection, err := b.connections.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return nil, 0, fmt.Errorf("load GitHub workspace connection: %w", err)
}
if connection == nil {
return nil, 0, ErrGitHubNotConfigured
}
if connection.Status != ConnectionStatusActive {
return nil, 0, ErrGitHubConnectionInvalid
}
if connection.Source != ConnectionSourceGitHubAppInstallation {
return connection, 0, nil
}
if strings.TrimSpace(connection.AppRegistrationID) == "" {
return nil, 0, ErrGitHubNotConfigured
}
generation, err := b.resolver.appCredentialGeneration(connection.AppRegistrationID)
if err != nil {
return nil, 0, err
}
return connection, generation, nil
}
func (b *CredentialBroker) Resolve(
ctx context.Context,
req BrokerCredentialRequest,
) (*BrokerCredential, error) {
if b == nil || b.connections == nil || b.resolver == nil || b.authorizer == nil {
return nil, ErrGitHubNotConfigured
}
if strings.TrimSpace(req.Lease) == "" {
return nil, ErrCredentialLeaseInvalid
}
record, err := b.loadLease(req)
if err != nil {
return nil, err
}
if err := b.authorizeLease(ctx, record); err != nil {
return nil, err
}
credential, err := b.resolveLeaseCredential(ctx, record)
if err != nil {
return nil, err
}
if err := b.validateLeaseConnection(ctx, record); err != nil {
return nil, err
}
if !b.renewLease(req.Lease) {
return nil, ErrCredentialLeaseRevoked
}
return credential, nil
}
func (b *CredentialBroker) loadLease(req BrokerCredentialRequest) (credentialLeaseRecord, error) {
hash := sha256.Sum256([]byte(req.Lease))
now := b.now()
b.mu.Lock()
record, ok := b.leases[hash]
b.sweepExpiredLocked(now)
b.mu.Unlock()
if !ok {
return credentialLeaseRecord{}, ErrCredentialLeaseInvalid
}
if !record.ExpiresAt.After(now) {
return credentialLeaseRecord{}, ErrCredentialLeaseExpired
}
if !credentialLeaseMatches(record, req) {
return credentialLeaseRecord{}, ErrCredentialScopeDenied
}
return record, nil
}
func (b *CredentialBroker) authorizeLease(ctx context.Context, record credentialLeaseRecord) error {
if err := b.authorizer.AuthorizeGitHubRepository(
ctx, record.WorkspaceID, record.TaskID, record.SessionID,
record.RepositoryID, record.Owner, record.Repo,
); err != nil {
return fmt.Errorf("%w: %v", ErrCredentialScopeDenied, err)
}
return b.validateLeaseConnection(ctx, record)
}
func (b *CredentialBroker) validateLeaseConnection(
ctx context.Context,
record credentialLeaseRecord,
) error {
connection, err := b.connections.GetWorkspaceConnection(ctx, record.WorkspaceID)
if err != nil {
return fmt.Errorf("load GitHub workspace connection: %w", err)
}
if connection == nil || connection.Status != ConnectionStatusActive ||
connection.CredentialGeneration != record.CredentialGeneration ||
connection.AppRegistrationID != record.AppRegistrationID {
return ErrCredentialLeaseRevoked
}
if record.AppRegistrationID != "" {
generation, generationErr := b.resolver.appCredentialGeneration(record.AppRegistrationID)
if generationErr != nil || generation != record.AppCredentialGeneration {
return ErrCredentialLeaseRevoked
}
}
return nil
}
func (b *CredentialBroker) resolveLeaseCredential(
ctx context.Context,
record credentialLeaseRecord,
) (*BrokerCredential, error) {
resolved, err := b.resolver.Resolve(ctx, ResolveCredentialRequest{
WorkspaceID: record.WorkspaceID,
Purpose: CredentialPurposeGitTransport,
RepoOwner: record.Owner,
RepoName: record.Repo,
})
if err != nil {
return nil, err
}
if resolved.CredentialGeneration != record.CredentialGeneration ||
resolved.AppRegistrationID != record.AppRegistrationID ||
resolved.AppCredentialGeneration != record.AppCredentialGeneration || resolved.credential == "" {
return nil, ErrCredentialLeaseRevoked
}
// Credential-helper get requests do not distinguish fetch from push. Read
// is the minimum transport capability; GitHub enforces write permission if
// the returned installation token is subsequently used for a push.
if resolved.Principal.Kind == AuthPrincipalApp && !resolved.Capabilities[CapabilityGitRead] {
return nil, fmt.Errorf("%w: %s", ErrGitHubCapabilityDenied, CapabilityGitRead)
}
return &BrokerCredential{
Username: gitHubTokenUsername,
Password: resolved.credential,
ExpiresAt: resolved.ExpiresAt,
Principal: resolved.Principal,
}, nil
}
func (b *CredentialBroker) renewLease(rawLease string) bool {
hash := sha256.Sum256([]byte(rawLease))
now := b.now()
b.mu.Lock()
defer b.mu.Unlock()
record, ok := b.leases[hash]
if !ok || !record.ExpiresAt.After(now) {
return false
}
ttl := record.TTL
if ttl <= 0 || ttl > defaultCredentialLeaseTTL {
ttl = defaultCredentialLeaseTTL
}
record.ExpiresAt = now.UTC().Add(ttl)
b.leases[hash] = record
return true
}
func (b *CredentialBroker) sweepExpiredLocked(now time.Time) {
for hash, record := range b.leases {
if !record.ExpiresAt.After(now) {
delete(b.leases, hash)
}
}
}
func (b *CredentialBroker) workspaceLeaseCountLocked(workspaceID string) int {
count := 0
for _, record := range b.leases {
if record.WorkspaceID == workspaceID {
count++
}
}
return count
}
func (b *CredentialBroker) RevokeTask(taskID string) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
for hash, record := range b.leases {
if record.TaskID == taskID {
delete(b.leases, hash)
}
}
}
func (b *CredentialBroker) RevokeSession(sessionID string) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
for hash, record := range b.leases {
if record.SessionID == sessionID {
delete(b.leases, hash)
}
}
}
func (b *CredentialBroker) RevokeWorkspace(workspaceID string) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
for hash, record := range b.leases {
if record.WorkspaceID == workspaceID {
delete(b.leases, hash)
}
}
}
func validateCredentialLeaseScope(workspaceID, taskID, sessionID, repositoryID, owner, repo, host string) error {
for name, value := range map[string]string{
"workspace": workspaceID,
"task": taskID,
"session": sessionID,
"repository": repositoryID,
"owner": owner,
"repo": repo,
"host": host,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%w: %s is required", ErrCredentialLeaseInvalid, name)
}
}
if !strings.EqualFold(host, defaultGitHubHost) {
return fmt.Errorf("%w: unsupported host", ErrCredentialScopeDenied)
}
return nil
}
func credentialLeaseMatches(record credentialLeaseRecord, req BrokerCredentialRequest) bool {
return record.TaskID == req.TaskID &&
record.SessionID == req.SessionID &&
record.RepositoryID == req.RepositoryID &&
strings.EqualFold(record.Owner, req.Owner) &&
strings.EqualFold(record.Repo, req.Repo) &&
strings.EqualFold(record.Host, req.Host)
}