From 40331f497939a6b7094ecb734cd2a99ba1e7d93d Mon Sep 17 00:00:00 2001 From: Liam Galvin Date: Mon, 18 Feb 2019 14:19:56 +0000 Subject: [PATCH] Support signed commits --- README.md | 12 +- codeowners/provider.go | 46 +++- codeowners/resource_file.go | 95 +++++++- codeowners/util.go | 229 ++++++++++-------- .../google/go-github/github/git_commits.go | 4 + 5 files changed, 276 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 6333b073..f5cfb7d0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,17 @@ Do you use terraform to manage your GitHub organisation? Are you frustrated that Download the relevant binary from [releases](https://github.com/form3tech-oss/terraform-provider-codeowners/releases) and copy it to `$HOME/.terraform.d/plugins/`. -## Authentication +## Configuration + +The following provider block variables are available for configuration: + +- `github_token` GitHub auth token - see below section. (read from env var `$GITHUB_TOKEN`) +- `username` Username to use in commits (read from env var `$GITHUB_USERNAME`) +- `email` Email to use in commits - this must match the email in your GPG key if you are signing commits (read from env var `$GITHUB_EMAIL`) +- `gpg_secret_key` The private GPG key to use to sign commits (optional) (read from env var `$GPG_SECRET_KEY`) +- `gpg_passphrase` The passphrase associated with the aforementioned GPG key (optional) (read from env var `$GPG_PASSPHRASE`) + +### Authentication There are two methods for authenticating with this provider. diff --git a/codeowners/provider.go b/codeowners/provider.go index adc0cf3c..cb741247 100644 --- a/codeowners/provider.go +++ b/codeowners/provider.go @@ -18,6 +18,34 @@ func Provider() *schema.Provider { DefaultFunc: schema.EnvDefaultFunc("GITHUB_TOKEN", nil), Sensitive: true, }, + "gpg_passphrase": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "The passphrase associated with your gpg_secret_key, if you have provided one", + DefaultFunc: schema.EnvDefaultFunc("GPG_PASSPHRASE", ""), + Sensitive: true, + }, + "gpg_secret_key": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "GPG secret key to use to sign github commits", + DefaultFunc: schema.EnvDefaultFunc("GPG_SECRET_KEY", ""), + Sensitive: true, + }, + "email": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Email to use for commit messages - if a GPG key is provided, this email must match that used in the key", + DefaultFunc: schema.EnvDefaultFunc("GITHUB_EMAIL", nil), + Sensitive: true, + }, + "username": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Username to use for commit messages", + DefaultFunc: schema.EnvDefaultFunc("GITHUB_USERNAME", nil), + Sensitive: true, + }, }, ResourcesMap: map[string]*schema.Resource{ "codeowners_file": resourceFile(), @@ -26,6 +54,14 @@ func Provider() *schema.Provider { } } +type providerConfiguration struct { + client *github.Client + ghUsername string + ghEmail string + gpgKey string + gpgPassphrase string +} + func providerConfigure(d *schema.ResourceData) (interface{}, error) { ctx := context.Background() @@ -34,7 +70,11 @@ func providerConfigure(d *schema.ResourceData) (interface{}, error) { ) tc := oauth2.NewClient(ctx, ts) - client := github.NewClient(tc) - - return client, nil + return &providerConfiguration{ + client: github.NewClient(tc), + ghEmail: d.Get("email").(string), + ghUsername: d.Get("username").(string), + gpgKey: d.Get("gpg_secret_key").(string), + gpgPassphrase: d.Get("gpg_passphrase").(string), + }, nil } diff --git a/codeowners/resource_file.go b/codeowners/resource_file.go index 09e8b19d..8ddafe95 100644 --- a/codeowners/resource_file.go +++ b/codeowners/resource_file.go @@ -1,7 +1,9 @@ package codeowners import ( + "context" "fmt" + "net/http" "github.com/google/go-github/github" "github.com/hashicorp/terraform/helper/schema" @@ -64,27 +66,61 @@ func resourceFile() *schema.Resource { func resourceFileRead(d *schema.ResourceData, m interface{}) error { - client := m.(*github.Client) + config := m.(*providerConfiguration) file := expandFile(d) - ruleset, err := readRulesetForRepo(client, d.Get("branch").(string), file.RepositoryOwner, file.RepositoryName) + getOptions := &github.RepositoryContentGetOptions{} + if d.Get("branch").(string) != "" { + getOptions.Ref = d.Get("branch").(string) + } + + ctx := context.Background() + codeOwnerContent, _, rr, err := config.client.Repositories.GetContents(ctx, file.RepositoryOwner, file.RepositoryName, codeownersPath, getOptions) + if err != nil || rr.StatusCode >= 500 { + return fmt.Errorf("failed to retrieve file %s: %v", codeownersPath, err) + } + + if rr.StatusCode == http.StatusNotFound { + return fmt.Errorf("file %s does not exist", codeownersPath) + } + + raw, err := codeOwnerContent.GetContent() if err != nil { - return err + return fmt.Errorf("failed to retrieve content for %s: %s", codeownersPath, err) } - file.Ruleset = ruleset + file.Ruleset = parseRulesFile(raw) return flattenFile(file, d) } func resourceFileCreate(d *schema.ResourceData, m interface{}) error { - client := m.(*github.Client) + config := m.(*providerConfiguration) file := expandFile(d) - if err := createRulesetForRepo(client, file.Branch, file.RepositoryOwner, file.RepositoryName, file.Ruleset, "Adding CODEOWNERS file"); err != nil { + entries := []github.TreeEntry{ + github.TreeEntry{ + Path: github.String(codeownersPath), + Content: github.String(string(file.Ruleset.Compile())), + Type: github.String("blob"), + Mode: github.String("100644"), + }, + } + + if err := createCommit(config.client, &signedCommitOptions{ + repoName: file.RepositoryOwner, + repoOwner: file.RepositoryName, + branch: file.Branch, + commitMessage: "Adding CODEOWNERS file", + gpgPassphrase: config.gpgPassphrase, + gpgPrivateKey: config.gpgKey, + username: config.ghUsername, + email: config.ghEmail, + changes: entries, + }); err != nil { return err } @@ -93,11 +129,30 @@ func resourceFileCreate(d *schema.ResourceData, m interface{}) error { func resourceFileUpdate(d *schema.ResourceData, m interface{}) error { - client := m.(*github.Client) + config := m.(*providerConfiguration) file := expandFile(d) - if err := updateRulesetForRepo(client, file.Branch, file.RepositoryOwner, file.RepositoryName, file.Ruleset, "Adding CODEOWNERS file"); err != nil { + entries := []github.TreeEntry{ + github.TreeEntry{ + Path: github.String(codeownersPath), + Content: github.String(string(file.Ruleset.Compile())), + Type: github.String("blob"), + Mode: github.String("100644"), + }, + } + + if err := createCommit(config.client, &signedCommitOptions{ + repoName: file.RepositoryOwner, + repoOwner: file.RepositoryName, + branch: file.Branch, + commitMessage: "Updating CODEOWNERS file", + gpgPassphrase: config.gpgPassphrase, + gpgPrivateKey: config.gpgKey, + username: config.ghUsername, + email: config.ghEmail, + changes: entries, + }); err != nil { return err } @@ -105,11 +160,31 @@ func resourceFileUpdate(d *schema.ResourceData, m interface{}) error { } func resourceFileDelete(d *schema.ResourceData, m interface{}) error { - client := m.(*github.Client) + config := m.(*providerConfiguration) owner, name := d.Get("repository_owner").(string), d.Get("repository_name").(string) - return deleteRulesetForRepo(client, d.Get("branch").(string), owner, name, "Removing CODEOWNERS file") + ctx := context.Background() + + codeOwnerContent, _, rr, err := config.client.Repositories.GetContents(ctx, owner, name, codeownersPath, &github.RepositoryContentGetOptions{}) + if err != nil { + return fmt.Errorf("failed to retrieve file %s: %v", codeownersPath, err) + } + + if rr.StatusCode == http.StatusNotFound { // resource already removed + return nil + } + + options := &github.RepositoryContentFileOptions{ + Message: github.String("Removing CODEOWNERS file"), + SHA: codeOwnerContent.SHA, + } + if d.Get("branch").(string) != "" { + options.Branch = github.String(d.Get("branch").(string)) + } + + _, _, err = config.client.Repositories.DeleteFile(ctx, owner, name, codeownersPath, options) + return err } func flattenFile(file *File, d *schema.ResourceData) error { diff --git a/codeowners/util.go b/codeowners/util.go index 8d21d704..4cdd809d 100644 --- a/codeowners/util.go +++ b/codeowners/util.go @@ -1,12 +1,14 @@ package codeowners import ( + "bytes" "context" "fmt" - "net/http" "strings" + "time" "github.com/google/go-github/github" + "golang.org/x/crypto/openpgp" ) var codeownersPath = ".github/CODEOWNERS" @@ -58,7 +60,7 @@ func sameStringSlice(x, y []string) bool { if _, ok := diff[_y]; !ok { return false } - diff[_y] -= 1 + diff[_y]-- if diff[_y] == 0 { delete(diff, _y) } @@ -88,132 +90,167 @@ func (ruleset Ruleset) Compile() []byte { return []byte(output) } -func updateRulesetForRepo(client *github.Client, branch string, repoOwner string, repoName string, ruleset Ruleset, commitMessage string) error { - ctx := context.Background() - - getOptions := &github.RepositoryContentGetOptions{} - if branch != "" { - getOptions.Ref = branch - } - - codeOwnerContent, _, rr, err := client.Repositories.GetContents(ctx, repoOwner, repoName, codeownersPath, getOptions) - if err != nil || rr.StatusCode >= 400 { - return fmt.Errorf("failed to retrieve file %s: %v", codeownersPath, err) - } +func parseRulesFile(data string) Ruleset { - options := &github.RepositoryContentFileOptions{ - Content: ruleset.Compile(), - Message: &commitMessage, - SHA: codeOwnerContent.SHA, - } - if branch != "" { - options.Branch = &branch + rules := []Rule{} + lines := strings.Split(data, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + if trimmed[0] == '#' { // ignore comments + continue + } + words := strings.Split(trimmed, " ") + if len(words) < 2 { + continue + } + rule := Rule{ + Pattern: words[0], + } + for _, username := range words[1:] { + if len(username) == 0 { // may be split by multiple spaces + continue + } + if username[0] == '@' { + username = username[1:] + } + rule.Usernames = append(rule.Usernames, username) + } + rules = append(rules, rule) } - _, _, err = client.Repositories.UpdateFile(ctx, repoOwner, repoName, codeownersPath, options) + return rules - return err } -func createRulesetForRepo(client *github.Client, branch string, repoOwner string, repoName string, ruleset Ruleset, commitMessage string) error { - ctx := context.Background() - - options := &github.RepositoryContentFileOptions{ - Content: ruleset.Compile(), - Message: &commitMessage, - } - if branch != "" { - options.Branch = &branch - } - - _, _, err := client.Repositories.CreateFile(ctx, repoOwner, repoName, codeownersPath, options) - - return err +type signedCommitOptions struct { + repoOwner string + repoName string + commitMessage string + gpgPassphrase string + gpgPrivateKey string // detached armor format + changes []github.TreeEntry + branch string + username string + email string } -func deleteRulesetForRepo(client *github.Client, branch string, repoOwner string, repoName string, commitMessage string) error { +func createCommit(client *github.Client, options *signedCommitOptions) error { ctx := context.Background() - codeOwnerContent, _, rr, err := client.Repositories.GetContents(ctx, repoOwner, repoName, codeownersPath, &github.RepositoryContentGetOptions{}) + // get ref for selected branch + ref, _, err := client.Git.GetRef(ctx, options.repoOwner, options.repoName, "refs/heads/"+options.branch) if err != nil { - return fmt.Errorf("failed to retrieve file %s: %v", codeownersPath, err) + return err } - if rr.StatusCode == http.StatusNotFound { // resource already removed - return nil + // create tree containing required changes + tree, _, err := client.Git.CreateTree(ctx, options.repoOwner, options.repoName, *ref.Object.SHA, options.changes) + if err != nil { + return err } - options := &github.RepositoryContentFileOptions{ - Message: &commitMessage, - SHA: codeOwnerContent.SHA, - } - if branch != "" { - options.Branch = &branch + // get parent commit + parent, _, err := client.Repositories.GetCommit(ctx, options.repoOwner, options.repoName, *ref.Object.SHA) + if err != nil { + return err } - _, _, err = client.Repositories.DeleteFile(ctx, repoOwner, repoName, codeownersPath, options) + // This is not always populated, but is needed. + parent.Commit.SHA = github.String(parent.GetSHA()) - return err -} - -func readRulesetForRepo(client *github.Client, branch string, repoOwner string, repoName string) (Ruleset, error) { - - ctx := context.Background() - - getOptions := &github.RepositoryContentGetOptions{} - if branch != "" { - getOptions.Ref = branch + date := time.Now() + author := &github.CommitAuthor{ + Date: &date, + Name: github.String(options.username), + Email: github.String(options.email), } - codeOwnerContent, _, rr, err := client.Repositories.GetContents(ctx, repoOwner, repoName, codeownersPath, getOptions) - if err != nil || rr.StatusCode >= 500 { - return nil, fmt.Errorf("failed to retrieve file %s: %v", codeownersPath, err) - } + var verification *github.SignatureVerification + + if options.gpgPrivateKey != "" { + // the payload must be "an over the string commit as it would be written to the object database" + // we sign this data to verify the commit + payload := fmt.Sprintf( + `tree %s +parent %s +author %s <%s> %d +0000 +committer %s <%s> %d +0000 + +%s`, + tree.GetSHA(), + parent.GetSHA(), + author.GetName(), + author.GetEmail(), + date.Unix(), + author.GetName(), + author.GetEmail(), + date.Unix(), + options.commitMessage, + ) + + // sign the payload data + signature, err := signData(payload, options.gpgPrivateKey, options.gpgPassphrase) + if err != nil { + return err + } - if rr.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("file %s does not exist", codeownersPath) + verification = &github.SignatureVerification{ + Signature: signature, + } } - raw, err := codeOwnerContent.GetContent() + commit := &github.Commit{ + Author: author, + Message: &options.commitMessage, + Tree: tree, + Parents: []github.Commit{*parent.Commit}, + Verification: verification, + } + newCommit, _, err := client.Git.CreateCommit(ctx, options.repoOwner, options.repoName, commit) if err != nil { - return nil, fmt.Errorf("failed to retrieve content for %s: %s", codeownersPath, err) + return err } - return parseRulesFile(raw), nil - + // Attach the commit to the selected branch + ref.Object.SHA = newCommit.SHA + _, _, err = client.Git.UpdateRef(ctx, options.repoOwner, options.repoName, ref, false) + return err } -func parseRulesFile(data string) Ruleset { +func signData(data string, privateKey string, passphrase string) (*string, error) { - rules := []Rule{} - lines := strings.Split(data, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if len(trimmed) == 0 { - continue - } - if trimmed[0] == '#' { // ignore comments - continue - } - words := strings.Split(trimmed, " ") - if len(words) < 2 { - continue - } - rule := Rule{ - Pattern: words[0], + entitylist, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey)) + if err != nil { + return nil, err + } + pk := entitylist[0] + + ppb := []byte(passphrase) + + if pk.PrivateKey != nil && pk.PrivateKey.Encrypted { + err := pk.PrivateKey.Decrypt(ppb) + if err != nil { + return nil, err } - for _, username := range words[1:] { - if len(username) == 0 { // may be split by multiple spaces - continue - } - if username[0] == '@' { - username = username[1:] + } + + for _, subkey := range pk.Subkeys { + if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted { + err := subkey.PrivateKey.Decrypt(ppb) + if err != nil { + return nil, err } - rule.Usernames = append(rule.Usernames, username) } - rules = append(rules, rule) } - return rules - + out := new(bytes.Buffer) + reader := strings.NewReader(data) + if err := openpgp.ArmoredDetachSign(out, pk, reader, nil); err != nil { + return nil, err + } + signature := string(out.Bytes()) + return &signature, nil } diff --git a/vendor/github.com/google/go-github/github/git_commits.go b/vendor/github.com/google/go-github/github/git_commits.go index 7638acbd..9dfd3afb 100644 --- a/vendor/github.com/google/go-github/github/git_commits.go +++ b/vendor/github.com/google/go-github/github/git_commits.go @@ -84,6 +84,7 @@ type createCommit struct { Message *string `json:"message,omitempty"` Tree *string `json:"tree,omitempty"` Parents []string `json:"parents,omitempty"` + Signature *string `json:"signature,omitempty"` } // CreateCommit creates a new commit in a repository. @@ -115,6 +116,9 @@ func (s *GitService) CreateCommit(ctx context.Context, owner string, repo string if commit.Tree != nil { body.Tree = commit.Tree.SHA } + if commit.Verification != nil { + body.Signature = commit.Verification.Signature + } req, err := s.client.NewRequest("POST", u, body) if err != nil {