Skip to content

Commit

Permalink
feat: Rotate Github App Token outside of Atlantis commands (runatlant…
Browse files Browse the repository at this point in the history
…is#3208)

Co-authored-by: nitrocode <[email protected]>
Co-authored-by: PePe Amengual <[email protected]>
  • Loading branch information
3 people authored and ijames-gc committed Feb 13, 2024
1 parent 61642d4 commit 6e7861d
Show file tree
Hide file tree
Showing 18 changed files with 376 additions and 61 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ atlantis
atlantis.env
*.act
package-lock.json
Dockerfile.local

# gitreleaser
dist/
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ services:
- 4040:4040
environment:
# https://dashboard.ngrok.com/get-started/your-authtoken
NGROK_AUTH: REPLACE-WITH-YOUR-TOKEN
# NGROK_AUTH: REPLACE-WITH-YOUR-TOKEN // set this in atlantis.env
NGROK_PROTOCOL: http
NGROK_PORT: atlantis:4141
env_file:
- ./atlantis.env
depends_on:
- atlantis
redis:
Expand Down
2 changes: 2 additions & 0 deletions server/events/apply_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
)

func TestApplyCommandRunner_IsLocked(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

cases := []struct {
Expand Down Expand Up @@ -78,6 +79,7 @@ func TestApplyCommandRunner_IsLocked(t *testing.T) {
}

func TestApplyCommandRunner_IsSilenced(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

cases := []struct {
Expand Down
1 change: 1 addition & 0 deletions server/events/approve_policies_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
)

func TestApproveCommandRunner_IsOwner(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

cases := []struct {
Expand Down
2 changes: 1 addition & 1 deletion server/events/command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock
githubGetter = mocks.NewMockGithubPullGetter()
gitlabGetter = mocks.NewMockGitlabMergeRequestGetter()
azuredevopsGetter = mocks.NewMockAzureDevopsPullGetter()
logger = logging.NewNoopLogger(t)
logger := logging.NewNoopLogger(t)
projectCommandRunner = mocks.NewMockProjectCommandRunner()
workingDir = mocks.NewMockWorkingDir()
pendingPlanFinder = mocks.NewMockPendingPlanFinder()
Expand Down
39 changes: 12 additions & 27 deletions server/events/github_app_working_dir.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package events

import (
"fmt"
"strings"

"github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/logging"
)

const redactedReplacement = "://:<redacted>@"

// GithubAppWorkingDir implements WorkingDir.
// It acts as a proxy to an instance of WorkingDir that refreshes the app's token
// before every clone, given Github App tokens expire quickly
Expand All @@ -21,35 +20,21 @@ type GithubAppWorkingDir struct {
}

// Clone writes a fresh token for Github App authentication
func (g *GithubAppWorkingDir) Clone(log logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string, additionalBranches []string) (string, bool, error) {

log.Info("Refreshing git tokens for Github App")

token, err := g.Credentials.GetToken()
if err != nil {
return "", false, errors.Wrap(err, "getting github token")
}

home, err := homedir.Dir()
if err != nil {
return "", false, errors.Wrap(err, "getting home dir to write ~/.git-credentials file")
}

// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#http-based-git-access-by-an-installation
if err := WriteGitCreds("x-access-token", token, g.GithubHostname, home, log, true); err != nil {
return "", false, err
}

func (g *GithubAppWorkingDir) Clone(log logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) {
baseRepo := &p.BaseRepo

// Realistically, this is a super brittle way of supporting clones using gh app installation tokens
// This URL should be built during Repo creation and the struct should be immutable going forward.
// Doing this requires a larger refactor however, and can probably be coupled with supporting > 1 installation
authURL := fmt.Sprintf("://x-access-token:%s", token)
baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:", authURL, 1)
baseRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://:", "://x-access-token:", 1)
headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:", authURL, 1)
headRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://:", "://x-access-token:", 1)

// This removes the credential part from the url and leaves us with the raw http url
// git will then pick up credentials from the credential store which is set in vcs.WriteGitCreds.
// Git credentials will then be rotated by vcs.GitCredsTokenRotator
replacement := "://"
baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:@", replacement, 1)
baseRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1)
headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:@", replacement, 1)
headRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1)

return g.WorkingDir.Clone(log, headRepo, p, workspace, additionalBranches)
}
10 changes: 8 additions & 2 deletions server/events/github_app_working_dir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

. "github.com/petergtz/pegomock"
pegomock "github.com/petergtz/pegomock"
"github.com/runatlantis/atlantis/server/events"
eventMocks "github.com/runatlantis/atlantis/server/events/mocks"
"github.com/runatlantis/atlantis/server/events/models"
Expand Down Expand Up @@ -57,6 +58,8 @@ func TestClone_GithubAppNoneExisting(t *testing.T) {
}

func TestClone_GithubAppSetsCorrectUrl(t *testing.T) {
pegomock.RegisterMockTestingT(t)

workingDir := eventMocks.NewMockWorkingDir()

credentials := vcsMocks.NewMockGithubCredentials()
Expand All @@ -82,8 +85,9 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) {
headRepo := baseRepo

modifiedBaseRepo := baseRepo
modifiedBaseRepo.CloneURL = "https://x-access-token:[email protected]/runatlantis/atlantis.git"
modifiedBaseRepo.SanitizedCloneURL = "https://x-access-token:<redacted>@github.com/runatlantis/atlantis.git"
// remove credentials from both urls since we want to use the credential store
modifiedBaseRepo.CloneURL = "https://github.com/runatlantis/atlantis.git"
modifiedBaseRepo.SanitizedCloneURL = "https://github.com/runatlantis/atlantis.git"

When(credentials.GetToken()).ThenReturn("token", nil)
When(workingDir.Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default", []string{})).ThenReturn(
Expand All @@ -92,5 +96,7 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) {

_, success, _ := ghAppWorkingDir.Clone(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default", []string{})

workingDir.VerifyWasCalledOnce().Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default")

Assert(t, success == true, "clone url mutation error")
}
1 change: 1 addition & 0 deletions server/events/import_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
)

func TestImportCommandRunner_Run(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

tests := []struct {
Expand Down
1 change: 1 addition & 0 deletions server/events/plan_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
)

func TestPlanCommandRunner_IsSilenced(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

cases := []struct {
Expand Down
2 changes: 2 additions & 0 deletions server/events/pull_closed_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/core/db"
"github.com/runatlantis/atlantis/server/jobs"
"github.com/runatlantis/atlantis/server/logging"
"github.com/stretchr/testify/assert"
bolt "go.etcd.io/bbolt"

Expand Down Expand Up @@ -187,6 +188,7 @@ func TestCleanUpPullComments(t *testing.T) {
}

func TestCleanUpLogStreaming(t *testing.T) {
logger := logging.NewNoopLogger(t)
RegisterMockTestingT(t)

t.Run("Should Clean Up Log Streaming Resources When PR is closed", func(t *testing.T) {
Expand Down
71 changes: 71 additions & 0 deletions server/events/vcs/gh_app_creds_rotator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package vcs

import (
"time"

"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/logging"
"github.com/runatlantis/atlantis/server/scheduled"
)

// GitCredsTokenRotator continuously tries to rotate the github app access token every 30 seconds and writes the ~/.git-credentials file
type GitCredsTokenRotator interface {
Run()
GenerateJob() (scheduled.JobDefinition, error)
}

type githubAppTokenRotator struct {
log logging.SimpleLogging
githubCredentials GithubCredentials
githubHostname string
homeDirPath string
}

func NewGithubAppTokenRotator(
log logging.SimpleLogging,
githubCredentials GithubCredentials,
githubHostname string,
homeDirPath string) GitCredsTokenRotator {

return &githubAppTokenRotator{
log: log,
githubCredentials: githubCredentials,
githubHostname: githubHostname,
homeDirPath: homeDirPath,
}
}

// make sure interface is implemented correctly
var _ GitCredsTokenRotator = (*githubAppTokenRotator)(nil)

func (r *githubAppTokenRotator) GenerateJob() (scheduled.JobDefinition, error) {

return scheduled.JobDefinition{
Job: r,
Period: 30 * time.Second,
}, r.rotate()
}

func (r *githubAppTokenRotator) Run() {
err := r.rotate()
if err != nil {
// at least log the error message here, as we want to notify the that user that the key rotation wasn't successful
r.log.Err(err.Error())
}
}

func (r *githubAppTokenRotator) rotate() error {
r.log.Debug("Refreshing git tokens for Github App")

token, err := r.githubCredentials.GetToken()
if err != nil {
return errors.Wrap(err, "Getting github token")
}
r.log.Debug("token %s", token)

// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#http-based-git-access-by-an-installation
if err := WriteGitCreds("x-access-token", token, r.githubHostname, r.homeDirPath, r.log, true); err != nil {
return errors.Wrap(err, "Writing ~/.git-credentials file")
}
return nil
}
84 changes: 84 additions & 0 deletions server/events/vcs/gh_app_creds_rotator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package vcs_test

import (
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/vcs/testdata"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
)

func Test_githubAppTokenRotator_GenerateJob(t *testing.T) {
defer disableSSLVerification()()
testServer, err := testdata.GithubAppTestServer(t)
Ok(t, err)

anonCreds := &vcs.GithubAnonymousCredentials{}
anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
tempSecrets, err := anonClient.ExchangeCode("good-code")
Ok(t, err)
type fields struct {
githubCredentials vcs.GithubCredentials
}
tests := []struct {
name string
fields fields
credsFileWritten bool
wantErr bool
}{
{
name: "Should write .git-credentials file on start",
fields: fields{&vcs.GithubAppCredentials{
AppID: tempSecrets.ID,
Key: []byte(testdata.GithubPrivateKey),
Hostname: testServer,
}},
credsFileWritten: true,
wantErr: false,
},
{
name: "Should return an error if pem data is missing or wrong",
fields: fields{&vcs.GithubAppCredentials{
AppID: tempSecrets.ID,
Key: []byte("some bad formatted pem key"),
Hostname: testServer,
}},
credsFileWritten: false,
wantErr: true,
},
{
name: "Should return an error if app id is missing or wrong",
fields: fields{&vcs.GithubAppCredentials{
AppID: 3819,
Key: []byte(testdata.GithubPrivateKey),
Hostname: testServer,
}},
credsFileWritten: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
r := vcs.NewGithubAppTokenRotator(logging.NewNoopLogger(t), tt.fields.githubCredentials, testServer, tmpDir)
got, err := r.GenerateJob()
if (err != nil) != tt.wantErr {
t.Errorf("githubAppTokenRotator.GenerateJob() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.credsFileWritten {
credsFileContent := fmt.Sprintf(`https://x-access-token:some-token@%s`, testServer)
actContents, err := os.ReadFile(filepath.Join(tmpDir, ".git-credentials"))
Ok(t, err)
Equals(t, credsFileContent, string(actContents))
}
Equals(t, 30*time.Second, got.Period)
})
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package events
package vcs

import (
"fmt"
Expand Down
Loading

0 comments on commit 6e7861d

Please sign in to comment.