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 8 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ dist/
tmp-CHANGELOG.md

.envrc

Dockerfile.local
nitrocode marked this conversation as resolved.
Show resolved Hide resolved
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
34 changes: 9 additions & 25 deletions server/events/github_app_working_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ 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"
Expand All @@ -22,34 +20,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
authURL := fmt.Sprintf("://")
baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:@", authURL, 1)
baseRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://:@", "://", 1)
headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:@", authURL, 1)
headRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://@", "://", 1)
jonathanwiemers marked this conversation as resolved.
Show resolved Hide resolved

return g.WorkingDir.Clone(log, headRepo, p, workspace)
}
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package events_test
package vcs_test

import (
"fmt"
Expand All @@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"

"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
)
Expand All @@ -18,7 +18,7 @@ var logger logging.SimpleLogging
func TestWriteGitCreds_WriteFile(t *testing.T) {
tmp := t.TempDir()

err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)

expContents := `https://user:token@hostname`
Expand All @@ -37,7 +37,7 @@ func TestWriteGitCreds_Appends(t *testing.T) {
err := os.WriteFile(credsFile, []byte("contents"), 0600)
Ok(t, err)

err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)

expContents := "contents\nhttps://user:token@hostname"
Expand All @@ -56,7 +56,7 @@ func TestWriteGitCreds_NoModification(t *testing.T) {
err := os.WriteFile(credsFile, []byte(contents), 0600)
Ok(t, err)

err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)
actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials"))
Ok(t, err)
Expand All @@ -72,7 +72,7 @@ func TestWriteGitCreds_ReplaceApp(t *testing.T) {
err := os.WriteFile(credsFile, []byte(contents), 0600)
Ok(t, err)

err = events.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true)
err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true)
Ok(t, err)
expContets := "line1\nhttps://x-access-token:token@github.com\nline2"
actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials"))
Expand All @@ -89,7 +89,7 @@ func TestWriteGitCreds_AppendApp(t *testing.T) {
err := os.WriteFile(credsFile, []byte(contents), 0600)
Ok(t, err)

err = events.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true)
err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true)
Ok(t, err)
expContets := "https://x-access-token:token@github.com"
actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials"))
Expand All @@ -107,23 +107,23 @@ func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) {
Ok(t, err)

expErr := fmt.Sprintf("open %s: permission denied", credsFile)
actErr := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
actErr := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
ErrContains(t, expErr, actErr)
}

// Test that if we can't write, we error out.
func TestWriteGitCreds_ErrIfCannotWrite(t *testing.T) {
credsFile := "/this/dir/does/not/exist/.git-credentials" // nolint: gosec
expErr := fmt.Sprintf("writing generated .git-credentials file with user, token and hostname to %s: open %s: no such file or directory", credsFile, credsFile)
actErr := events.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false)
actErr := vcs.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false)
ErrEquals(t, expErr, actErr)
}

// Test that git is actually configured to use the credentials
func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) {
tmp := t.TempDir()

err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)

expOutput := `store`
Expand All @@ -136,7 +136,7 @@ func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) {
func TestWriteGitCreds_ConfigureGitUrlOverride(t *testing.T) {
tmp := t.TempDir()

err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)

expOutput := `ssh://git@hostname`
Expand Down
14 changes: 10 additions & 4 deletions server/scheduled/executor_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type ExecutorService struct {
log logging.SimpleLogging

// jobs
runtimeStatsPublisher JobDefinition
jobs []JobDefinition
}

func NewExecutorService(
Expand All @@ -33,11 +33,15 @@ func NewExecutorService(
}

return &ExecutorService{
log: log,
runtimeStatsPublisher: runtimeStatsPublisherJob,
log: log,
jobs: []JobDefinition{runtimeStatsPublisherJob},
}
}

func (s *ExecutorService) AddJob(jd JobDefinition) {
nitrocode marked this conversation as resolved.
Show resolved Hide resolved
s.jobs = append(s.jobs, jd)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice 👍


type JobDefinition struct {
Job Job
Period time.Duration
Expand All @@ -50,7 +54,9 @@ func (s *ExecutorService) Run() {

var wg sync.WaitGroup

s.runScheduledJob(ctx, &wg, s.runtimeStatsPublisher)
for _, jd := range s.jobs {
s.runScheduledJob(ctx, &wg, jd)
}

interrupt := make(chan os.Signal, 1)

Expand Down
Loading