Skip to content

Commit a873b0c

Browse files
committed
[BB-508] baton-github: use github app to sync
1 parent 5d0420f commit a873b0c

38 files changed

+2990
-8
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/conductorone/baton-sdk v0.3.8
99
github.com/deckarep/golang-set/v2 v2.7.0
1010
github.com/ennyjfrick/ruleguard-logfatal v0.0.2
11+
github.com/golang-jwt/jwt/v5 v5.2.2
1112
github.com/google/go-github/v63 v63.0.0
1213
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
1314
github.com/migueleliasweb/go-github-mock v0.0.23

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ github.com/go-toolsmith/astequal v1.0.3 h1:+LVdyRatFS+XO78SGV4I3TCEA0AC7fKEGma+f
116116
github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4=
117117
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
118118
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
119+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
120+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
119121
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
120122
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
121123
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=

pkg/config/conf.gen.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/config/config.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package config
22

33
import (
4+
"fmt"
5+
46
"github.com/conductorone/baton-sdk/pkg/field"
57
)
68

79
var (
810
accessTokenField = field.StringField(
911
"token",
1012
field.WithDescription("The GitHub access token used to connect to the GitHub API."),
11-
field.WithRequired(true),
1213
)
1314
orgsField = field.StringSliceField(
1415
"orgs",
@@ -18,11 +19,30 @@ var (
1819
"instance-url",
1920
field.WithDescription(`The GitHub instance URL to connect to. (default "https://github.com")`),
2021
)
22+
appIDField = field.StringField(
23+
"app-id",
24+
field.WithDescription("The GitHub App to connect to."),
25+
)
26+
appPrivateKey = field.StringField(
27+
"app-privatekey",
28+
field.WithDescription("The private key used to connect to the GitHub App"),
29+
)
2130
)
2231

2332
//go:generate go run ./gen
2433
var Config = field.NewConfiguration([]field.SchemaField{
2534
accessTokenField,
2635
orgsField,
2736
instanceUrlField,
37+
appIDField,
38+
appPrivateKey,
2839
})
40+
41+
func ValidateConfig(cfg *Github) error {
42+
apiKey := cfg.GetString(accessTokenField.FieldName)
43+
appKey := cfg.GetString(appIDField.FieldName)
44+
if len(apiKey) == 0 && len(appKey) == 0 {
45+
return fmt.Errorf("api-key or app-privatekey is missing")
46+
}
47+
return nil
48+
}

pkg/connector/connector.go

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@ package connector
22

33
import (
44
"context"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"encoding/json"
8+
"encoding/pem"
9+
"errors"
510
"fmt"
11+
"io"
612
"net/http"
713
"net/url"
814
"strings"
15+
"time"
916

1017
cfg "github.com/conductorone/baton-github/pkg/config"
1118
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
1219
"github.com/conductorone/baton-sdk/pkg/annotations"
1320
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
1421
"github.com/conductorone/baton-sdk/pkg/uhttp"
22+
jwtv5 "github.com/golang-jwt/jwt/v5"
1523
"github.com/google/go-github/v63/github"
1624
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
1725
"github.com/shurcooL/githubv4"
@@ -54,6 +62,7 @@ var (
5462
type GitHub struct {
5563
orgs []string
5664
client *github.Client
65+
appClient *github.Client
5766
instanceURL string
5867
graphqlClient *githubv4.Client
5968
hasSAMLEnabled *bool
@@ -62,7 +71,7 @@ type GitHub struct {
6271

6372
func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer {
6473
return []connectorbuilder.ResourceSyncer{
65-
orgBuilder(gh.client, gh.orgCache, gh.orgs),
74+
orgBuilder(gh.client, gh.appClient, gh.orgCache, gh.orgs),
6675
teamBuilder(gh.client, gh.orgCache),
6776
userBuilder(gh.client, gh.hasSAMLEnabled, gh.graphqlClient, gh.orgCache),
6877
repositoryBuilder(gh.client, gh.orgCache),
@@ -78,6 +87,10 @@ func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
7887

7988
// Validate hits the GitHub API to validate that the configured credentials are still valid.
8089
func (gh *GitHub) Validate(ctx context.Context) (annotations.Annotations, error) {
90+
if gh.appClient != nil {
91+
return gh.validateAppCredentials(ctx)
92+
}
93+
8194
page := 0
8295
orgLogins := gh.orgs
8396
filterOrgs := true
@@ -99,7 +112,6 @@ func (gh *GitHub) Validate(ctx context.Context) (annotations.Annotations, error)
99112
if resp.NextPage == 0 {
100113
break
101114
}
102-
103115
page = resp.NextPage
104116
}
105117
}
@@ -132,6 +144,40 @@ func (gh *GitHub) Validate(ctx context.Context) (annotations.Annotations, error)
132144
return nil, nil
133145
}
134146

147+
func (gh *GitHub) validateAppCredentials(ctx context.Context) (annotations.Annotations, error) {
148+
page := 0
149+
orgLogins := gh.orgs
150+
151+
installationsMap := make(map[string]struct{})
152+
for {
153+
installations, resp, err := gh.appClient.Apps.ListInstallations(ctx, &github.ListOptions{Page: page})
154+
if err != nil {
155+
return nil, fmt.Errorf("github-connector: failed to retrieve org: %w", err)
156+
}
157+
if resp.StatusCode == http.StatusUnauthorized {
158+
return nil, status.Error(codes.Unauthenticated, "github token is not authorized")
159+
}
160+
for _, installation := range installations {
161+
if installation.Account == nil {
162+
continue
163+
}
164+
installationsMap[installation.Account.GetLogin()] = struct{}{}
165+
}
166+
167+
if resp.NextPage == 0 {
168+
break
169+
}
170+
page = resp.NextPage
171+
}
172+
173+
for _, o := range orgLogins {
174+
if _, ok := installationsMap[o]; !ok {
175+
return nil, fmt.Errorf("access token must be an admin on the %s organization", o)
176+
}
177+
}
178+
return nil, nil
179+
}
180+
135181
// newGitHubClient returns a new GitHub API client authenticated with an access token via oauth2.
136182
func newGitHubClient(ctx context.Context, instanceURL string, accessToken string) (*github.Client, error) {
137183
httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx)))
@@ -157,22 +203,35 @@ func newGitHubClient(ctx context.Context, instanceURL string, accessToken string
157203

158204
// New returns the GitHub connector configured to sync against the instance URL.
159205
func New(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
160-
client, err := newGitHubClient(ctx, ghc.InstanceUrl, ghc.Token)
206+
jwttoken, token, err := getClientToken(ghc)
207+
if err != nil {
208+
return nil, err
209+
}
210+
211+
client, err := newGitHubClient(ctx, ghc.InstanceUrl, token)
161212
if err != nil {
162213
return nil, err
163214
}
164-
graphqlClient, err := newGitHubGraphqlClient(ctx, ghc.InstanceUrl, ghc.Token)
215+
graphqlClient, err := newGitHubGraphqlClient(ctx, ghc.InstanceUrl, token)
165216
if err != nil {
166217
return nil, err
167218
}
219+
220+
var appClient *github.Client
221+
if jwttoken != "" {
222+
appClient, err = newGitHubClient(ctx, ghc.InstanceUrl, jwttoken)
223+
if err != nil {
224+
return nil, err
225+
}
226+
}
168227
gh := &GitHub{
169228
client: client,
229+
appClient: appClient,
170230
instanceURL: ghc.InstanceUrl,
171231
orgs: ghc.Orgs,
172232
graphqlClient: graphqlClient,
173233
orgCache: newOrgNameCache(client),
174234
}
175-
176235
return gh, nil
177236
}
178237

@@ -203,3 +262,72 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, accessToken
203262

204263
return githubv4.NewClient(tc), nil
205264
}
265+
266+
func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) {
267+
block, _ := pem.Decode([]byte(p))
268+
if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") {
269+
return nil, errors.New("invalid private key PEM format")
270+
}
271+
272+
// PKCS8 format
273+
if block.Type == "PRIVATE KEY" {
274+
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
275+
if err != nil {
276+
return nil, err
277+
}
278+
rsaKey, ok := key.(*rsa.PrivateKey)
279+
if !ok {
280+
return nil, errors.New("not an RSA private key")
281+
}
282+
return rsaKey, nil
283+
}
284+
285+
// PKCS1 format
286+
return x509.ParsePKCS1PrivateKey(block.Bytes)
287+
}
288+
289+
// getClientToken returns
290+
// 1. fine-grained personal access tokens if any.
291+
// 2. (JWT token, installation access tokens) if using github app.
292+
func getClientToken(ghc *cfg.Github) (string, string, error) {
293+
if ghc.Token != "" {
294+
return "", ghc.Token, nil
295+
}
296+
297+
key, err := loadPrivateKeyFromString(ghc.AppPrivatekey)
298+
if err != nil {
299+
return "", "", err
300+
}
301+
now := time.Now()
302+
token, err := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{
303+
"iat": now.Unix() - 60, // issued at
304+
"exp": now.Add(time.Minute * 10).Unix(), // expires
305+
"iss": ghc.AppId, // GitHub App ID
306+
}).SignedString(key)
307+
if err != nil {
308+
return "", "", err
309+
}
310+
311+
url := fmt.Sprintf("https://api.github.com/app/installations/%s/access_tokens", "67110013")
312+
313+
req, _ := http.NewRequest("POST", url, nil)
314+
req.Header.Set("Authorization", "Bearer "+token)
315+
req.Header.Set("Accept", "application/vnd.github+json")
316+
317+
resp, err := http.DefaultClient.Do(req)
318+
if err != nil {
319+
return "", "", err
320+
}
321+
defer resp.Body.Close()
322+
323+
if resp.StatusCode != 201 {
324+
body, _ := io.ReadAll(resp.Body)
325+
return "", "", fmt.Errorf("GitHub API error: %s", body)
326+
}
327+
328+
var result struct {
329+
Token string `json:"token"`
330+
}
331+
err = json.NewDecoder(resp.Body).Decode(&result)
332+
return token, result.Token, err
333+
}

0 commit comments

Comments
 (0)