Skip to content

Commit

Permalink
Support Github App authentication (#146)
Browse files Browse the repository at this point in the history
Using a GitHub App has the benefit that API calls count towards that app installation instead of a user
This is useful in cases where you want to limit the number of automation users but have no such limits on Apps, since they are free

Co-authored-by: Mateusz Szostok <[email protected]>
  • Loading branch information
julienduchesne and mszostok authored Apr 15, 2022
1 parent 436c7ac commit 7dfc6dc
Show file tree
Hide file tree
Showing 13 changed files with 152 additions and 26 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.TOKEN_INTEGRATION_TESTS }}
TOKEN_WITH_NO_SCOPES: ${{ secrets.TOKEN_WITH_NO_SCOPES }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
run: |
echo "${{ env.BINARY_PATH }}"
make test-integration
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ Use the following environment variables to configure the application:
| <tt>GITHUB_ACCESS_TOKEN</tt> | | GitHub access token. Instruction for creating a token can be found [here](./docs/gh-token.md). If not provided, the owners validating functionality may not work properly. For example, you may reach the API calls quota or, if you are setting GitHub Enterprise base URL, an unauthorized error may occur. |
| <tt>GITHUB_BASE_URL</tt> | `https://api.github.com/` | GitHub base URL for API requests. Defaults to the public GitHub API but can be set to a domain endpoint to use with GitHub Enterprise. |
| <tt>GITHUB_UPLOAD_URL</tt> | `https://uploads.github.com/` | GitHub upload URL for uploading files. <br> <br>It is taken into account only when `GITHUB_BASE_URL` is also set. If only `GITHUB_BASE_URL` is provided, this parameter defaults to the `GITHUB_BASE_URL` value. |
| <tt>GITHUB_APP_ID</tt> | | Github App ID for authentication. This replaces the `GITHUB_ACCESS_TOKEN`. Instruction for creating a Github App can be found [here](./docs/gh-token.md) |
| <tt>GITHUB_APP_INSTALLATION_ID</tt> | | Github App Installation ID. Required when `GITHUB_APP_ID` is set. |
| <tt>GITHUB_APP_PRIVATE_KEY</tt> | | Github App private key in PEM format. Required when `GITHUB_APP_ID` is set. |
| <tt>CHECKS</tt> | - | List of checks to be executed. By default, all checks are executed. Possible values: `files`,`owners`,`duppatterns`,`syntax`. |
| <tt>EXPERIMENTAL_CHECKS</tt> | - | The comma-separated list of experimental checks that should be executed. By default, all experimental checks are turned off. Possible values: `notowned`. |
| <tt>CHECK_FAILURE_LEVEL</tt> | `warning` | Defines the level on which the application should treat check issues as failures. Defaults to `warning`, which treats both errors and warnings as failures, and exits with error code 3. Possible values are `error` and `warning`. |
Expand Down
20 changes: 18 additions & 2 deletions docs/gh-token.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
[← back to docs](./README.md)

# GitHub personal access token
# Github tokens

The [valid_owner.go](./../internal/check/valid_owner.go) check requires the GitHub personal access token for the following reasons:
The [valid_owner.go](./../internal/check/valid_owner.go) check requires the GitHub token for the following reasons:

1. Information about organization teams and their repositories is not publicly available.
2. If you set GitHub Enterprise base URL, an unauthorized error may occur.
3. For unauthenticated requests, the rate limit allows for up to 60 requests per hour. Unauthenticated requests are associated with the originating IP address. In a big organization where you have a lot of calls between your infrastructure server and the GitHub site, it is easy to exceed that quota.

You can either use a personal access token or a Github App.

## GitHub personal access token

Instructions for creating a token can be found [here](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token). The minimal scope required for the token is **read-only**, but the definition of this scope differs between public and private repositories.

#### Public repositories
Expand All @@ -23,3 +27,15 @@ For private repositories, select `repo` and `read:org`:
![token-public.png](./assets/token-private.png)

The Codeowners Validator source code is available on GitHub. You can always perform a security audit against its code base and build your own version from the source code if your organization is more strict about the software run in its infrastructure.

## Github App

Here are the steps to create a Github App and use it for this tool:

1. [Create a GitHub App](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app). **Note: your app does not need a callback or a webhook URL**.
2. Add a read-only permission to the "Members" item of organization permissions.
3. [Install the app in your organization](https://docs.github.com/en/developers/apps/managing-github-apps/installing-github-apps)
4. Done! To authenticate with your app, you need three environment variables:
1. `GITHUB_APP_PRIVATE_KEY`: PEM-format key generated when the app is installed. If you lost it, you can regenerate it ([docs](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#generating-a-private-key)).
2. `GITHUB_APP_ID`: Found in the app's "About" page (Organization settings -> Developer settings -> Edit button on your app).
3. `GITHUB_APP_INSTALLATION_ID`: Found in the URL your organization's app install page (Organization settings -> Github Apps -> Configure button on your app). It's the last number in the URL, ex: `https://github.com/organizations/my-org/settings/installations/1234567890`.
11 changes: 6 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ module github.com/mszostok/codeowners-validator
go 1.17

require (
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4
github.com/dustin/go-humanize v1.0.0
github.com/fatih/color v1.13.0
github.com/google/go-github/v29 v29.0.3
github.com/google/go-github/v41 v41.0.0
github.com/hashicorp/go-multierror v1.1.1
github.com/mattn/go-zglob v0.0.4-0.20201017022353-70beb5203ba6
github.com/pkg/errors v0.9.1
Expand All @@ -24,14 +26,13 @@ require (
gotest.tools v2.2.0+incompatible
)

require github.com/dustin/go-humanize v1.0.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/golang-jwt/jwt/v4 v4.0.0 // indirect
github.com/golang/protobuf v1.4.3 // indirect
github.com/google/go-cmp v0.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
Expand Down
17 changes: 12 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4 h1:tXKVfhE7FcSkhkv0UwkLvPDeZ4kz6OXd0PKPlFqf81M=
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4/go.mod h1:B40qPqJxWE0jDZgOR1JmaMy+4AY1eBP+IByOvqyAKp0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
Expand Down Expand Up @@ -75,6 +77,8 @@ github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aev
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down Expand Up @@ -111,12 +115,14 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc=
github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-github/v41 v41.0.0 h1:HseJrM2JFf2vfiZJ8anY2hqBjdfY1Vlj/K27ueww4gg=
github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg=
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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
Expand Down Expand Up @@ -223,6 +229,7 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
Expand Down
2 changes: 1 addition & 1 deletion internal/check/package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestRespectingCanceledContext(t *testing.T) {
check.NewFileExist(),
check.NewValidSyntax(),
check.NewNotOwnedFile(check.NotOwnedFileConfig{}),
must(check.NewValidOwner(check.ValidOwnerConfig{Repository: "org/repo"}, nil)),
must(check.NewValidOwner(check.ValidOwnerConfig{Repository: "org/repo"}, nil, true)),
}

for _, checker := range checkers {
Expand Down
12 changes: 10 additions & 2 deletions internal/check/valid_owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

"github.com/mszostok/codeowners-validator/internal/ctxutil"

"github.com/google/go-github/v29/github"
"github.com/google/go-github/v41/github"
"github.com/pkg/errors"
)

Expand Down Expand Up @@ -40,6 +40,7 @@ type ValidOwnerConfig struct {
// ValidOwner validates each owner
type ValidOwner struct {
ghClient *github.Client
checkScopes bool
orgMembers *map[string]struct{}
orgName string
orgTeams []*github.Team
Expand All @@ -50,7 +51,7 @@ type ValidOwner struct {
}

// NewValidOwner returns new instance of the ValidOwner
func NewValidOwner(cfg ValidOwnerConfig, ghClient *github.Client) (*ValidOwner, error) {
func NewValidOwner(cfg ValidOwnerConfig, ghClient *github.Client, checkScopes bool) (*ValidOwner, error) {
split := strings.Split(cfg.Repository, "/")
if len(split) != 2 {
return nil, errors.Errorf("Wrong repository name. Expected pattern 'owner/repository', got '%s'", cfg.Repository)
Expand All @@ -63,6 +64,7 @@ func NewValidOwner(cfg ValidOwnerConfig, ghClient *github.Client) (*ValidOwner,

return &ValidOwner{
ghClient: ghClient,
checkScopes: checkScopes,
orgName: split[0],
orgRepoName: split[1],
ignOwners: ignOwners,
Expand Down Expand Up @@ -368,6 +370,12 @@ func (v *ValidOwner) CheckSatisfied(ctx context.Context) error {
}
}

if !v.checkScopes {
// If the GitHub client uses a GitHub App, the headers won't have scope information.
// TODO: Call the https://api.github.com/app/installations and check if the `permission` field has `"members": "read"
return nil
}

return v.checkRequiredScopes(resp.Header)
}

Expand Down
6 changes: 3 additions & 3 deletions internal/check/valid_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestValidOwnerCheckerIgnoredOwner(t *testing.T) {
ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{
Repository: "org/repo",
IgnoredOwners: []string{"@owner1"},
}, nil)
}, nil, true)
require.NoError(t, err)

givenCodeowners := `* @owner1`
Expand Down Expand Up @@ -106,7 +106,7 @@ func TestValidOwnerCheckerIgnoredOwner(t *testing.T) {
Repository: "org/repo",
AllowUnownedPatterns: tc.allowUnownedPatterns,
IgnoredOwners: []string{"@owner1"},
}, nil)
}, nil, true)
require.NoError(t, err)

// when
Expand Down Expand Up @@ -147,7 +147,7 @@ func TestValidOwnerCheckerOwnersMustBeTeams(t *testing.T) {
Repository: "org/repo",
AllowUnownedPatterns: tc.allowUnownedPatterns,
OwnersMustBeTeams: true,
}, nil)
}, nil, true)
require.NoError(t, err)

// when
Expand Down
63 changes: 58 additions & 5 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,94 @@ package github

import (
"context"
"errors"
"net/http"
"time"

"github.com/bradleyfalzon/ghinstallation/v2"

"github.com/mszostok/codeowners-validator/pkg/url"

"github.com/google/go-github/v29/github"
"github.com/google/go-github/v41/github"
"golang.org/x/oauth2"
)

type ClientConfig struct {
AccessToken string
AccessToken string `envconfig:"optional"`

AppID int64 `envconfig:"optional"`
AppPrivateKey string `envconfig:"optional"`
AppInstallationID int64 `envconfig:"optional"`

BaseURL string `envconfig:"optional"`
UploadURL string `envconfig:"optional"`
HTTPRequestTimeout time.Duration `envconfig:"default=30s"`
}

func NewClient(ctx context.Context, cfg ClientConfig) (ghClient *github.Client, err error) {
// Validate validates if provided client options are valid.
func (c *ClientConfig) Validate() error {
if c.AccessToken == "" && c.AppID == 0 {
return errors.New("GitHub authorization is required, provide ACCESS_TOKEN or APP_ID")
}

if c.AccessToken != "" && c.AppID != 0 {
return errors.New("GitHub ACCESS_TOKEN cannot be provided when APP_ID is specified")
}

if c.AppID != 0 {
if c.AppInstallationID == 0 {
return errors.New("GitHub APP_INSTALLATION_ID is required with APP_ID")
}
if c.AppPrivateKey == "" {
return errors.New("GitHub APP_PRIVATE_KEY is required with APP_ID")
}
}

return nil
}

func NewClient(ctx context.Context, cfg *ClientConfig) (ghClient *github.Client, isApp bool, err error) {
if err := cfg.Validate(); err != nil {
return nil, false, err
}

httpClient := http.DefaultClient

if cfg.AccessToken != "" {
httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: cfg.AccessToken},
))
} else if cfg.AppID != 0 {
httpClient, err = createAppInstallationHTTPClient(cfg)
isApp = true
if err != nil {
return
}
}
httpClient.Timeout = cfg.HTTPRequestTimeout

baseURL, uploadURL := cfg.BaseURL, cfg.UploadURL

if baseURL == "" {
return github.NewClient(httpClient), nil
ghClient = github.NewClient(httpClient)
return
}

if uploadURL == "" { // often the baseURL is same as the uploadURL, so we do not require to provide both of them
uploadURL = baseURL
}

bURL, uURL := url.CanonicalPath(baseURL), url.CanonicalPath(uploadURL)
return github.NewEnterpriseClient(bURL, uURL, httpClient)
ghClient, err = github.NewEnterpriseClient(bURL, uURL, httpClient)
return
}

func createAppInstallationHTTPClient(cfg *ClientConfig) (client *http.Client, err error) {
tr := http.DefaultTransport
itr, err := ghinstallation.New(tr, cfg.AppID, cfg.AppInstallationID, []byte(cfg.AppPrivateKey))
if err != nil {
return nil, err
}

return &http.Client{Transport: itr}, nil
}
4 changes: 2 additions & 2 deletions internal/load/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ func Checks(ctx context.Context, enabledChecks, experimentalChecks []string) ([]
return nil, errors.Wrapf(err, "while loading config for %s", "owners")
}

ghClient, err := github.NewClient(ctx, cfg.Github)
ghClient, isApp, err := github.NewClient(ctx, &cfg.Github)
if err != nil {
return nil, errors.Wrap(err, "while creating GitHub client")
}

owners, err := check.NewValidOwner(cfg.OwnerChecker, ghClient)
owners, err := check.NewValidOwner(cfg.OwnerChecker, ghClient, !isApp)
if err != nil {
return nil, errors.Wrap(err, "while enabling 'owners' checker")
}
Expand Down
Loading

0 comments on commit 7dfc6dc

Please sign in to comment.