From 4b811b6af8acb1594dd021f4954ba7f38436e34f Mon Sep 17 00:00:00 2001 From: Gareth Dawson Date: Fri, 27 Sep 2024 17:09:56 +0100 Subject: [PATCH] Add support for GitHub App authentication (#363) --- cspell.config.json | 1 + docs/sources/setup/datasource.md | 4 +- docs/sources/setup/token.md | 45 ++++++------ go.mod | 7 +- go.sum | 6 ++ pkg/github/client/client.go | 46 +++++++++++- pkg/github/datasource.go | 3 +- pkg/models/settings.go | 22 ++++-- pkg/plugin/instance.go | 4 +- src/types.ts | 25 ++++++- src/views/ConfigEditor.spec.tsx | 2 + src/views/ConfigEditor.tsx | 120 +++++++++++++++++++++++-------- 12 files changed, 217 insertions(+), 68 deletions(-) diff --git a/cspell.config.json b/cspell.config.json index 31431316..2b5294f4 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -20,6 +20,7 @@ "dfutil", "dserrors", "errorsource", + "ghinstallation", "githubclient", "githubv", "googlegithub", diff --git a/docs/sources/setup/datasource.md b/docs/sources/setup/datasource.md index 75178172..432e3c8a 100644 --- a/docs/sources/setup/datasource.md +++ b/docs/sources/setup/datasource.md @@ -17,7 +17,7 @@ weight: 103 # Configure the GitHub data source plugin for Grafana -1. After creating the **personal access token** in GitHub, navigate into Grafana and click on the menu option on the top left. +1. After creating the **access token** in GitHub, navigate into Grafana and click on the menu option on the top left. 1. Browse to the **Connections** menu and then click on the **Data sources**. @@ -25,7 +25,7 @@ weight: 103 1. Go to its settings tab and at the bottom, you will find the **Connection** section. -1. Paste the personal access token. +1. Paste the access token. ![Configuring API Token](/media/docs/grafana/data-sources/github/github-plugin-confg-token.png) (_Optional_): If you using the GitHub Enterprise, then select the **Enterprise** option inside the **Additional Settings** section and paste the URL of your GitHub Enterprise. diff --git a/docs/sources/setup/token.md b/docs/sources/setup/token.md index 209fec83..1db7f473 100644 --- a/docs/sources/setup/token.md +++ b/docs/sources/setup/token.md @@ -1,7 +1,7 @@ --- -title: Create a GitHub personal access token -menuTitle: Create a personal access token -description: Create a GitHub personal access token +title: Create a GitHub access token +menuTitle: Create an access token +description: Create a GitHub access token keywords: - data source - github @@ -15,27 +15,14 @@ labels: weight: 102 --- -# Create a GitHub personal access token +# Create a GitHub access token -You will need a _personal access token_ to use the plugin. GitHub currently supports two types of personal access tokens: - -1. fine-grained personal access tokens -1. personal access tokens (classic) - -Read more about [personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). - -The Grafana GitHub data source plugin works with both. Below is a table that indicates what minimum requirements must be matched before the plugin can be used. - -Options: - -| Setting | Required | Description | -| --------------------- | -------- | ----------------------------------------------------- | -| Access token | true | This is required to allow plugin to connect to GitHub | -| GitHub Enterprise URL | false | Only if you are using GitHub Enterprise account | +You will need either a `GitHub App` or a `Personal Access Token` to use this plugin. ## Creating a personal access token (classic) -This is an example when you want to use the personal access token (classic). +This is an example when you want to use the personal access token (classic). \ +Read more about [personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). 1. Login to your GitHub account. 1. Navigate to [Personal access tokens](https://github.com/settings/tokens) and click **Generate new token**. @@ -43,9 +30,23 @@ This is an example when you want to use the personal access token (classic). 1. Define the permissions which you want to allow. 1. Click **Generate Token**. -### Permissions +## Using GitHub App Authentication + +You can also authenticate using a GitHub App instead of a personal access token. This method allows for better security and fine-grained access to resources. + +1. Register a new GitHub App by following the instructions in the [GitHub App documentation](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). +1. After registering the App, generate a private key for authentication. +1. Note down the App ID assigned to your GitHub App. +1. [Install the GitHub App](https://docs.github.com/en/apps/using-github-apps/installing-your-own-github-app) on your GitHub account or organization. +1. Note the installation ID after completing the installation. +1. In Grafana's data source settings, provide the **app id**, **installation id**, and **private key** in the appropriate fields. + +> **Where to find your installation id?** \ +> Head over to Settings > Installed GitHub Apps > Configure. The installation ID can be found at the end of the URL `https://github.com/settings/installations/`. + +## Permissions -You will need to define the access permissions for your token in order to allow it to access the data. +You will need to define the access permissions for your **personal access token** in order to allow it to access the data. The following lists include the required permissions for the access token: diff --git a/go.mod b/go.mod index 6abca26d..7c84002b 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/grafana/github-datasource -go 1.22 +go 1.23 -toolchain go1.22.1 +toolchain go1.23.1 require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 github.com/google/go-github/v53 v53.2.0 github.com/grafana/grafana-plugin-sdk-go v0.250.0 github.com/influxdata/tdigest v0.0.1 @@ -32,6 +33,8 @@ require ( github.com/go-openapi/swag v0.22.8 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect diff --git a/go.sum b/go.sum index 6b98f960..2a8e52dc 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhP github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag= +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= @@ -60,6 +62,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= @@ -70,6 +74,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index c7ef7944..ae0444f0 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + "github.com/bradleyfalzon/ghinstallation/v2" googlegithub "github.com/google/go-github/v53/github" "github.com/grafana/github-datasource/pkg/models" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -52,11 +53,46 @@ var runnerPerMinuteRate = map[string]float64{ // New instantiates a new GitHub API client. func New(ctx context.Context, settings models.Settings) (*Client, error) { - if settings.AccessToken == "" { - // If access token is not set, return downstream error as it is required. - return nil, errorsource.DownstreamError(fmt.Errorf("access token is required"), false) + if settings.SelectedAuthType == "github-app" { + return createAppClient(settings) } + if settings.SelectedAuthType == "personal-access-token" { + return createAccessTokenClient(ctx, settings) + } + + return nil, errorsource.DownstreamError(fmt.Errorf("access token or app token are required"), false) +} + +func createAppClient(settings models.Settings) (*Client, error) { + appId, err := strconv.ParseInt(settings.AppId, 10, 64) + if err != nil { + return nil, errorsource.DownstreamError(fmt.Errorf("error parsing app id"), false) + } + + installationId, err := strconv.ParseInt(settings.InstallationId, 10, 64) + if err != nil { + return nil, errorsource.DownstreamError(fmt.Errorf("error parsing installation id"), false) + } + + itr, err := ghinstallation.New(http.DefaultTransport, appId, installationId, []byte(settings.PrivateKey)) + if err != nil { + return nil, errorsource.DownstreamError(fmt.Errorf("error creating token source"), false) + } + + httpClient := &http.Client{Transport: itr} + + if settings.GitHubURL == "" { + return &Client{ + restClient: googlegithub.NewClient(httpClient), + graphqlClient: githubv4.NewClient(httpClient), + }, nil + } + + return useGitHubEnterprise(httpClient, settings) +} + +func createAccessTokenClient(ctx context.Context, settings models.Settings) (*Client, error) { src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: settings.AccessToken}, ) @@ -70,6 +106,10 @@ func New(ctx context.Context, settings models.Settings) (*Client, error) { }, nil } + return useGitHubEnterprise(httpClient, settings) +} + +func useGitHubEnterprise(httpClient *http.Client, settings models.Settings) (*Client, error) { _, err := url.Parse(settings.GitHubURL) if err != nil { return nil, errorsource.DownstreamError(fmt.Errorf("incorrect enterprise url"), false) diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index 3036030d..ff1b4c98 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -2,7 +2,6 @@ package github import ( "context" - "fmt" "strings" "github.com/grafana/github-datasource/pkg/dfutil" @@ -209,7 +208,7 @@ func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataReques func NewDatasource(ctx context.Context, settings models.Settings) (*Datasource, error) { client, err := githubclient.New(ctx, settings) if err != nil { - return nil, fmt.Errorf("instantiating github client: %w", err) + return nil, err } return &Datasource{client: client}, nil } diff --git a/pkg/models/settings.go b/pkg/models/settings.go index 4962c151..f647d20b 100644 --- a/pkg/models/settings.go +++ b/pkg/models/settings.go @@ -6,14 +6,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" ) -// Settings represents the Datasource options in Grafana type Settings struct { - AccessToken string `json:"accessToken"` - GitHubURL string `json:"githubUrl"` - CachingEnabled bool `json:"cachingEnabled"` + SelectedAuthType string `json:"selectedAuthType"` + AccessToken string `json:"accessToken"` + PrivateKey string `json:"privateKey"` + AppId string `json:"appId"` + InstallationId string `json:"installationId"` + GitHubURL string `json:"githubUrl"` + CachingEnabled bool `json:"cachingEnabled"` } -// LoadSettings converts the DataSourceInLoadSettings to usable GitHub settings func LoadSettings(settings backend.DataSourceInstanceSettings) (Settings, error) { s := Settings{} if err := json.Unmarshal(settings.JSONData, &s); err != nil { @@ -24,5 +26,15 @@ func LoadSettings(settings backend.DataSourceInstanceSettings) (Settings, error) s.AccessToken = val } + if val, ok := settings.DecryptedSecureJSONData["privateKey"]; ok { + s.PrivateKey = val + } + + // Data sources created before the auth type was introduced will have an accessToken but no auth type. + // In this case, we default to personal access token. + if s.AccessToken != "" && s.SelectedAuthType == "" { + s.SelectedAuthType = "personal-access-token" + } + return s, nil } diff --git a/pkg/plugin/instance.go b/pkg/plugin/instance.go index ac52f53a..87d1a5fa 100644 --- a/pkg/plugin/instance.go +++ b/pkg/plugin/instance.go @@ -14,7 +14,7 @@ import ( func NewGitHubInstance(ctx context.Context, settings models.Settings) (instancemgmt.Instance, error) { gh, err := github.NewDatasource(ctx, settings) if err != nil { - return nil, fmt.Errorf("instantiating github datasource: %w", err) + return nil, err } var d Datasource = gh @@ -37,7 +37,7 @@ func NewDataSourceInstance(_ context.Context, settings backend.DataSourceInstanc instance, err := NewGitHubInstance(context.Background(), datasourceSettings) if err != nil { - return instance, fmt.Errorf("instantiating github instance") + return instance, fmt.Errorf("instantiating github instance: %w", err) } return instance, nil diff --git a/src/types.ts b/src/types.ts index d2706fd1..30bb2db6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,13 +16,34 @@ export interface GitHubEnterpriseOptions { githubUrl?: string; } -export interface GitHubDataSourceOptions extends DataSourceJsonData, RepositoryOptions, GitHubEnterpriseOptions { - // Any global settings +export interface GitHubAppAuth { + appId?: string; + installationId?: string; +} + +export interface GitHubDataSourceOptions + extends DataSourceJsonData, + RepositoryOptions, + GitHubEnterpriseOptions, + GitHubAppAuth { + selectedAuthType?: GitHubAuthType; } export interface GitHubSecureJsonData { // accessToken is set if the user is using a Personal Access Token to connect to GitHub accessToken?: string; + // privateKey is set if the user is using a GitHub App to connect to GitHub + privateKey?: string; +} + +export enum GitHubAuthType { + Personal = 'personal-access-token', + App = 'github-app', +} + +export enum GitHubLicenseType { + Basic = 'github-basic', + Enterprise = 'github-enterprise', } export enum QueryType { diff --git a/src/views/ConfigEditor.spec.tsx b/src/views/ConfigEditor.spec.tsx index 385d0666..91e8f05a 100644 --- a/src/views/ConfigEditor.spec.tsx +++ b/src/views/ConfigEditor.spec.tsx @@ -9,6 +9,7 @@ describe('Config Editor', () => { const options = { jsonData: {}, secureJsonFields: {} } as any; render(); await waitFor(() => expect(screen.getByText('Additional Settings')).toBeInTheDocument()); + onOptionsChange.mockClear(); // this is called on component render, so we need to clear it to avoid false positives expect(screen.getByLabelText('Basic')).toBeChecked(); expect(screen.getByLabelText('Enterprise')).not.toBeChecked(); expect(screen.queryByText('GitHub Enterprise URL')).not.toBeInTheDocument(); @@ -22,6 +23,7 @@ describe('Config Editor', () => { const options = { jsonData: { githubUrl: 'https://foo.bar' }, secureJsonFields: {} } as any; render(); await waitFor(() => expect(screen.getByText('Additional Settings')).toBeInTheDocument()); + onOptionsChange.mockClear(); expect(screen.getByLabelText('Basic')).not.toBeChecked(); expect(screen.getByLabelText('Enterprise')).toBeChecked(); expect(screen.queryByText('GitHub Enterprise URL')).toBeInTheDocument(); diff --git a/src/views/ConfigEditor.tsx b/src/views/ConfigEditor.tsx index ce79a93b..7cae76e9 100644 --- a/src/views/ConfigEditor.tsx +++ b/src/views/ConfigEditor.tsx @@ -1,10 +1,15 @@ import { css } from '@emotion/css'; -import { DataSourcePluginOptionsEditorProps, GrafanaTheme2, onUpdateDatasourceJsonDataOption } from '@grafana/data'; +import { + DataSourcePluginOptionsEditorProps, + GrafanaTheme2, + onUpdateDatasourceJsonDataOption, + onUpdateDatasourceSecureJsonDataOption, +} from '@grafana/data'; import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; -import { Collapse, Field, Input, Label, RadioButtonGroup, SecretInput, useStyles2 } from '@grafana/ui'; -import React, { ChangeEvent, useState } from 'react'; +import { Collapse, Field, Input, Label, RadioButtonGroup, SecretInput, SecretTextArea, useStyles2 } from '@grafana/ui'; +import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { components } from '../components/selectors'; -import { GitHubDataSourceOptions, GitHubSecureJsonData } from '../types'; +import { GitHubAuthType, GitHubDataSourceOptions, GitHubLicenseType, GitHubSecureJsonData } from '../types'; import { Divider } from 'components/Divider'; export type ConfigEditorProps = DataSourcePluginOptionsEditorProps; @@ -14,13 +19,23 @@ const ConfigEditor = (props: ConfigEditorProps) => { const { jsonData, secureJsonData, secureJsonFields } = options; const secureSettings = (secureJsonData || {}) as GitHubSecureJsonData; const styles = useStyles2(getStyles); + const WIDTH_LONG = 40; + const authOptions = [ + { label: 'Personal Access Token', value: GitHubAuthType.Personal }, + { label: 'GitHub App', value: GitHubAuthType.App }, + ]; + const licenseOptions = [ + { label: 'Basic', value: GitHubLicenseType.Basic }, + { label: 'Enterprise', value: GitHubLicenseType.Enterprise }, + ]; - const [selectedLicense, setSelectedLicense] = useState(jsonData.githubUrl ? 'github-enterprise' : 'github-basic'); const [isOpen, setIsOpen] = useState(true); + const [selectedLicense, setSelectedLicense] = useState( + jsonData.githubUrl ? GitHubLicenseType.Enterprise : GitHubLicenseType.Basic + ); - const onSettingUpdate = - (prop: string, set = true) => - (event: ChangeEvent) => { + const onSettingUpdate = (prop: string, set = true) => { + return (event: ChangeEvent) => { const { onOptionsChange, options } = props; onOptionsChange({ ...options, @@ -34,25 +49,34 @@ const ConfigEditor = (props: ConfigEditorProps) => { }, }); }; + }; const onSettingReset = (prop: string) => () => { onSettingUpdate(prop, false)({ target: { value: '' } } as ChangeEvent); }; - const onLicenseChange = (value: string) => { - if (value === 'github-basic') { + const onAuthChange = useCallback( + (value: GitHubAuthType) => { + onOptionsChange({ ...options, jsonData: { ...jsonData, selectedAuthType: value } }); + }, + [jsonData, onOptionsChange, options] + ); + + const onLicenseChange = (value: GitHubLicenseType) => { + // clear out githubUrl when switching to basic + if (value === GitHubLicenseType.Basic) { onOptionsChange({ ...options, jsonData: { ...jsonData, githubUrl: '' } }); } setSelectedLicense(value); }; - const licenseOptions = [ - { label: 'Basic', value: 'github-basic' }, - { label: 'Enterprise', value: 'github-enterprise' }, - ]; - - const WIDTH_LONG = 40; + useEffect(() => { + // set the default auth type if its a new datasource and nothing is set + if (!jsonData.selectedAuthType) { + onAuthChange(GitHubAuthType.Personal); + } + }, [jsonData.selectedAuthType, onAuthChange]); return ( <> @@ -88,17 +112,57 @@ const ConfigEditor = (props: ConfigEditorProps) => { - - - + + + {jsonData.selectedAuthType === GitHubAuthType.Personal && ( + + + + )} + + {jsonData.selectedAuthType === GitHubAuthType.App && ( + <> + + + + + + + + + + + )} @@ -112,7 +176,7 @@ const ConfigEditor = (props: ConfigEditorProps) => { className={styles.radioButton} /> - {selectedLicense === 'github-enterprise' && ( + {selectedLicense === GitHubLicenseType.Enterprise && (