Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support repo creation as part of rill deploy by requesting Github write access #4488

Merged
merged 14 commits into from
Apr 9, 2024
2 changes: 2 additions & 0 deletions admin/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ type User struct {
DisplayName string `db:"display_name"`
PhotoURL string `db:"photo_url"`
GithubUsername string `db:"github_username"`
GithubRefreshToken string `db:"github_refresh_token"`
CreatedOn time.Time `db:"created_on"`
UpdatedOn time.Time `db:"updated_on"`
ActiveOn time.Time `db:"active_on"`
Expand All @@ -393,6 +394,7 @@ type UpdateUserOptions struct {
DisplayName string
PhotoURL string
GithubUsername string
GithubRefreshToken string
QuotaSingleuserOrgs int
PreferenceTimeZone string
}
Expand Down
1 change: 1 addition & 0 deletions admin/database/postgres/migrations/0025.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN github_refresh_token TEXT NOT NULL DEFAULT '';
3 changes: 2 additions & 1 deletion admin/database/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,11 +603,12 @@ func (c *connection) UpdateUser(ctx context.Context, id string, opts *database.U
}

res := &database.User{}
err := c.getDB(ctx).QueryRowxContext(ctx, "UPDATE users SET display_name=$2, photo_url=$3, github_username=$4, quota_singleuser_orgs=$5, preference_time_zone=$6, updated_on=now() WHERE id=$1 RETURNING *",
err := c.getDB(ctx).QueryRowxContext(ctx, "UPDATE users SET display_name=$2, photo_url=$3, github_username=$4, github_refresh_token=$5, quota_singleuser_orgs=$6, preference_time_zone=$7, updated_on=now() WHERE id=$1 RETURNING *",
id,
opts.DisplayName,
opts.PhotoURL,
opts.GithubUsername,
opts.GithubRefreshToken,
opts.QuotaSingleuserOrgs,
opts.PreferenceTimeZone).StructScan(res)
if err != nil {
Expand Down
152 changes: 138 additions & 14 deletions admin/server/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,89 @@ const (
githubcookieFieldRemote = "github_remote"
)

func (s *Server) GetGithubUserStatus(ctx context.Context, req *adminv1.GetGithubUserStatusRequest) (*adminv1.GetGithubUserStatusResponse, error) {
// Check the request is made by an authenticated user
claims := auth.GetClaims(ctx)
if claims.OwnerType() != auth.OwnerTypeUser {
return nil, status.Error(codes.Unauthenticated, "not authenticated")
}

user, err := s.admin.DB.FindUser(ctx, claims.OwnerID())
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
return nil, status.Error(codes.Internal, err.Error())
}
if user.GithubUsername == "" {
// If we don't have user's github username we navigate user to installtion assuming they never installed github app
grantAccessURL, err := urlutil.WithQuery(s.urls.githubConnect, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create redirect URL: %s", err)
}

return &adminv1.GetGithubUserStatusResponse{
HasAccess: false,
GrantAccessUrl: grantAccessURL,
}, nil
}
token, refreshToken, err := s.userAccessToken(ctx, user.GithubRefreshToken)
if err != nil {
// token not valid or expired, take auth again
grantAccessURL, err := urlutil.WithQuery(s.urls.githubAuth, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create redirect URL: %s", err)
}

return &adminv1.GetGithubUserStatusResponse{
HasAccess: false,
GrantAccessUrl: grantAccessURL,
}, nil
}

// refresh token changes after using it for getting a new token
// so saving the updated refresh token
user, err = s.admin.DB.UpdateUser(ctx, claims.OwnerID(), &database.UpdateUserOptions{
DisplayName: user.DisplayName,
PhotoURL: user.PhotoURL,
GithubUsername: user.GithubUsername,
GithubRefreshToken: refreshToken,
QuotaSingleuserOrgs: user.QuotaSingleuserOrgs,
PreferenceTimeZone: user.PreferenceTimeZone,
})
if err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}

installation, _, err := s.admin.Github.AppClient().Apps.FindUserInstallation(ctx, user.GithubUsername)
if err != nil {
return nil, fmt.Errorf("failed to get user installation: %w", err)
}

gitClient, err := s.admin.Github.InstallationClient(*installation.ID)
if err != nil {
return nil, fmt.Errorf("failed to get installation client: %w", err)
}

orgs, _, err := gitClient.Organizations.List(ctx, user.GithubUsername, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user organizations: %s", err.Error())
}

var orgNames []string
for _, org := range orgs {
orgNames = append(orgNames, org.GetLogin())
}

return &adminv1.GetGithubUserStatusResponse{
HasAccess: true,
GrantAccessUrl: "",
AccessToken: token,
Account: user.GithubUsername,
Organizations: orgNames,
}, nil
}

func (s *Server) GetGithubRepoStatus(ctx context.Context, req *adminv1.GetGithubRepoStatusRequest) (*adminv1.GetGithubRepoStatusResponse, error) {
observability.AddRequestAttributes(ctx,
attribute.String("args.github_url", req.GithubUrl),
Expand Down Expand Up @@ -148,7 +231,7 @@ func (s *Server) registerGithubEndpoints(mux *http.ServeMux) {
observability.MuxHandle(inner, "/github/connect/callback", s.authenticator.HTTPMiddleware(middleware.Check(s.checkGithubRateLimit("/github/connect/callback"), http.HandlerFunc(s.githubConnectCallback))))
observability.MuxHandle(inner, "/github/auth/login", s.authenticator.HTTPMiddleware(middleware.Check(s.checkGithubRateLimit("github/auth/login"), http.HandlerFunc(s.githubAuthLogin))))
observability.MuxHandle(inner, "/github/auth/callback", s.authenticator.HTTPMiddleware(middleware.Check(s.checkGithubRateLimit("github/auth/callback"), http.HandlerFunc(s.githubAuthCallback))))
observability.MuxHandle(inner, "/github/post-auth-redirect", s.authenticator.HTTPMiddleware(middleware.Check(s.checkGithubRateLimit("github/post-auth-redirect"), http.HandlerFunc(s.githubRepoStatus))))
observability.MuxHandle(inner, "/github/post-auth-redirect", s.authenticator.HTTPMiddleware(middleware.Check(s.checkGithubRateLimit("github/post-auth-redirect"), http.HandlerFunc(s.githubStatus))))
mux.Handle("/github/", observability.Middleware("admin", s.logger, inner))
}

Expand Down Expand Up @@ -230,7 +313,7 @@ func (s *Server) githubConnectCallback(w http.ResponseWriter, r *http.Request) {
}

// exchange code to get an auth token and create a github client with user auth
githubClient, err := s.userAuthGithubClient(ctx, code)
githubClient, refreshToken, err := s.userAuthGithubClient(ctx, code)
if err != nil {
http.Error(w, "unauthorised user", http.StatusUnauthorized)
return
Expand All @@ -255,6 +338,7 @@ func (s *Server) githubConnectCallback(w http.ResponseWriter, r *http.Request) {
DisplayName: user.DisplayName,
PhotoURL: user.PhotoURL,
GithubUsername: githubUser.GetLogin(),
GithubRefreshToken: refreshToken,
QuotaSingleuserOrgs: user.QuotaSingleuserOrgs,
PreferenceTimeZone: user.PreferenceTimeZone,
})
Expand Down Expand Up @@ -404,7 +488,7 @@ func (s *Server) githubAuthCallback(w http.ResponseWriter, r *http.Request) {
}

// exchange code to get an auth token and create a github client with user auth
c, err := s.userAuthGithubClient(ctx, code)
c, refreshToken, err := s.userAuthGithubClient(ctx, code)
if err != nil {
// todo :: check for unauthorised user error
http.Error(w, fmt.Sprintf("internal error %s", err.Error()), http.StatusInternalServerError)
Expand Down Expand Up @@ -434,6 +518,7 @@ func (s *Server) githubAuthCallback(w http.ResponseWriter, r *http.Request) {
DisplayName: user.DisplayName,
PhotoURL: user.PhotoURL,
GithubUsername: gitUser.GetLogin(),
GithubRefreshToken: refreshToken,
QuotaSingleuserOrgs: user.QuotaSingleuserOrgs,
PreferenceTimeZone: user.PreferenceTimeZone,
})
Expand Down Expand Up @@ -512,9 +597,10 @@ func (s *Server) githubWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

// githubRepoStatus is a http wrapper over [GetGithubRepoStatus]. It redirects to the grantAccessURL if there is no access.
// githubStatus is a http wrapper over [GetGithubRepoStatus]/[GetGithubUserStatus] depending upon whether `remote` query is passed.
// It redirects to the grantAccessURL if there is no access.
// It's implemented as a non-gRPC endpoint mounted directly on /github/post-auth-redirect.
func (s *Server) githubRepoStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) githubStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check the request is made by an authenticated user
claims := auth.GetClaims(ctx)
Expand All @@ -523,18 +609,36 @@ func (s *Server) githubRepoStatus(w http.ResponseWriter, r *http.Request) {
return
}

resp, err := s.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{GithubUrl: r.URL.Query().Get("remote")})
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch github repo status: %s", err), http.StatusInternalServerError)
return
var (
hasAccess bool
grantAccessURL string
remote = r.URL.Query().Get("remote")
)

if remote == "" {
resp, err := s.GetGithubUserStatus(ctx, &adminv1.GetGithubUserStatusRequest{})
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch user status: %s", err), http.StatusInternalServerError)
return
}
hasAccess = resp.HasAccess
grantAccessURL = resp.GrantAccessUrl
} else {
resp, err := s.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{GithubUrl: remote})
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch github repo status: %s", err), http.StatusInternalServerError)
return
}
hasAccess = resp.HasAccess
grantAccessURL = resp.GrantAccessUrl
}

