From d72067985531677668e8730a75006d7709d69bb2 Mon Sep 17 00:00:00 2001 From: KenSpur Date: Fri, 16 Jun 2023 14:32:32 +0200 Subject: [PATCH] add github_codespaces_organization_secret --- ..._github_codespaces_organization_secrets.go | 78 ++++++ ...ub_codespaces_organization_secrets_test.go | 59 +++++ github/provider.go | 2 + ...e_github_codespaces_organization_secret.go | 235 ++++++++++++++++++ ...hub_codespaces_organization_secret_test.go | 186 ++++++++++++++ 5 files changed, 560 insertions(+) create mode 100644 github/data_source_github_codespaces_organization_secrets.go create mode 100644 github/data_source_github_codespaces_organization_secrets_test.go create mode 100644 github/resource_github_codespaces_organization_secret.go create mode 100644 github/resource_github_codespaces_organization_secret_test.go diff --git a/github/data_source_github_codespaces_organization_secrets.go b/github/data_source_github_codespaces_organization_secrets.go new file mode 100644 index 0000000000..e6bc1121c7 --- /dev/null +++ b/github/data_source_github_codespaces_organization_secrets.go @@ -0,0 +1,78 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v53/github" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubDependabotOrganizationSecrets() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubDependabotOrganizationSecretsRead, + + Schema: map[string]*schema.Schema{ + "secrets": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "visibility": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubDependabotOrganizationSecretsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + options := github.ListOptions{ + PerPage: 100, + } + + var all_secrets []map[string]string + for { + secrets, resp, err := client.Dependabot.ListOrgSecrets(context.TODO(), owner, &options) + if err != nil { + return err + } + for _, secret := range secrets.Secrets { + new_secret := map[string]string{ + "name": secret.Name, + "visibility": secret.Visibility, + "created_at": secret.CreatedAt.String(), + "updated_at": secret.UpdatedAt.String(), + } + all_secrets = append(all_secrets, new_secret) + + } + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + d.SetId(owner) + d.Set("secrets", all_secrets) + + return nil +} diff --git a/github/data_source_github_codespaces_organization_secrets_test.go b/github/data_source_github_codespaces_organization_secrets_test.go new file mode 100644 index 0000000000..2e25e57ab8 --- /dev/null +++ b/github/data_source_github_codespaces_organization_secrets_test.go @@ -0,0 +1,59 @@ +package github + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesOrganizationSecretsDataSource(t *testing.T) { + + t.Run("queries organization codespaces secrets from a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "test" { + secret_name = "org_dep_secret_1_%s" + plaintext_value = "foo" + visibility = "private" + } + `, randomID) + + config2 := config + ` + data "github_codespaces_organization_secrets" "test" { + } + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.#", "1"), + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.0.name", strings.ToUpper(fmt.Sprintf("ORG_DEP_SECRET_1_%s", randomID))), + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.0.visibility", "private"), + resource.TestCheckResourceAttrSet("data.github_codespaces_organization_secrets.test", "secrets.0.created_at"), + resource.TestCheckResourceAttrSet("data.github_codespaces_organization_secrets.test", "secrets.0.updated_at"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/provider.go b/github/provider.go index ab1e906491..923870b341 100644 --- a/github/provider.go +++ b/github/provider.go @@ -114,6 +114,7 @@ func Provider() terraform.ResourceProvider { "github_branch_default": resourceGithubBranchDefault(), "github_branch_protection": resourceGithubBranchProtection(), "github_branch_protection_v3": resourceGithubBranchProtectionV3(), + "github_codespaces_organization_secret": resourceGithubCodespacesOrganizationSecret(), "github_codespaces_secret": resourceGithubCodespacesSecret(), "github_dependabot_organization_secret": resourceGithubDependabotOrganizationSecret(), "github_dependabot_organization_secret_repositories": resourceGithubDependabotOrganizationSecretRepositories(), @@ -172,6 +173,7 @@ func Provider() terraform.ResourceProvider { "github_branch": dataSourceGithubBranch(), "github_branch_protection_rules": dataSourceGithubBranchProtectionRules(), "github_collaborators": dataSourceGithubCollaborators(), + "github_codespaces_organization_secrets": dataSourceGithubCodespacesOrganizationSecrets(), "github_codespaces_secrets": dataSourceGithubCodespacesSecrets(), "github_dependabot_organization_public_key": dataSourceGithubDependabotOrganizationPublicKey(), "github_dependabot_organization_secrets": dataSourceGithubDependabotOrganizationSecrets(), diff --git a/github/resource_github_codespaces_organization_secret.go b/github/resource_github_codespaces_organization_secret.go new file mode 100644 index 0000000000..e02e020137 --- /dev/null +++ b/github/resource_github_codespaces_organization_secret.go @@ -0,0 +1,235 @@ +package github + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubCodespacesOrganizationSecret() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubCodespacesOrganizationSecretCreateOrUpdate, + Read: resourceGithubCodespacesOrganizationSecretRead, + Update: resourceGithubCodespacesOrganizationSecretCreateOrUpdate, + Delete: resourceGithubCodespacesOrganizationSecretDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.Set("secret_name", d.Id()) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateFunc: validateSecretNameFunc, + }, + "encrypted_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"plaintext_value"}, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + ValidateFunc: validation.StringIsBase64, + }, + "plaintext_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + Description: "Plaintext value of the secret to be encrypted.", + ConflictsWith: []string{"encrypted_value"}, + }, + "visibility": { + Type: schema.TypeString, + Required: true, + Description: "Configures the access that repositories have to the organization secret. Must be one of 'all', 'private' or 'selected'. 'selected_repository_ids' is required if set to 'selected'.", + ValidateFunc: validateValueFunc([]string{"all", "private", "selected"}), + ForceNew: true, + }, + "selected_repository_ids": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Set: schema.HashInt, + Optional: true, + Description: "An array of repository ids that can access the organization secret.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' update.", + }, + }, + } +} + +func resourceGithubCodespacesOrganizationSecretCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + secretName := d.Get("secret_name").(string) + plaintextValue := d.Get("plaintext_value").(string) + var encryptedValue string + + visibility := d.Get("visibility").(string) + selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + + if visibility != "selected" && hasSelectedRepositories { + return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") + } + + selectedRepositoryIDs := github.SelectedRepoIDs{} + + if hasSelectedRepositories { + ids := selectedRepositories.(*schema.Set).List() + + for _, id := range ids { + selectedRepositoryIDs = append(selectedRepositoryIDs, id.(int64)) + } + } + + keyId, publicKey, err := getCodespacesOrganizationPublicKeyDetails(owner, meta) + if err != nil { + return err + } + + if encryptedText, ok := d.GetOk("encrypted_value"); ok { + encryptedValue = encryptedText.(string) + } else { + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return err + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + // Create an EncryptedSecret and encrypt the plaintext value into it + eSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: keyId, + Visibility: visibility, + SelectedRepositoryIDs: selectedRepositoryIDs, + EncryptedValue: encryptedValue, + } + + _, err = client.Codespaces.CreateOrUpdateOrgSecret(ctx, owner, eSecret) + if err != nil { + return err + } + + d.SetId(secretName) + return resourceGithubCodespacesOrganizationSecretRead(d, meta) +} + +func resourceGithubCodespacesOrganizationSecretRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + secret, _, err := client.Codespaces.GetOrgSecret(ctx, owner, d.Id()) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", + d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("encrypted_value", d.Get("encrypted_value")) + d.Set("plaintext_value", d.Get("plaintext_value")) + d.Set("created_at", secret.CreatedAt.String()) + d.Set("visibility", secret.Visibility) + + selectedRepositoryIDs := []int64{} + + if secret.Visibility == "selected" { + opt := &github.ListOptions{ + PerPage: 30, + } + for { + results, resp, err := client.Codespaces.ListSelectedReposForOrgSecret(ctx, owner, d.Id(), opt) + if err != nil { + return err + } + + for _, repo := range results.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + d.Set("selected_repository_ids", selectedRepositoryIDs) + + // This is a drift detection mechanism based on timestamps. + // + // If we do not currently store the "updated_at" field, it means we've only + // just created the resource and the value is most likely what we want it to + // be. + // + // If the resource is changed externally in the meantime then reading back + // the last update timestamp will return a result different than the + // timestamp we've persisted in the state. In that case, we can no longer + // trust that the value (which we don't see) is equal to what we've declared + // previously. + // + // The only solution to enforce consistency between is to mark the resource + // as deleted (unset the ID) in order to fix potential drift by recreating + // the resource. + if updatedAt, ok := d.GetOk("updated_at"); ok && updatedAt != secret.UpdatedAt.String() { + log.Printf("[WARN] The secret %s has been externally updated in GitHub", d.Id()) + d.SetId("") + } else if !ok { + d.Set("updated_at", secret.UpdatedAt.String()) + } + + return nil +} + +func resourceGithubCodespacesOrganizationSecretDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting secret: %s", d.Id()) + _, err := client.Codespaces.DeleteOrgSecret(ctx, orgName, d.Id()) + return err +} + +func getCodespacesOrganizationPublicKeyDetails(owner string, meta interface{}) (keyId, pkValue string, err error) { + client := meta.(*Owner).v3client + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetOrgPublicKey(ctx, owner) + if err != nil { + return keyId, pkValue, err + } + + return publicKey.GetKeyID(), publicKey.GetKey(), err +} diff --git a/github/resource_github_codespaces_organization_secret_test.go b/github/resource_github_codespaces_organization_secret_test.go new file mode 100644 index 0000000000..c3adbe80c4 --- /dev/null +++ b/github/resource_github_codespaces_organization_secret_test.go @@ -0,0 +1,186 @@ +package github + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesOrganizationSecret(t *testing.T) { + t.Run("creates and updates secrets without error", func(t *testing.T) { + secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + visibility = "private" + } + + resource "github_codespaces_organization_secret" "encrypted_secret" { + secret_name = "test_encrypted_secret" + encrypted_value = "%s" + visibility = "private" + } + `, secretValue, secretValue) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.plaintext_secret", "plaintext_value", + secretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.encrypted_secret", "encrypted_value", + secretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "updated_at", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.plaintext_secret", "plaintext_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.encrypted_secret", "encrypted_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "updated_at", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: strings.Replace(config, + secretValue, + updatedSecretValue, 2), + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes secrets without error", func(t *testing.T) { + config := ` + resource "github_codespaces_organization_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + visibility = "private" + } + + resource "github_codespaces_organization_secret" "encrypted_secret" { + secret_name = "test_encrypted_secret" + visibility = "private" + } + ` + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Destroy: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports secrets without error", func(t *testing.T) { + secretValue := "super_secret_value" + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "test_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + visibility = "private" + } + `, secretValue) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.test_secret", "plaintext_value", + secretValue, + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_codespaces_organization_secret.test_secret", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"plaintext_value"}, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +}