grafana/apps/provisioning/pkg/connection/github/connection.go
Shubham Nainwal 331343df9f
Provisioning: allow GitHub App connection without webhook permissions (#126129)
* fix(gitsync): allow GitHub App connection without webhooks:write when pollingOnly is set

* fix(gitsync): allow GitHub App connection without webhooks:write when webhookDisabled is set

* chore(gitsync): regenerate openapi spec and frontend client for webhookDisabled field
2026-06-12 08:27:26 +02:00

567 lines
18 KiB
Go

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) Client
}
type ConnectionSecrets struct {
PrivateKey common.RawSecureValue
Token common.RawSecureValue
}
type Connection struct {
obj *provisioning.Connection
ghFactory GithubFactory
secrets ConnectionSecrets
}
func NewConnection(
obj *provisioning.Connection,
factory GithubFactory,
secrets ConnectionSecrets,
) Connection {
return Connection{
obj: obj,
ghFactory: factory,
secrets: secrets,
}
}
const (
//TODO(ferruvich): these probably need to be setup in API configuration.
githubInstallationURL = "https://github.com/settings/installations"
)
// 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.obj.Spec.GitHub.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.obj.Spec.GitHub.AppID)
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnauthorized,
Success: false,
Errors: []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.obj.Spec.GitHub.AppID)
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnauthorized,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("secure", "privateKey").String(),
Detail: "invalid private key",
},
},
}, nil
}
if claims.Issuer != c.obj.Spec.GitHub.AppID {
logger.Info("JWT issuer mismatch", "expected", c.obj.Spec.GitHub.AppID, "got", claims.Issuer)
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnauthorized,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "appID").String(),
Detail: "invalid app ID",
BadValue: c.obj.Spec.GitHub.AppID,
},
},
}, nil
}
}
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
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, ErrAuthentication):
// ErrAuthentication is returned when the underlying JWT is invalid.
// This means that appID and/or privateKey are not correct.
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnauthorized,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "appID").String(),
Detail: "verify appID is correct",
BadValue: c.obj.Spec.GitHub.AppID,
},
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("secure", "privateKey").String(),
Detail: "verify privateKey is correct",
},
},
}, nil
case errors.Is(err, ErrNotFound):
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusNotFound,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueNotFound,
Field: field.NewPath("spec", "github", "appID").String(),
Detail: "app not found",
BadValue: c.obj.Spec.GitHub.AppID,
},
},
}, nil
case errors.Is(err, ErrServiceUnavailable):
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusServiceUnavailable,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeInternal,
Detail: ErrServiceUnavailable.Error(),
},
},
}, nil
default:
// Generic error - invalid spec
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnprocessableEntity,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "appID").String(),
Detail: "verify appID is correct",
BadValue: c.obj.Spec.GitHub.AppID,
},
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("secure", "privateKey").String(),
Detail: "verify privateKey is correct",
},
},
}, nil
}
}
if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID {
logger.Info("app ID mismatch", "expected", c.obj.Spec.GitHub.AppID, "got", app.ID)
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "appID").String(),
Detail: "appID mismatch",
BadValue: c.obj.Spec.GitHub.AppID,
},
},
}, nil
}
// Validate the app's permissions.
permissionErrors := validatePermissions(permissionTargetApp, c.obj.Spec.GitHub.AppID, app.Permissions, c.obj.Spec.GitHub.WebhookDisabled)
if len(permissionErrors) > 0 {
logger.Info("GitHub App permission validation failed", "appID", c.obj.Spec.GitHub.AppID, "errorCount", len(permissionErrors))
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusForbidden,
Success: false,
Errors: permissionErrors,
}, nil
}
installation, err := ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID)
if err != nil {
logger.Info("error getting app installation", "installationID", c.obj.Spec.GitHub.InstallationID, "error", err)
// Check for specific error types
switch {
case errors.Is(err, ErrAuthentication):
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnauthorized,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "installationID").String(),
Detail: ErrAuthentication.Error(),
BadValue: c.obj.Spec.GitHub.InstallationID,
},
},
}, nil
case errors.Is(err, ErrNotFound):
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusNotFound,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "installationID").String(),
Detail: "installation not found",
BadValue: c.obj.Spec.GitHub.InstallationID,
},
},
}, nil
case errors.Is(err, ErrServiceUnavailable):
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusServiceUnavailable,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "installationID").String(),
Detail: ErrServiceUnavailable.Error(),
BadValue: c.obj.Spec.GitHub.InstallationID,
},
},
}, nil
default:
// Generic error - invalid spec
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusUnprocessableEntity,
Success: false,
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "installationID").String(),
Detail: "invalid installation ID",
BadValue: c.obj.Spec.GitHub.InstallationID,
},
},
}, 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 := validatePermissions(permissionTargetInstallation, c.obj.Spec.GitHub.InstallationID, installation.Permissions, c.obj.Spec.GitHub.WebhookDisabled)
if len(installationPermErrors) > 0 {
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusForbidden,
Success: false,
Errors: installationPermErrors,
}, nil
}
return &provisioning.TestResults{
TypeMeta: metav1.TypeMeta{
APIVersion: provisioning.APIVERSION,
Kind: "TestResults",
},
Code: http.StatusOK,
Success: true,
}, 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.GitHub == nil {
return nil, errors.New("connection is not a GitHub connection")
}
if repo.Spec.GitHub == nil {
return nil, errors.New("repository is not a GitHub repo")
}
_, repoName, err := github.ParseOwnerRepoGithub(repo.Spec.GitHub.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse repo URL: %w", err)
}
// Create the GitHub client with the JWT token
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
// Create an installation access token scoped to this repository
installationToken, err := ghClient.CreateInstallationAccessToken(ctx, c.obj.Spec.GitHub.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, 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.GitHub == nil {
return nil, fmt.Errorf("github configuration is required")
}
// Create the GitHub client with the JWT token
ghClient := c.ghFactory.New(ctx, c.secrets.Token)
token, err := ghClient.CreateInstallationAccessToken(ctx, c.obj.Spec.GitHub.InstallationID, "")
if err != nil {
return nil, fmt.Errorf("failed to create installation access token: %w", err)
}
installationGhClient := c.ghFactory.New(ctx, common.RawSecureValue(token.Token))
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.GitHub == nil {
return "", errors.New("connection is not a GitHub connection")
}
return GenerateJWTToken(c.obj.Spec.GitHub.AppID, c.secrets.PrivateKey)
}
// TokenCreationTime returns when the underlying token has been created.
func (c *Connection) TokenCreationTime(_ context.Context) (time.Time, error) {
issuingTime, _, err := getIssuingAndExpirationTimeFromToken(c.secrets.Token, c.secrets.PrivateKey)
if err != nil {
return time.Time{}, err
}
return issuingTime, nil
}
// TokenExpiration returns the underlying token expiration.
func (c *Connection) TokenExpiration(_ context.Context) (time.Time, error) {
_, expiration, err := getIssuingAndExpirationTimeFromToken(c.secrets.Token, c.secrets.PrivateKey)
if err != nil {
return time.Time{}, err
}
return expiration, nil
}
// TokenValid returns whether the underlying token is valid.
func (c *Connection) TokenValid(_ context.Context) bool {
claims, err := parseJWTToken(c.secrets.Token, c.secrets.PrivateKey)
if err != nil {
// Error here means the token has not been built with the object privateKey
return false
}
// For the token to be valid, the issuer must be equal to the object appID
return claims.Issuer == c.obj.Spec.GitHub.AppID
}
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 validatePermissions(target permissionTarget, id string, permissions Permissions, webhookDisabled bool) []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 !webhookDisabled {
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", "github", "appID").String()
case permissionTargetInstallation:
detail = fmt.Sprintf(
"GitHub App installation lacks required '%s' permission: requires '%s', has '%s'. Accept the updated permissions at %s/%s",
name,
toAppPermissionString(perm.required),
toAppPermissionString(perm.current),
githubInstallationURL,
id,
)
fieldPath = field.NewPath("spec", "github", "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)
)