diff --git a/github/data_source_github_actions_public_key.go b/github/data_source_github_actions_public_key.go new file mode 100644 index 0000000000..e972934142 --- /dev/null +++ b/github/data_source_github_actions_public_key.go @@ -0,0 +1,53 @@ +package github + +import ( + "context" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "log" +) + +func dataSourceGithubActionsPublicKey() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubActionsPublicKeyRead, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + }, + "key_id": { + Type: schema.TypeString, + Optional: true, + }, + "key": { + Type: schema.TypeString, + Optional: true, + }, + }, + } +} + +func dataSourceGithubActionsPublicKeyRead(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + repository := d.Get("repository").(string) + owner := meta.(*Organization).name + log.Printf("[INFO] Refreshing GitHub Actions Public Key from: %s/%s", owner, repository) + + client := meta.(*Organization).client + ctx := context.Background() + + publicKey, _, err := client.Actions.GetPublicKey(ctx, owner, repository) + if err != nil { + return err + } + + d.SetId(publicKey.GetKeyID()) + d.Set("key_id", publicKey.GetKeyID()) + d.Set("key", publicKey.GetKey()) + + return nil +} diff --git a/github/data_source_github_actions_public_key_test.go b/github/data_source_github_actions_public_key_test.go new file mode 100644 index 0000000000..c2ef3a4bca --- /dev/null +++ b/github/data_source_github_actions_public_key_test.go @@ -0,0 +1,53 @@ +package github + +import ( + "fmt" + "os" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubActionsPublicKeyDataSource_noMatchReturnsError(t *testing.T) { + repo := "non-existent" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckGithubActionsPublicKeyDataSourceConfig(repo), + ExpectError: regexp.MustCompile(`Not Found`), + }, + }, + }) +} + +func TestAccCheckGithubActionsPublicKeyDataSource_existing(t *testing.T) { + repo := os.Getenv("GITHUB_TEMPLATE_REPOSITORY") + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckGithubActionsPublicKeyDataSourceConfig(repo), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_actions_public_key.test_pk", "key"), + resource.TestCheckResourceAttrSet("data.github_actions_public_key.test_pk", "key_id"), + ), + }, + }, + }) +} + +func testAccCheckGithubActionsPublicKeyDataSourceConfig(repo string) string { + return fmt.Sprintf(` +data "github_actions_public_key" "test_pk" { + repository = "%s" +} +`, repo) +} diff --git a/github/provider.go b/github/provider.go index d34b5b05dc..fcb692067f 100644 --- a/github/provider.go +++ b/github/provider.go @@ -46,6 +46,7 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ + "github_actions_secret": resourceGithubActionsSecret(), "github_branch_protection": resourceGithubBranchProtection(), "github_issue_label": resourceGithubIssueLabel(), "github_membership": resourceGithubMembership(), @@ -68,13 +69,14 @@ func Provider() terraform.ResourceProvider { }, DataSourcesMap: map[string]*schema.Resource{ - "github_collaborators": dataSourceGithubCollaborators(), - "github_ip_ranges": dataSourceGithubIpRanges(), - "github_release": dataSourceGithubRelease(), - "github_repositories": dataSourceGithubRepositories(), - "github_repository": dataSourceGithubRepository(), - "github_team": dataSourceGithubTeam(), - "github_user": dataSourceGithubUser(), + "github_collaborators": dataSourceGithubCollaborators(), + "github_ip_ranges": dataSourceGithubIpRanges(), + "github_release": dataSourceGithubRelease(), + "github_repositories": dataSourceGithubRepositories(), + "github_repository": dataSourceGithubRepository(), + "github_team": dataSourceGithubTeam(), + "github_user": dataSourceGithubUser(), + "github_actions_public_key": dataSourceGithubActionsPublicKey(), }, } diff --git a/github/resource_github_actions_secret.go b/github/resource_github_actions_secret.go new file mode 100644 index 0000000000..94dcee3786 --- /dev/null +++ b/github/resource_github_actions_secret.go @@ -0,0 +1,169 @@ +package github + +import ( + "context" + "encoding/base64" + "fmt" + "github.com/google/go-github/v29/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "golang.org/x/crypto/nacl/box" + "log" +) + +func resourceGithubActionsSecret() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubActionsSecretCreateOrUpdate, + Read: resourceGithubActionsSecretRead, + Update: resourceGithubActionsSecretCreateOrUpdate, + Delete: resourceGithubActionsSecretDelete, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + }, + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "plaintext_value": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubActionsSecretCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Organization).client + owner := meta.(*Organization).name + ctx := context.Background() + + repo := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + plaintextValue := d.Get("plaintext_value").(string) + + keyId, publicKey, err := getPublicKeyDetails(owner, repo, meta) + if err != nil { + return err + } + + encryptedText, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return err + } + + // Create an EncryptedSecret and encrypt the plaintext value into it + eSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: keyId, + EncryptedValue: base64.StdEncoding.EncodeToString(encryptedText), + } + + _, err = client.Actions.CreateOrUpdateSecret(ctx, owner, repo, eSecret) + if err != nil { + return err + } + + d.SetId(buildTwoPartID(&repo, &secretName)) + return resourceGithubActionsSecretRead(d, meta) +} + +func resourceGithubActionsSecretRead(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Organization).client + owner := meta.(*Organization).name + ctx := context.Background() + + repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + if err != nil { + return err + } + + secret, _, err := client.Actions.GetSecret(ctx, owner, repoName, secretName) + if err != nil { + d.SetId("") + return err + } + + d.Set("plaintext_value", d.Get("plaintext_value")) + d.Set("updated_at", secret.UpdatedAt.Format("default")) + d.Set("created_at", secret.CreatedAt.Format("default")) + + return nil +} + +func resourceGithubActionsSecretDelete(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Organization).client + orgName := meta.(*Organization).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + if err != nil { + return err + } + + log.Printf("[DEBUG] Deleting secret: %s", d.Id()) + _, err = client.Actions.DeleteSecret(ctx, orgName, repoName, secretName) + + return err +} + +func getPublicKeyDetails(owner, repository string, meta interface{}) (keyId, pkValue string, err error) { + client := meta.(*Organization).client + ctx := context.Background() + + publicKey, _, err := client.Actions.GetPublicKey(ctx, owner, repository) + if err != nil { + return keyId, pkValue, err + } + + return publicKey.GetKeyID(), publicKey.GetKey(), err +} + +func encryptPlaintext(plaintext, publicKeyB64 string) ([]byte, error) { + publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return nil, err + } + + var publicKeyBytes32 [32]byte + copiedLen := copy(publicKeyBytes32[:], publicKeyBytes) + if copiedLen == 0 { + return nil, fmt.Errorf("could not convert publicKey to bytes") + } + + plaintextBytes := []byte(plaintext) + var encryptedBytes []byte + + cipherText, err := box.SealAnonymous(encryptedBytes, plaintextBytes, &publicKeyBytes32, nil) + if err != nil { + return nil, err + } + + return cipherText, nil +} diff --git a/github/resource_github_actions_secret_test.go b/github/resource_github_actions_secret_test.go new file mode 100644 index 0000000000..00c1c79271 --- /dev/null +++ b/github/resource_github_actions_secret_test.go @@ -0,0 +1,112 @@ +package github + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" +) + +func TestAccGithubActionsSecret_basic(t *testing.T) { + repo := os.Getenv("GITHUB_TEMPLATE_REPOSITORY") + + secretResourceName := "github_actions_secret.test_secret" + secretValue := "super_secret_value" + updatedSecretValue := "updated_super_secret_value" + t.Log(testAccGithubActionsSecretFullConfig(repo, secretValue)) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGithubActionsSecretDestroy, + Steps: []resource.TestStep{ + { + Config: testAccGithubActionsSecretFullConfig(repo, secretValue), + Check: resource.ComposeTestCheckFunc( + testAccCheckGithubActionsSecretExists(secretResourceName, "test_secret_name", t), + resource.TestCheckResourceAttr("github_actions_secret.test_secret", "plaintext_value", secretValue), + resource.TestCheckResourceAttrSet("github_actions_secret.test_secret", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test_secret", "updated_at"), + ), + }, + { + Config: testAccGithubActionsSecretFullConfig(repo, updatedSecretValue), + Check: resource.ComposeTestCheckFunc( + testAccCheckGithubActionsSecretExists(secretResourceName, "test_secret_name", t), + resource.TestCheckResourceAttr("github_actions_secret.test_secret", "plaintext_value", updatedSecretValue), + resource.TestCheckResourceAttrSet("github_actions_secret.test_secret", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test_secret", "updated_at"), + ), + }, + }, + }) +} + +func testAccGithubActionsSecretFullConfig(repoName, plaintext string) string { + + // Take resources from other tests to avoid manual creation of secrets / repos + githubPKData := testAccCheckGithubActionsPublicKeyDataSourceConfig(repoName) + githubActionsSecretResource := testAccGithubActionsSecretConfig(repoName, plaintext) + + return fmt.Sprintf("%s%s", githubPKData, githubActionsSecretResource) +} + +func testAccGithubActionsSecretConfig(repo, plaintext string) string { + return fmt.Sprintf(` +resource "github_actions_secret" "test_secret" { + repository = "%s" + secret_name = "test_secret_name" + plaintext_value = "%s" +} +`, repo, plaintext) +} + +func testAccCheckGithubActionsSecretExists(resourceName, secretName string, t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + actualResource, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not Found: %s", resourceName) + } + + repoName := actualResource.Primary.Attributes["repository"] + if repoName == "" { + return fmt.Errorf("no repo name is set") + } + + org := testAccProvider.Meta().(*Organization) + conn := org.client + _, _, err := conn.Actions.GetSecret(context.TODO(), org.name, repoName, secretName) + if err != nil { + t.Log("Failed to get secret") + return err + } + + return nil + } +} + +func testAccCheckGithubActionsSecretDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*Organization).client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_actions_secret" { + continue + } + owner := testAccProvider.Meta().(*Organization).name + repoName := rs.Primary.Attributes["repository"] + secretName := rs.Primary.Attributes["secret_name"] + + gotSecret, resp, err := client.Actions.GetSecret(context.TODO(), owner, repoName, secretName) + if err == nil && gotSecret != nil && gotSecret.Name == secretName { + return fmt.Errorf("secret %s still exists", rs.Primary.ID) + } + if resp != nil && resp.StatusCode != 404 { + return err + } + return nil + } + return nil +} diff --git a/go.mod b/go.mod index 10eada1c69..4f8d691ce0 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/terraform-providers/terraform-provider-github +go 1.13 + require ( - github.com/google/go-github/v29 v29.0.2 + github.com/google/go-github/v29 v29.0.3 github.com/hashicorp/terraform-plugin-sdk v1.7.0 github.com/kylelemons/godebug v1.1.0 + golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d ) - -go 1.13 diff --git a/go.sum b/go.sum index 5465f7d564..ad5036228d 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= -github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= +github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc= +github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -193,6 +193,8 @@ golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= diff --git a/vendor/modules.txt b/vendor/modules.txt index c7f697a417..85ff7862e1 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -73,7 +73,7 @@ github.com/google/go-cmp/cmp/internal/diff github.com/google/go-cmp/cmp/internal/flags github.com/google/go-cmp/cmp/internal/function github.com/google/go-cmp/cmp/internal/value -# github.com/google/go-github/v29 v29.0.2 +# github.com/google/go-github/v29 v29.0.3 github.com/google/go-github/v29/github # github.com/google/go-querystring v1.0.0 github.com/google/go-querystring/query diff --git a/website/docs/d/actions_public_key.html.markdown b/website/docs/d/actions_public_key.html.markdown new file mode 100644 index 0000000000..230f804285 --- /dev/null +++ b/website/docs/d/actions_public_key.html.markdown @@ -0,0 +1,31 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_public_key" +description: |- + Get information on a GitHub Actions Public Key. +--- + +# github_actions_public_key + +Use this data source to retrieve information about a GitHub Actions public key. This data source is required to be used with other GitHub secrets interactions. +Note that the provider `token` must have admin rights to a repository to retrieve it's action public key. + +## Example Usage + +```hcl +data "github_secrets_public_key" "example" { + owner = "example_owner" + repository = "example_repo" +} +``` + +## Argument Reference + + * `owner` - (Required) Owner of the repository. + * `repository` - (Required) Name of the repository to get public key from. + +## Attributes Reference + + * `key_id` - ID of the key that has been retrieved. + * `key` - Actual key retrieved. + diff --git a/website/docs/r/actions_secret.html.markdown b/website/docs/r/actions_secret.html.markdown new file mode 100644 index 0000000000..6487cd8cc0 --- /dev/null +++ b/website/docs/r/actions_secret.html.markdown @@ -0,0 +1,47 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_secret" +description: |- + Creates and manages an Action Secret within a GitHub repository +--- + +# github_actions_secret + +This resource allows you to create and manage GitHub Actions secrets within your GitHub repositories. +You must have write access to a repository to use this resource. + +Secret values are encrypted using the [Go '/crypto/box' module](https://godoc.org/golang.org/x/crypto/nacl/box) which is +interoperable with [libsodium](https://libsodium.gitbook.io/doc/). Libsodium is used by Github to decrypt secret values. + +For the purposes of security, the contents of the `plaintext_value` field have been marked as `sensitive` to Terraform, +but it is important to note that **this does not hide it from state files**. You should treat state as sensitive always. +It is also advised that you do not store plaintext values in your code but rather populate the `plaintext_value` +using fields from a resource, data source or variable as, while encrypted in state, these will be easily accessible +in your code. See below for an example of this abstraction. + +## Example Usage + +```hcl +data "github_actions_public_key" "example_public_key" { + owner = "example_owner" + repository = "example_repository" +} + +resource "github_actions_secret" "example_secret" { + repository = "example_repository" + secret_name = "example_secret_name" + plaintext_value = var.some_secret_string + key_id = github_actions_public_key.example_public_key.key_id + public_key = github_actions_public_key.example_public_key.key +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) Name of the repository +* `secret_name` - (Required) Name of the secret +* `plaintext_value` - (Required) Plaintext value of the secret to be encrypted +* `key_id` - (Required) ID if the key used for encryption +* `public_key` - (Required) Public key of the repository to be used in encryption of the `plaintext_value` diff --git a/website/github.erb b/website/github.erb index d76bb2b6b1..fc0c76d37f 100644 --- a/website/github.erb +++ b/website/github.erb @@ -13,6 +13,9 @@