diff --git a/README.md b/README.md index d46077ff..fbe77eb6 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,33 @@ Inspired by [the original][original-resource], with some important differences: Make sure to check out [#migrating](#migrating) to learn more. -## Source Configuration +## Configuration + +### Authentication +You can either use a personal access token or let the resource run as a [Github App](https://developer.github.com/apps/). + +#### Personal access token +Please set the `access_token`. +If you want github-pr-resource to work with a private repository. Set `repo:full` permissions on the access token you create on GitHub. If it is a public repository, `repo:status` is enough. + +#### Github App +This is useful when you are part of an organisation and do not want to share your personal access token with everyone else having access to your Secrets Manager. +Please provide `app_id` and `private_key`. + +We need the following permissions: +- Contents - Read-only => required +- Pull-requests - Read-only => required +- Pull-requests - Read & write => To write/delete comments on pull requests +- Commit statuses - Read & write => To set a commit status + +### Source | Parameter | Required | Example | Description | |-----------------------------|----------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `repository` | Yes | `itsdalmo/test-repository` | The repository to target. | -| `access_token` | Yes | | A Github Access Token with repository access (required for setting status on commits). N.B. If you want github-pr-resource to work with a private repository. Set `repo:full` permissions on the access token you create on GitHub. If it is a public repository, `repo:status` is enough. | +| `access_token` | Auth | | A Github Access Token with repository access (required for setting status on commits). | +| `app_id` | Auth | `69592` | The Github App app id can be found in the settings of your app. | +| `private_key` | Auth | `-----BEGIN RSA PRIVATE KEY....` | The private key of your Github App as described [here](https://docs.github.com/en/developers/apps/authenticating-with-github-apps). | | `v3_endpoint` | No | `https://api.github.com` | Endpoint to use for the V3 Github API (Restful). | | `v4_endpoint` | No | `https://api.github.com/graphql` | Endpoint to use for the V4 Github API (Graphql). | | `paths` | No | `["terraform/*/*.tf"]` | Only produce new versions if the PR includes changes to files that match one or more glob patterns or prefixes. | diff --git a/authentication.go b/authentication.go new file mode 100644 index 00000000..06fa46ac --- /dev/null +++ b/authentication.go @@ -0,0 +1,90 @@ +package resource + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type InstallationResponse struct { + Id int +} + +type TokenResponse struct { + Token string +} + +func GenerateAccessToken(s *Source, now time.Time) (string, error) { + if s.AccessToken != "" { + return s.AccessToken, nil + } + + decode, _ := pem.Decode([]byte(s.PrivateKey)) + key, _ := x509.ParsePKCS1PrivateKey(decode.Bytes) + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + panic(err) + } + + cl := jwt.Claims{ + Issuer: s.AppId, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(9 * time.Minute)), + } + signedJwt, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + if err != nil { + panic(err) + } + + var endpoint string + if s.V3Endpoint != "" { + endpoint = strings.TrimRight(s.V3Endpoint, "/") + } else { + endpoint = "https://api.github.com" + } + + installationResponse := callApi("GET", endpoint+"/repos/"+s.Repository+"/installation", signedJwt) + var ir InstallationResponse + err = json.NewDecoder(installationResponse.Body).Decode(&ir) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("Error decoding installation response with status %d", installationResponse.StatusCode)) + _, _ = fmt.Fprintln(os.Stderr, installationResponse.Body) + panic(err) + } + + tokenResponse := callApi("POST", endpoint+"/app/installations/"+strconv.Itoa(ir.Id)+"/access_tokens", signedJwt) + + var tr TokenResponse + err = json.NewDecoder(tokenResponse.Body).Decode(&tr) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("Error decoding token response with status %d", tokenResponse.StatusCode)) + _, _ = fmt.Fprintln(os.Stderr, tokenResponse.Body) + panic(err) + } + + return tr.Token, nil +} + +func callApi(method string, endpoint string, signedJwt string) *http.Response { + tokenRequest, err := http.NewRequest(method, endpoint, nil) + if err != nil { + panic(err) + } + tokenRequest.Header.Add("Authorization", "Bearer "+signedJwt) + tokenRequest.Header.Add("Accept", "application/vnd.github.machine-man-preview+json") + client := &http.Client{} + response, err := client.Do(tokenRequest) + + if err != nil { + panic(err) + } + return response +} diff --git a/authentication_test.go b/authentication_test.go new file mode 100644 index 00000000..8c4a6c44 --- /dev/null +++ b/authentication_test.go @@ -0,0 +1,132 @@ +package resource_test + +import ( + "fmt" + "github.com/stretchr/testify/assert" + resource "github.com/telia-oss/github-pr-resource" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGenerateAccessToken(t *testing.T) { + + validInstallationResponse := ` +{ + "id": 9912873, + "account": { + "login": "github", + "id": 1, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", + "avatar_url": "https://github.com/images/error/hubot_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/orgs/github", + "html_url": "https://github.com/github", + "followers_url": "https://api.github.com/users/github/followers", + "following_url": "https://api.github.com/users/github/following{/other_user}", + "gists_url": "https://api.github.com/users/github/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github/subscriptions", + "organizations_url": "https://api.github.com/users/github/orgs", + "repos_url": "https://api.github.com/orgs/github/repos", + "events_url": "https://api.github.com/orgs/github/events", + "received_events_url": "https://api.github.com/users/github/received_events", + "type": "Organization", + "site_admin": false + }, + "repository_selection": "all", + "access_tokens_url": "https://api.github.com/installations/1/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/github/settings/installations/1", + "app_id": 1, + "target_id": 1, + "target_type": "Organization", + "permissions": { + "checks": "write", + "metadata": "read", + "contents": "read" + }, + "events": [ + "push", + "pull_request" + ], + "created_at": "2018-02-09T20:51:14Z", + "updated_at": "2018-02-09T20:51:14Z", + "single_file_name": null +} +` + + validTokenResponse := ` +{ + "token": "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9", + "expires_at": "2020-06-21T00:03:29Z", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read", + "pull_requests": "write" + }, + "repository_selection": "selected" +} +` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.RequestURI == "/repos/itsdalmo/test-repository/installation" { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "application/vnd.github.machine-man-preview+json", r.Header.Get("Accept")) + assert.Equal(t, "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTI2OTE5ODIsImlhdCI6MTU5MjY5MTQ0MiwiaXNzIjoiNjk1OTIifQ.H_a6i7TpaGOsaoliH_i7AT5UMwM9LqO21lEFYiZ96_H15cEF6D_kyZrcHyinP2fSC8rX_OQ-DvPsehTNTtfOhgM-nsdgg-gTzdCSlASgc00sGhw_pjBFwHpD6V1NQojc82L8SAR9Bg75g0xVlQ_dAR_Lbtmk252X_AlabRAfdSchK_GVdc3kSEbp28lc87EF7J_lFdRCNuVi1xcLFOXPmMeu4epSBf1ZMtuts28C7iqaI4QJ9keaGFug1wpL-WLDcFbvmB2nJhBYN9tArGM0ZHZ5i4EhFyFjGpBwTyo5P7WY7P3zYtz36gwgntYRtPPivcFQ-wUWuvMpL6vKd-Pp8w", r.Header.Get("Authorization")) + _, _ = fmt.Fprintln(w, validInstallationResponse) + } + + if r.RequestURI == "/app/installations/9912873/access_tokens" { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/vnd.github.machine-man-preview+json", r.Header.Get("Accept")) + assert.Equal(t, "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTI2OTE5ODIsImlhdCI6MTU5MjY5MTQ0MiwiaXNzIjoiNjk1OTIifQ.H_a6i7TpaGOsaoliH_i7AT5UMwM9LqO21lEFYiZ96_H15cEF6D_kyZrcHyinP2fSC8rX_OQ-DvPsehTNTtfOhgM-nsdgg-gTzdCSlASgc00sGhw_pjBFwHpD6V1NQojc82L8SAR9Bg75g0xVlQ_dAR_Lbtmk252X_AlabRAfdSchK_GVdc3kSEbp28lc87EF7J_lFdRCNuVi1xcLFOXPmMeu4epSBf1ZMtuts28C7iqaI4QJ9keaGFug1wpL-WLDcFbvmB2nJhBYN9tArGM0ZHZ5i4EhFyFjGpBwTyo5P7WY7P3zYtz36gwgntYRtPPivcFQ-wUWuvMpL6vKd-Pp8w", r.Header.Get("Authorization")) + _, _ = fmt.Fprintln(w, validTokenResponse) + } + })) + defer ts.Close() + + tests := []struct { + description string + source resource.Source + expectedAccessToken string + }{ + { + description: "return given access token", + source: resource.Source{ + Repository: "itsdalmo/test-repository", + AccessToken: "oauthtoken", + }, + expectedAccessToken: "oauthtoken", + }, + { + description: "create access token", + source: resource.Source{ + Repository: "itsdalmo/test-repository", + AppId: "69592", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAv6oJaa+0BCOT0dITtJK6oPfrE2R0Iynofj/a/vq4rfAw2MJp\nXA5l6WbwmQhevSm8sIYNb2T32qLyRsVXwBo1J8ovIoTXiPdsV41D19tytjctnf+u\n0LncTo2JJR3ik7/Ynu5Id4zjnsn1pYqwLX4EFxgCuEKwdZutPyiY2J7wATfsTtOJ\nsLF8idQijG23i5Obs6AWCZcHOhvdgfYAUOxLv2WRkCG5O1aXYa6nqVn0AgRgKjaJ\nnAENEoG7O9OEWIcUiG30riQouxMHfj0bATCvYoj7a2tvn4CUqk+SBODyh3Fvi0oC\na/NCBYFeK/5uUNyXQHqWy7xEVRef5lC0XbrUVwIDAQABAoIBAFDdqhkIQ/iXFjAp\n5ZyDZ/CwiWNmN8X6UZiq0nhQSolA1SsvY4qunHsMrqiyql4/dNg5xwNf419A7t3D\nN5HavOCr4pU63UFxuyl5dc1mTpDo2PtXvGdec8BE4T9iy40xHXF48eRW8la1uUn+\nKPUYvRsNS2B46sDETSVfuJV1AahRN6aD2WnzQ7wB+S/mqsPXqy+S2zobnU70Wzmt\nhYQzsuIY+BkfOCS565guYvJt66wRGi/NsnybC6z3iRZxZygtKorMKXPjmtdoyCZ3\njkOHhXV5XH8Ldut+1mycg+6c+dTZ9RTrAzo4ouofptm9+ZNlv1aK7HrDDKYQ/x/z\n70hhIfkCgYEA7aNt7db3S6sTEA+yC5DLxxkFIK7K8qxdP6vE087fdHO+ik1LvyI+\ni5Wqj/fT2d/lYR+cbH/zy2JBy5RWfdVJ9HBW/eZ9RqQx8ry7lE5MqbwU6y+d2bCF\nAjffx3yJK1aljuVLdGeu8abYsusUQUhbslZJNg6Ar7z+FUHaChJI0KsCgYEAznk4\nx+5PtvIWj6aXTJiqtofnOtHXXxrq5alzBErvDBHHzP9/ZCucHxJZ4zqHWduj2+9b\nJUgk2BH3+wFMVKpcdTld2iTFWGaFsArTJkE4SBQGx3zcdsoESOnu/DrOTFNu+LiV\nhLzb9CHKM91fIet8MYYxKiyH09+Mi7xBtw8WQwUCgYAH/tCrCOmHHTll9/E4nGWO\nzFO01syzP4NfqgrUSYiRJXfKtXEP/Dn4fk+fymnRUcwo6WRc7i0osaSfEd2bHDsB\nw2nZ3xBl+Q5JKXpyMfQ4XcCibRa1hU/kVDbuQk1nLOIjHandP8POE5wE4Q3saF/V\nbzvFWtWPlB9EXdPVNOpIQwKBgHkJEsIQ72XdUGBxVewu6pQJ4wDWFhzIWL68sJHp\no2w92BRSCkmcTu7gARV1L/b7DHlXPOUD/6UyE15vCmHvZDfLozrHp3AE2YWzMsgQ\nH4ARTVAP3+U603wytkfh6SFRH5JqEiw30fCxBimVMbleo/UcJyID7LPFLkyT1SoM\njA5JAoGACEhaHTXWFYXv+eTJUXFcwhDZ5sRvQYRCTLGSv746lr+SpZ02bEcRvaTH\nv0K1Hph6OzhcCO27VdimngnzsoXoa2OXicWpdjX3hqhXMvwspe9a1y9u7LPZwqJH\n9Kc7VaZP1iS4EIxEc+qx38HqdeiUBcqTMRam6k3mSwactBKCKDI=\n-----END RSA PRIVATE KEY-----\n", + V3Endpoint: ts.URL, + }, + expectedAccessToken: "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9", + }, + { + description: "handle trailing slash", + source: resource.Source{ + Repository: "itsdalmo/test-repository", + AppId: "69592", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAv6oJaa+0BCOT0dITtJK6oPfrE2R0Iynofj/a/vq4rfAw2MJp\nXA5l6WbwmQhevSm8sIYNb2T32qLyRsVXwBo1J8ovIoTXiPdsV41D19tytjctnf+u\n0LncTo2JJR3ik7/Ynu5Id4zjnsn1pYqwLX4EFxgCuEKwdZutPyiY2J7wATfsTtOJ\nsLF8idQijG23i5Obs6AWCZcHOhvdgfYAUOxLv2WRkCG5O1aXYa6nqVn0AgRgKjaJ\nnAENEoG7O9OEWIcUiG30riQouxMHfj0bATCvYoj7a2tvn4CUqk+SBODyh3Fvi0oC\na/NCBYFeK/5uUNyXQHqWy7xEVRef5lC0XbrUVwIDAQABAoIBAFDdqhkIQ/iXFjAp\n5ZyDZ/CwiWNmN8X6UZiq0nhQSolA1SsvY4qunHsMrqiyql4/dNg5xwNf419A7t3D\nN5HavOCr4pU63UFxuyl5dc1mTpDo2PtXvGdec8BE4T9iy40xHXF48eRW8la1uUn+\nKPUYvRsNS2B46sDETSVfuJV1AahRN6aD2WnzQ7wB+S/mqsPXqy+S2zobnU70Wzmt\nhYQzsuIY+BkfOCS565guYvJt66wRGi/NsnybC6z3iRZxZygtKorMKXPjmtdoyCZ3\njkOHhXV5XH8Ldut+1mycg+6c+dTZ9RTrAzo4ouofptm9+ZNlv1aK7HrDDKYQ/x/z\n70hhIfkCgYEA7aNt7db3S6sTEA+yC5DLxxkFIK7K8qxdP6vE087fdHO+ik1LvyI+\ni5Wqj/fT2d/lYR+cbH/zy2JBy5RWfdVJ9HBW/eZ9RqQx8ry7lE5MqbwU6y+d2bCF\nAjffx3yJK1aljuVLdGeu8abYsusUQUhbslZJNg6Ar7z+FUHaChJI0KsCgYEAznk4\nx+5PtvIWj6aXTJiqtofnOtHXXxrq5alzBErvDBHHzP9/ZCucHxJZ4zqHWduj2+9b\nJUgk2BH3+wFMVKpcdTld2iTFWGaFsArTJkE4SBQGx3zcdsoESOnu/DrOTFNu+LiV\nhLzb9CHKM91fIet8MYYxKiyH09+Mi7xBtw8WQwUCgYAH/tCrCOmHHTll9/E4nGWO\nzFO01syzP4NfqgrUSYiRJXfKtXEP/Dn4fk+fymnRUcwo6WRc7i0osaSfEd2bHDsB\nw2nZ3xBl+Q5JKXpyMfQ4XcCibRa1hU/kVDbuQk1nLOIjHandP8POE5wE4Q3saF/V\nbzvFWtWPlB9EXdPVNOpIQwKBgHkJEsIQ72XdUGBxVewu6pQJ4wDWFhzIWL68sJHp\no2w92BRSCkmcTu7gARV1L/b7DHlXPOUD/6UyE15vCmHvZDfLozrHp3AE2YWzMsgQ\nH4ARTVAP3+U603wytkfh6SFRH5JqEiw30fCxBimVMbleo/UcJyID7LPFLkyT1SoM\njA5JAoGACEhaHTXWFYXv+eTJUXFcwhDZ5sRvQYRCTLGSv746lr+SpZ02bEcRvaTH\nv0K1Hph6OzhcCO27VdimngnzsoXoa2OXicWpdjX3hqhXMvwspe9a1y9u7LPZwqJH\n9Kc7VaZP1iS4EIxEc+qx38HqdeiUBcqTMRam6k3mSwactBKCKDI=\n-----END RSA PRIVATE KEY-----\n", + V3Endpoint: ts.URL + "/", + }, + expectedAccessToken: "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := resource.GenerateAccessToken(&tt.source, time.Unix(1592691442, 0)) + assert.Nil(t, err) + assert.Equal(t, tt.expectedAccessToken, got) + }) + } +} diff --git a/git.go b/git.go index 53f339d4..b9f1f8ad 100644 --- a/git.go +++ b/git.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) // Git interface for testing purposes. @@ -34,8 +35,12 @@ func NewGitClient(source *Source, dir string, output io.Writer) (*GitClient, err if source.DisableGitLFS { os.Setenv("GIT_LFS_SKIP_SMUDGE", "true") } + accessToken, err := GenerateAccessToken(source, time.Now()) + if err != nil { + return nil, err + } return &GitClient{ - AccessToken: source.AccessToken, + AccessToken: accessToken, Directory: dir, Output: output, }, nil diff --git a/github.go b/github.go index ab10cbdc..30f0afd5 100644 --- a/github.go +++ b/github.go @@ -11,6 +11,7 @@ import ( "path" "strconv" "strings" + "time" "github.com/google/go-github/v28/github" "github.com/shurcooL/githubv4" @@ -57,8 +58,12 @@ func NewGithubClient(s *Source) (*GithubClient, error) { ctx = context.TODO() } + accessToken, err := GenerateAccessToken(s, time.Now()) + if err != nil { + return nil, err + } client := oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: s.AccessToken}, + &oauth2.Token{AccessToken: accessToken}, )) var v3 *github.Client diff --git a/go.mod b/go.mod index ef1f5ca4..032fafd7 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/tools v0.0.0-20200423205358-59e73619c742 // indirect google.golang.org/appengine v1.6.6 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 ) go 1.14 diff --git a/go.sum b/go.sum index 82ec24e7..59661cb9 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= diff --git a/models.go b/models.go index 9e4e7b1c..e37adfbf 100644 --- a/models.go +++ b/models.go @@ -13,6 +13,8 @@ import ( type Source struct { Repository string `json:"repository"` AccessToken string `json:"access_token"` + AppId string `json:"app_id"` + PrivateKey string `json:"private_key"` V3Endpoint string `json:"v3_endpoint"` V4Endpoint string `json:"v4_endpoint"` Paths []string `json:"paths"` @@ -31,8 +33,8 @@ type Source struct { // Validate the source configuration. func (s *Source) Validate() error { - if s.AccessToken == "" { - return errors.New("access_token must be set") + if s.AccessToken == "" && (s.AppId == "" || s.PrivateKey == "") { + return errors.New("access_token or app_id and private_key must be set") } if s.Repository == "" { return errors.New("repository must be set")