if resp.HasAccess {
if hasAccess {
http.Redirect(w, r, s.urls.githubConnectSuccess, http.StatusTemporaryRedirect)
return
}

redirectURL, err := urlutil.WithQuery(s.urls.githubConnectUI, map[string]string{"redirect": resp.GrantAccessUrl})
redirectURL, err := urlutil.WithQuery(s.urls.githubConnectUI, map[string]string{"redirect": grantAccessURL})
if err != nil {
http.Error(w, fmt.Sprintf("failed to create redirect URL: %s", err), http.StatusInternalServerError)
return
Expand All @@ -543,7 +647,7 @@ func (s *Server) githubRepoStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}

func (s *Server) userAuthGithubClient(ctx context.Context, code string) (*github.Client, error) {
func (s *Server) userAuthGithubClient(ctx context.Context, code string) (*github.Client, string, error) {
oauthConf := &oauth2.Config{
ClientID: s.opts.GithubClientID,
ClientSecret: s.opts.GithubClientSecret,
Expand All @@ -552,11 +656,11 @@ func (s *Server) userAuthGithubClient(ctx context.Context, code string) (*github

token, err := oauthConf.Exchange(ctx, code)
if err != nil {
return nil, err
return nil, "", err
}

oauthClient := oauthConf.Client(ctx, token)
return github.NewClient(oauthClient), nil
return github.NewClient(oauthClient), token.RefreshToken, nil
}

// isCollaborator checks if the user is a collaborator of the repository identified by owner and repo
Expand Down Expand Up @@ -605,3 +709,23 @@ func (s *Server) checkGithubRateLimit(route string) middleware.CheckFunc {
return nil
}
}

func (s *Server) userAccessToken(ctx context.Context, refreshToken string) (string, string, error) {
if refreshToken == "" {
return "", "", errors.New("refresh token is empty")
}

oauthConf := &oauth2.Config{
ClientID: s.opts.GithubClientID,
ClientSecret: s.opts.GithubClientSecret,
Endpoint: githuboauth.Endpoint,
}

src := oauthConf.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
oauthToken, err := src.Token()
if err != nil {
return "", "", err
}

return oauthToken.AccessToken, oauthToken.RefreshToken, nil
}
2 changes: 2 additions & 0 deletions admin/server/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func (s *Server) UpdateUserPreferences(ctx context.Context, req *adminv1.UpdateU
DisplayName: user.DisplayName,
PhotoURL: user.PhotoURL,
GithubUsername: user.GithubUsername,
GithubRefreshToken: user.GithubRefreshToken,
QuotaSingleuserOrgs: user.QuotaSingleuserOrgs,
PreferenceTimeZone: valOrDefault(req.Preferences.TimeZone, user.PreferenceTimeZone),
})
Expand Down Expand Up @@ -307,6 +308,7 @@ func (s *Server) SudoUpdateUserQuotas(ctx context.Context, req *adminv1.SudoUpda
DisplayName: user.DisplayName,
PhotoURL: user.PhotoURL,
GithubUsername: user.GithubUsername,
GithubRefreshToken: user.GithubRefreshToken,
QuotaSingleuserOrgs: int(valOrDefault(req.SingleuserOrgs, uint32(user.QuotaSingleuserOrgs))),
PreferenceTimeZone: user.PreferenceTimeZone,
})
Expand Down
1 change: 1 addition & 0 deletions admin/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func (s *Service) CreateOrUpdateUser(ctx context.Context, email, name, photoURL
DisplayName: name,
PhotoURL: photoURL,
GithubUsername: user.GithubUsername,
GithubRefreshToken: user.GithubRefreshToken,
QuotaSingleuserOrgs: user.QuotaSingleuserOrgs,
PreferenceTimeZone: user.PreferenceTimeZone,
})
Expand Down
Loading
Loading