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

feat: Rotate Github App Token outside of Atlantis commands #3208

Merged
merged 22 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
nitrocode marked this conversation as resolved.
Show resolved Hide resolved
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
37 changes: 11 additions & 26 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 @@ -22,34 +21,20 @@ 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) (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
}

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)
}
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")).ThenReturn(
Expand All @@ -92,5 +96,7 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) {

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

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
67 changes: 67 additions & 0 deletions server/events/vcs/gh_app_creds_rotator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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() {
r.rotate()
}

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