/
githubmirror
/
grafana
Обзор
Документация
Войти
/
githubmirror
/
grafana
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
apps/provisioning/pkg/connection/github/connection.go
526 строк
16 KB
Alejandro
Provisioning: (4/8) Add OAuth app connection support (#129741)
06 авг 2026, 17:04
Не верифицирован
06 авг 2026, 17:04
c8734a4
Код
Авторство
О чём код?
package github import ( "context" "errors" "fmt" "net/http" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/connection" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) //go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter type GithubFactory interface { New(ctx context.Context, ghToken common.RawSecureValue, opts ...ClientOption) (Client, error) } type ConnectionSecrets struct { PrivateKey common.RawSecureValue Token common.RawSecureValue } // ConnectionConfig exposes the GitHub parameters a connection needs. The github.com // implementation (config) reads them from spec.Github; GitHub Enterprise injects its // own implementation via NewConnectionWithCustomConfig. // //go:generate mockery --name ConnectionConfig --structname MockConnectionConfig --inpackage --filename connectionconfig_mock.go --with-expecter type ConnectionConfig interface { AppID() string InstallationID() string CustomServerURL() string } type Connection struct { obj *provisioning.Connection ghFactory GithubFactory secrets ConnectionSecrets cfg ConnectionConfig } // NewConnection builds a github.com connection whose parameters are resolved from spec.Github. func NewConnection( obj *provisioning.Connection, factory GithubFactory, secrets ConnectionSecrets, ) Connection { return Connection{ obj: obj, ghFactory: factory, secrets: secrets, cfg: config{obj: obj}, } } // NewConnectionWithCustomConfig builds a connection whose GitHub parameters are resolved by // the given config. Used by GitHub Enterprise to read from spec.githubEnterprise. func NewConnectionWithCustomConfig( obj *provisioning.Connection, factory GithubFactory, secrets ConnectionSecrets, config ConnectionConfig, ) Connection { return Connection{ obj: obj, ghFactory: factory, secrets: secrets, cfg: config, } } // config is the github.com ConnectionConfig, reading from spec.github. type config struct { obj *provisioning.Connection } func (c config) AppID() string { if c.obj.Spec.GitHub == nil { return "" } return c.obj.Spec.GitHub.AppID } func (c config) InstallationID() string { if c.obj.Spec.GitHub == nil { return "" } return c.obj.Spec.GitHub.InstallationID } func (c config) CustomServerURL() string { return "" } var _ ConnectionConfig = config{} // Test validates the appID and installationID against the given github token. func (c *Connection) Test(ctx context.Context) (*provisioning.TestResults, error) { logger := logging.FromContext(ctx) // If given token doesn't exists, or the privateKey is being renewed, we need to generate a new token for testing. if c.secrets.Token.IsZero() || !c.obj.Secure.PrivateKey.Create.IsZero() { // In case the token is not generated, we create one on the fly // to testing that the other fields are valid. token, err := GenerateJWTToken(c.cfg.AppID(), c.secrets.PrivateKey) if err != nil { // Error generating JWT token means the privateKey is not valid. logger.Info("JWT token generation failed during connection test", "appID", c.cfg.AppID()) return connection.FailedTestResults( http.StatusUnauthorized, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("secure", "privateKey").String(), Detail: "invalid private key", }, }, ), nil } c.obj.Secure.Token.Create = token c.secrets.Token = token } else { // In case the token is there, we verify it's correct. claims, err := parseJWTToken(c.secrets.Token, c.secrets.PrivateKey) if err != nil { // Error parsing JWT token means the given private key is invalid logger.Info("JWT token parsing failed during connection test", "appID", c.cfg.AppID()) return connection.FailedTestResults( http.StatusUnauthorized, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("secure", "privateKey").String(), Detail: "invalid private key", }, }, ), nil } if claims.Issuer != c.cfg.AppID() { logger.Info("JWT issuer mismatch", "expected", c.cfg.AppID(), "got", claims.Issuer) return connection.FailedTestResults( http.StatusUnauthorized, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "appID").String(), Detail: "invalid app ID", BadValue: c.cfg.AppID(), }, }, ), nil } } ghClient, err := c.ghFactory.New(ctx, c.secrets.Token, WithCustomServerURL(c.cfg.CustomServerURL())) if err != nil { return nil, err } app, err := ghClient.GetApp(ctx) if err != nil { logger.Info("error getting app", "error", err) // Check for specific error types switch { case errors.Is(err, connection.ErrAuthentication): // ErrAuthentication is returned when the underlying JWT is invalid. // This means that appID and/or privateKey are not correct. return connection.FailedTestResults( http.StatusUnauthorized, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "appID").String(), Detail: "authentication failed. The appID exists but could not be accessed with the privateKey. Verify appID is correct", BadValue: c.cfg.AppID(), }, { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("secure", "privateKey").String(), Detail: "authentication failed. Verify privateKey is the generated private key for the appID", BadValue: "****", }, }, ), nil case errors.Is(err, ErrNotFound): return connection.FailedTestResults( http.StatusNotFound, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueNotFound, Field: field.NewPath("spec", string(c.obj.Spec.Type), "appID").String(), Detail: "app not found", BadValue: c.cfg.AppID(), }, }, ), nil case errors.Is(err, ErrServiceUnavailable): return connection.FailedTestResults( http.StatusServiceUnavailable, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeInternal, Detail: ErrServiceUnavailable.Error(), }, }, ), nil default: // Generic error return connection.FailedTestResults( http.StatusUnprocessableEntity, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Detail: fmt.Errorf("failed to GET app: %w", err).Error(), }, }, ), nil } } if fmt.Sprintf("%d", app.ID) != c.cfg.AppID() { logger.Info("app ID mismatch", "expected", c.cfg.AppID(), "got", app.ID) return connection.FailedTestResults( http.StatusBadRequest, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "appID").String(), Detail: "appID mismatch", BadValue: c.cfg.AppID(), }, }, ), nil } // Validate the app's permissions. permissionErrors := c.validatePermissions(permissionTargetApp, c.cfg.AppID(), app.Permissions) if len(permissionErrors) > 0 { logger.Info("GitHub App permission validation failed", "appID", c.cfg.AppID(), "errorCount", len(permissionErrors)) return connection.FailedTestResults(http.StatusForbidden, permissionErrors), nil } installation, err := ghClient.GetAppInstallation(ctx, c.cfg.InstallationID()) if err != nil { logger.Info("error getting app installation", "installationID", c.cfg.InstallationID(), "error", err) // Check for specific error types switch { case errors.Is(err, connection.ErrAuthentication): return connection.FailedTestResults( http.StatusUnauthorized, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "installationID").String(), Detail: connection.ErrAuthentication.Error(), BadValue: c.cfg.InstallationID(), }, }, ), nil case errors.Is(err, ErrNotFound): return connection.FailedTestResults( http.StatusNotFound, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "installationID").String(), Detail: "installation not found", BadValue: c.cfg.InstallationID(), }, }, ), nil case errors.Is(err, ErrServiceUnavailable): return connection.FailedTestResults( http.StatusServiceUnavailable, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Field: field.NewPath("spec", string(c.obj.Spec.Type), "installationID").String(), Detail: ErrServiceUnavailable.Error(), BadValue: c.cfg.InstallationID(), }, }, ), nil default: // Generic error return connection.FailedTestResults( http.StatusUnprocessableEntity, []provisioning.ErrorDetails{ { Type: metav1.CauseTypeFieldValueInvalid, Detail: fmt.Errorf("failed to GET app installation: %w", err).Error(), }, }, ), nil } } // Validate that the installation has accepted the required permissions. // Installation permissions may lag behind App permissions when the App owner added new // permissions but the installation owner has not yet accepted them on GitHub. installationPermErrors := c.validatePermissions(permissionTargetInstallation, c.cfg.InstallationID(), installation.Permissions) if len(installationPermErrors) > 0 { return connection.FailedTestResults(http.StatusForbidden, installationPermErrors), nil } return connection.SuccessTestResults(), nil } // GenerateRepositoryToken generates a repository-scoped access token. func (c *Connection) GenerateRepositoryToken(ctx context.Context, repo *provisioning.Repository) (*connection.ExpirableSecureValue, error) { if repo == nil { return nil, errors.New("a repository is required to generate a token") } if !c.obj.Spec.IsGitHub() { return nil, errors.New("connection is not a GitHub-based connection") } if !repo.Spec.Type.IsGitHub() { return nil, errors.New("repository is not a GitHub-based repo") } _, repoName, err := github.ParseOwnerRepoGithub(repo.URL()) if err != nil { return nil, fmt.Errorf("failed to parse repo URL: %w", err) } // Create the GitHub client with the JWT token ghClient, err := c.ghFactory.New(ctx, c.secrets.Token, WithCustomServerURL(c.cfg.CustomServerURL())) if err != nil { return nil, err } // Create an installation access token scoped to this repository installationToken, err := ghClient.CreateInstallationAccessToken(ctx, c.cfg.InstallationID(), repoName) if err != nil { switch { case errors.Is(err, ErrUnprocessableEntity): return nil, fmt.Errorf("%s: %w", err.Error(), connection.ErrRepositoryAccess) case errors.Is(err, ErrNotFound): return nil, fmt.Errorf("%s: %w", err.Error(), connection.ErrNotFound) case errors.Is(err, connection.ErrAuthentication): return nil, connection.ErrAuthentication } return nil, fmt.Errorf("failed to create installation access token: %w", err) } return &connection.ExpirableSecureValue{ Token: common.RawSecureValue(installationToken.Token), ExpiresAt: installationToken.ExpiresAt, }, nil } // ListRepositories returns the list of repositories accessible through this GitHub App connection. func (c *Connection) ListRepositories(ctx context.Context) ([]provisioning.ExternalRepository, error) { if !c.obj.Spec.IsGitHub() { return nil, fmt.Errorf("github configuration is required") } // Create the GitHub client with the JWT token ghClient, err := c.ghFactory.New(ctx, c.secrets.Token, WithCustomServerURL(c.cfg.CustomServerURL())) if err != nil { return nil, err } token, err := ghClient.CreateInstallationAccessToken(ctx, c.cfg.InstallationID(), "") if err != nil { return nil, fmt.Errorf("failed to create installation access token: %w", err) } installationGhClient, err := c.ghFactory.New(ctx, common.RawSecureValue(token.Token), WithCustomServerURL(c.cfg.CustomServerURL())) if err != nil { return nil, err } repos, err := installationGhClient.ListInstallationRepositories(ctx) if err != nil { return nil, fmt.Errorf("list installation repositories: %w", err) } result := make([]provisioning.ExternalRepository, 0, len(repos)) for _, repo := range repos { result = append(result, provisioning.ExternalRepository{ Name: repo.Name, Owner: repo.Owner, URL: repo.URL, }) } return result, nil } // GenerateConnectionToken generates a JWT token for GitHub App authentication. // Implements the connection.TokenConnection interface. func (c *Connection) GenerateConnectionToken(_ context.Context) (common.RawSecureValue, error) { if !c.obj.Spec.IsGitHub() { return "", errors.New("connection is not a GitHub connection") } token, err := GenerateJWTToken(c.cfg.AppID(), c.secrets.PrivateKey) if err != nil { return "", err } return token, nil } // ValidateToken checks the stored JWT. A token that does not parse with the // private key or was issued for another appID is invalid. func (c *Connection) ValidateToken() (expiresAt time.Time, err error) { claims, err := parseJWTToken(c.secrets.Token, c.secrets.PrivateKey) if err != nil { return time.Time{}, err } if claims.Issuer != c.cfg.AppID() { return time.Time{}, errors.New("token was issued for another appID") } if claims.ExpiresAt != nil { return claims.ExpiresAt.Time, nil } return time.Time{}, nil } type permissionTarget int const ( permissionTargetApp permissionTarget = iota permissionTargetInstallation ) // validatePermissions checks if the given app or installation has required permissions. // For installations, permissions may differ from App permissions when the App's permissions // were updated but the installation owner has not yet accepted them on GitHub. // When webhookDisabled is true, the webhooks:write check is skipped because webhook // integration has been explicitly disabled for this connection. func (c *Connection) validatePermissions(target permissionTarget, id string, permissions Permissions) []provisioning.ErrorDetails { var errs []provisioning.ErrorDetails requiredPerms := map[string]struct { current Permission required Permission }{ "contents": { current: permissions.Contents, required: PermissionWrite, }, "metadata": { current: permissions.Metadata, required: PermissionRead, }, "pull_requests": { current: permissions.PullRequests, required: PermissionWrite, }, } if c.obj.Spec.Webhook == nil || !c.obj.Spec.Webhook.Disabled { requiredPerms["webhooks"] = struct { current Permission required Permission }{ current: permissions.Webhooks, required: PermissionWrite, } } for name, perm := range requiredPerms { if perm.current < perm.required { var detail string var fieldPath string switch target { case permissionTargetApp: detail = fmt.Sprintf( "GitHub App lacks required '%s' permission: requires '%s', has '%s'", name, toAppPermissionString(perm.required), toAppPermissionString(perm.current), ) fieldPath = field.NewPath("spec", string(c.obj.Spec.Type), "appID").String() case permissionTargetInstallation: detail = fmt.Sprintf( "GitHub App installation lacks required '%s' permission: requires '%s', has '%s'. Accept the updated permissions at %s", name, toAppPermissionString(perm.required), toAppPermissionString(perm.current), c.obj.Spec.URL, ) fieldPath = field.NewPath("spec", string(c.obj.Spec.Type), "installationID").String() } errs = append(errs, provisioning.ErrorDetails{ Type: metav1.CauseTypeForbidden, Field: fieldPath, Detail: detail, BadValue: id, }) } } return errs } func toAppPermissionString(permissions Permission) string { switch permissions { case PermissionNone: return "" case PermissionRead: return "read" case PermissionWrite: return "write" } return "" } var ( _ connection.Connection = (*Connection)(nil) _ connection.TokenConnection = (*Connection)(nil) )