From ddf932a3001ec6a7c5e02a3cb557322eba0e778f Mon Sep 17 00:00:00 2001 From: Jake Uren Date: Wed, 15 Jul 2026 14:43:00 +1000 Subject: [PATCH 1/4] Add github_code_security_configuration resource Adds org- and enterprise-level management of GitHub Code Security Configurations, including /defaults and /attach sub-endpoints. --- github/provider.go | 1 + ...urce_github_code_security_configuration.go | 446 ++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 github/resource_github_code_security_configuration.go diff --git a/github/provider.go b/github/provider.go index d39dfd55ef..cb181334c7 100644 --- a/github/provider.go +++ b/github/provider.go @@ -183,6 +183,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_branch_default": resourceGithubBranchDefault(), "github_branch_protection": resourceGithubBranchProtection(), "github_branch_protection_v3": resourceGithubBranchProtectionV3(), + "github_code_security_configuration": resourceGithubCodeSecurityConfiguration(), "github_codespaces_organization_secret": resourceGithubCodespacesOrganizationSecret(), "github_codespaces_organization_secret_repositories": resourceGithubCodespacesOrganizationSecretRepositories(), "github_codespaces_secret": resourceGithubCodespacesSecret(), diff --git a/github/resource_github_code_security_configuration.go b/github/resource_github_code_security_configuration.go new file mode 100644 index 0000000000..dcfc0c6b45 --- /dev/null +++ b/github/resource_github_code_security_configuration.go @@ -0,0 +1,446 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubCodeSecurityConfiguration() *schema.Resource { + featureValueDiag := validation.ToDiagFunc(validation.StringInSlice([]string{"enabled", "disabled", "not_set"}, false)) + + return &schema.Resource{ + Description: "Manages a GitHub Code Security Configuration at the organization or enterprise level.", + Create: resourceGithubCodeSecurityConfigurationCreate, + Read: resourceGithubCodeSecurityConfigurationRead, + Update: resourceGithubCodeSecurityConfigurationUpdate, + Delete: resourceGithubCodeSecurityConfigurationDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubCodeSecurityConfigurationImport, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the code security configuration. Must be unique within the organization or enterprise.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Default: "", + Description: "A description of the code security configuration.", + }, + "enterprise_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The slug of the enterprise to create the configuration in. If omitted, the configuration is created at the organization level using the provider's configured owner.", + }, + "advanced_security": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of GitHub Advanced Security. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "dependency_graph": { + Type: schema.TypeString, + Optional: true, + Default: "enabled", + Description: "The enablement status of Dependency Graph. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "dependabot_alerts": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of Dependabot alerts. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "dependabot_security_updates": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of Dependabot security updates. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "code_scanning_default_setup": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of code scanning default setup. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "secret_scanning": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of secret scanning. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "secret_scanning_push_protection": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of secret scanning push protection. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "secret_scanning_validity_checks": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of secret scanning validity checks. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "secret_scanning_non_provider_patterns": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of secret scanning non-provider patterns. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "private_vulnerability_reporting": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "The enablement status of private vulnerability reporting. Can be 'enabled', 'disabled' or 'not_set'.", + ValidateDiagFunc: featureValueDiag, + }, + "enforcement": { + Type: schema.TypeString, + Optional: true, + Default: "enforced", + Description: "The enforcement status of the configuration. Can be 'enforced' or 'unenforced'.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"enforced", "unenforced"}, false)), + }, + "default_for_new_repos": { + Type: schema.TypeString, + Optional: true, + Description: "Which types of new repositories this configuration should be applied to by default. Can be 'all', 'none', 'private_and_internal' or 'public'. If omitted, the configuration is not set as a default.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "none", "private_and_internal", "public"}, false)), + }, + "attach_scope": { + Type: schema.TypeString, + Optional: true, + Description: "The scope of repositories to attach the configuration to. Can be 'all' or 'all_without_configurations'. Attachment is applied on create and whenever this value changes; it cannot be read back from the API, so removing this attribute does not detach repositories.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "all_without_configurations"}, false)), + }, + "configuration_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The numeric ID of the code security configuration.", + }, + "target_type": { + Type: schema.TypeString, + Computed: true, + Description: "The target type of the configuration ('organization' or 'enterprise').", + }, + "html_url": { + Type: schema.TypeString, + Computed: true, + Description: "The URL of the configuration in the GitHub UI.", + }, + }, + } +} + +func buildCodeSecurityConfiguration(d *schema.ResourceData) github.CodeSecurityConfiguration { + return github.CodeSecurityConfiguration{ + Name: d.Get("name").(string), + Description: d.Get("description").(string), + AdvancedSecurity: new(d.Get("advanced_security").(string)), + DependencyGraph: new(d.Get("dependency_graph").(string)), + DependabotAlerts: new(d.Get("dependabot_alerts").(string)), + DependabotSecurityUpdates: new(d.Get("dependabot_security_updates").(string)), + CodeScanningDefaultSetup: new(d.Get("code_scanning_default_setup").(string)), + SecretScanning: new(d.Get("secret_scanning").(string)), + SecretScanningPushProtection: new(d.Get("secret_scanning_push_protection").(string)), + SecretScanningValidityChecks: new(d.Get("secret_scanning_validity_checks").(string)), + SecretScanningNonProviderPatterns: new(d.Get("secret_scanning_non_provider_patterns").(string)), + PrivateVulnerabilityReporting: new(d.Get("private_vulnerability_reporting").(string)), + Enforcement: new(d.Get("enforcement").(string)), + } +} + +func resourceGithubCodeSecurityConfigurationCreate(d *schema.ResourceData, meta any) error { + owner := meta.(*Owner) + client := owner.v3client + ctx := context.Background() + + enterpriseSlug := d.Get("enterprise_slug").(string) + body := buildCodeSecurityConfiguration(d) + + var config *github.CodeSecurityConfiguration + var err error + if enterpriseSlug != "" { + log.Printf("[DEBUG] Creating code security configuration %q for enterprise: %s", body.Name, enterpriseSlug) + config, _, err = client.Enterprise.CreateCodeSecurityConfiguration(ctx, enterpriseSlug, body) + } else { + if err := checkOrganization(meta); err != nil { + return err + } + log.Printf("[DEBUG] Creating code security configuration %q for organization: %s", body.Name, owner.name) + config, _, err = client.Organizations.CreateCodeSecurityConfiguration(ctx, owner.name, body) + } + if err != nil { + return err + } + + d.SetId(strconv.FormatInt(config.GetID(), 10)) + + if v, ok := d.GetOk("default_for_new_repos"); ok { + if err := setCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, config.GetID(), v.(string)); err != nil { + return err + } + } + + if v, ok := d.GetOk("attach_scope"); ok { + if err := attachCodeSecurityConfiguration(ctx, client, owner.name, enterpriseSlug, config.GetID(), v.(string)); err != nil { + return err + } + } + + return resourceGithubCodeSecurityConfigurationRead(d, meta) +} + +func resourceGithubCodeSecurityConfigurationRead(d *schema.ResourceData, meta any) error { + owner := meta.(*Owner) + client := owner.v3client + ctx := context.Background() + + configID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + enterpriseSlug := d.Get("enterprise_slug").(string) + + var config *github.CodeSecurityConfiguration + if enterpriseSlug != "" { + config, _, err = client.Enterprise.GetCodeSecurityConfiguration(ctx, enterpriseSlug, configID) + } else { + config, _, err = client.Organizations.GetCodeSecurityConfiguration(ctx, owner.name, configID) + } + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing code security configuration %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + return err + } + + if err := d.Set("name", config.Name); err != nil { + return err + } + if err := d.Set("description", config.Description); err != nil { + return err + } + if err := d.Set("advanced_security", config.GetAdvancedSecurity()); err != nil { + return err + } + if err := d.Set("dependency_graph", config.GetDependencyGraph()); err != nil { + return err + } + if err := d.Set("dependabot_alerts", config.GetDependabotAlerts()); err != nil { + return err + } + if err := d.Set("dependabot_security_updates", config.GetDependabotSecurityUpdates()); err != nil { + return err + } + if err := d.Set("code_scanning_default_setup", config.GetCodeScanningDefaultSetup()); err != nil { + return err + } + if err := d.Set("secret_scanning", config.GetSecretScanning()); err != nil { + return err + } + if err := d.Set("secret_scanning_push_protection", config.GetSecretScanningPushProtection()); err != nil { + return err + } + if err := d.Set("secret_scanning_validity_checks", config.GetSecretScanningValidityChecks()); err != nil { + return err + } + if err := d.Set("secret_scanning_non_provider_patterns", config.GetSecretScanningNonProviderPatterns()); err != nil { + return err + } + if err := d.Set("private_vulnerability_reporting", config.GetPrivateVulnerabilityReporting()); err != nil { + return err + } + if err := d.Set("enforcement", config.GetEnforcement()); err != nil { + return err + } + if err := d.Set("configuration_id", config.GetID()); err != nil { + return err + } + if err := d.Set("target_type", config.GetTargetType()); err != nil { + return err + } + if err := d.Set("html_url", config.GetHTMLURL()); err != nil { + return err + } + + // default_for_new_repos is only surfaced via the /defaults listing. + defaultForNewRepos, err := readCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, configID) + if err != nil { + return err + } + // A configuration that is not a default (or is explicitly "none") does not + // appear in the defaults listing; preserve an explicit "none" in state. + if defaultForNewRepos == "" && d.Get("default_for_new_repos").(string) == "none" { + defaultForNewRepos = "none" + } + if err := d.Set("default_for_new_repos", defaultForNewRepos); err != nil { + return err + } + + // attach_scope is write-only: the API does not expose the scope a + // configuration was attached with, so it is intentionally not read back. + + return nil +} + +func resourceGithubCodeSecurityConfigurationUpdate(d *schema.ResourceData, meta any) error { + owner := meta.(*Owner) + client := owner.v3client + ctx := context.Background() + + configID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + enterpriseSlug := d.Get("enterprise_slug").(string) + body := buildCodeSecurityConfiguration(d) + + if enterpriseSlug != "" { + log.Printf("[DEBUG] Updating code security configuration %d for enterprise: %s", configID, enterpriseSlug) + _, _, err = client.Enterprise.UpdateCodeSecurityConfiguration(ctx, enterpriseSlug, configID, body) + } else { + log.Printf("[DEBUG] Updating code security configuration %d for organization: %s", configID, owner.name) + _, _, err = client.Organizations.UpdateCodeSecurityConfiguration(ctx, owner.name, configID, body) + } + if err != nil { + return err + } + + if d.HasChange("default_for_new_repos") { + newReposParam := d.Get("default_for_new_repos").(string) + if newReposParam == "" { + // Removing the attribute reverts the default to "none". + newReposParam = "none" + } + if err := setCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, configID, newReposParam); err != nil { + return err + } + } + + if d.HasChange("attach_scope") { + if v, ok := d.GetOk("attach_scope"); ok { + if err := attachCodeSecurityConfiguration(ctx, client, owner.name, enterpriseSlug, configID, v.(string)); err != nil { + return err + } + } + } + + return resourceGithubCodeSecurityConfigurationRead(d, meta) +} + +func resourceGithubCodeSecurityConfigurationDelete(d *schema.ResourceData, meta any) error { + owner := meta.(*Owner) + client := owner.v3client + ctx := context.Background() + + configID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + enterpriseSlug := d.Get("enterprise_slug").(string) + + if enterpriseSlug != "" { + log.Printf("[DEBUG] Deleting code security configuration %d for enterprise: %s", configID, enterpriseSlug) + _, err = client.Enterprise.DeleteCodeSecurityConfiguration(ctx, enterpriseSlug, configID) + } else { + log.Printf("[DEBUG] Deleting code security configuration %d for organization: %s", configID, owner.name) + _, err = client.Organizations.DeleteCodeSecurityConfiguration(ctx, owner.name, configID) + } + return err +} + +// resourceGithubCodeSecurityConfigurationImport supports two import ID formats: +// - "" for organization-level configurations +// - ":" for enterprise-level configurations +func resourceGithubCodeSecurityConfigurationImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), ":") + switch len(parts) { + case 1: + // organization-level: ID is the configuration ID as-is + case 2: + if err := d.Set("enterprise_slug", parts[0]); err != nil { + return nil, err + } + d.SetId(parts[1]) + default: + return nil, fmt.Errorf("invalid import ID %q: expected '' or ':'", d.Id()) + } + + if _, err := strconv.ParseInt(d.Id(), 10, 64); err != nil { + return nil, fmt.Errorf("invalid configuration ID %q: %w", d.Id(), err) + } + + return []*schema.ResourceData{d}, nil +} + +func setCodeSecurityConfigurationDefault(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64, newReposParam string) error { + var err error + if enterpriseSlug != "" { + log.Printf("[DEBUG] Setting code security configuration %d as default (%s) for enterprise: %s", configID, newReposParam, enterpriseSlug) + _, _, err = client.Enterprise.SetDefaultCodeSecurityConfiguration(ctx, enterpriseSlug, configID, newReposParam) + } else { + log.Printf("[DEBUG] Setting code security configuration %d as default (%s) for organization: %s", configID, newReposParam, org) + _, _, err = client.Organizations.SetDefaultCodeSecurityConfiguration(ctx, org, configID, newReposParam) + } + return err +} + +func attachCodeSecurityConfiguration(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64, scope string) error { + var err error + if enterpriseSlug != "" { + log.Printf("[DEBUG] Attaching code security configuration %d with scope %s for enterprise: %s", configID, scope, enterpriseSlug) + _, err = client.Enterprise.AttachCodeSecurityConfigurationToRepositories(ctx, enterpriseSlug, configID, scope) + } else { + log.Printf("[DEBUG] Attaching code security configuration %d with scope %s for organization: %s", configID, scope, org) + _, err = client.Organizations.AttachCodeSecurityConfigurationToRepositories(ctx, org, configID, scope, nil) + } + return err +} + +func readCodeSecurityConfigurationDefault(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64) (string, error) { + var defaults []*github.CodeSecurityConfigurationWithDefaultForNewRepos + var err error + if enterpriseSlug != "" { + defaults, _, err = client.Enterprise.ListDefaultCodeSecurityConfigurations(ctx, enterpriseSlug) + } else { + defaults, _, err = client.Organizations.ListDefaultCodeSecurityConfigurations(ctx, org) + } + if err != nil { + return "", err + } + for _, def := range defaults { + if def.GetConfiguration().GetID() == configID { + value := def.GetDefaultForNewRepos() + if value == "none" { + return "", nil + } + return value, nil + } + } + return "", nil +} From 75520b843163d85786f6b14fbd72bd9ad77c3e0e Mon Sep 17 00:00:00 2001 From: Jake Uren Date: Wed, 15 Jul 2026 14:43:00 +1000 Subject: [PATCH 2/4] Add acceptance tests for github_code_security_configuration Covers org-level create/update and import, plus enterprise-level create/update/import using the : import format. --- ...github_code_security_configuration_test.go | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 github/resource_github_code_security_configuration_test.go diff --git a/github/resource_github_code_security_configuration_test.go b/github/resource_github_code_security_configuration_test.go new file mode 100644 index 0000000000..3de15a28b1 --- /dev/null +++ b/github/resource_github_code_security_configuration_test.go @@ -0,0 +1,183 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccGithubCodeSecurityConfiguration(t *testing.T) { + t.Run("creates and updates an organization configuration without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + configs := map[string]string{ + "before": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test configuration" + + dependency_graph = "enabled" + dependabot_alerts = "disabled" + private_vulnerability_reporting = "disabled" + enforcement = "unenforced" + } + `, randomID), + + "after": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test configuration (updated)" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + private_vulnerability_reporting = "enabled" + enforcement = "enforced" + } + `, randomID), + } + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "description", "Terraform acceptance test configuration"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependency_graph", "enabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "disabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "private_vulnerability_reporting", "disabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "enforcement", "unenforced"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "target_type", "organization"), + resource.TestCheckResourceAttrSet("github_code_security_configuration.test", "configuration_id"), + resource.TestCheckResourceAttrSet("github_code_security_configuration.test", "html_url"), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_code_security_configuration.test", "description", "Terraform acceptance test configuration (updated)"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "enabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "private_vulnerability_reporting", "enabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "enforcement", "enforced"), + ), + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configs["before"], + Check: checks["before"], + }, + { + Config: configs["after"], + Check: checks["after"], + }, + }, + }) + }) + + t.Run("imports an organization configuration without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test import configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + }, + { + ResourceName: "github_code_security_configuration.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates, updates and imports an enterprise configuration without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + configs := map[string]string{ + "before": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + enterprise_slug = "%s" + name = "tf-acc-test-%s" + description = "Terraform acceptance test enterprise configuration" + + dependency_graph = "enabled" + dependabot_alerts = "disabled" + enforcement = "unenforced" + } + `, testAccConf.enterpriseSlug, randomID), + + "after": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + enterprise_slug = "%s" + name = "tf-acc-test-%s" + description = "Terraform acceptance test enterprise configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + } + `, testAccConf.enterpriseSlug, randomID), + } + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_code_security_configuration.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "disabled"), + resource.TestCheckResourceAttr("github_code_security_configuration.test", "target_type", "enterprise"), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "enabled"), + ), + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configs["before"], + Check: checks["before"], + }, + { + Config: configs["after"], + Check: checks["after"], + }, + { + ResourceName: "github_code_security_configuration.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importCodeSecurityConfigurationByEnterprise("github_code_security_configuration.test"), + }, + }, + }) + }) +} + +// importCodeSecurityConfigurationByEnterprise builds an import ID of the form +// : from the resource in state. +func importCodeSecurityConfigurationByEnterprise(logicalName string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs := s.RootModule().Resources[logicalName] + if rs == nil { + return "", fmt.Errorf("cannot find %s in terraform state", logicalName) + } + return fmt.Sprintf("%s:%s", testAccConf.enterpriseSlug, rs.Primary.ID), nil + } +} From 232b4cd882183c37cde1bb40b89758ae957c081d Mon Sep 17 00:00:00 2001 From: Jake Uren Date: Wed, 15 Jul 2026 14:43:00 +1000 Subject: [PATCH 3/4] Add docs template and example for github_code_security_configuration Docs regenerated with make generatedocs. --- docs/resources/code_security_configuration.md | 114 ++++++++++++++++++ .../code_security_configuration/example_1.tf | 34 ++++++ .../code_security_configuration.md.tmpl | 79 ++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 docs/resources/code_security_configuration.md create mode 100644 examples/resources/code_security_configuration/example_1.tf create mode 100644 templates/resources/code_security_configuration.md.tmpl diff --git a/docs/resources/code_security_configuration.md b/docs/resources/code_security_configuration.md new file mode 100644 index 0000000000..751713b523 --- /dev/null +++ b/docs/resources/code_security_configuration.md @@ -0,0 +1,114 @@ +--- +page_title: "github_code_security_configuration (Resource) - GitHub" +description: |- + Manages a GitHub Code Security Configuration at the organization or enterprise level. +--- + +# github_code_security_configuration (Resource) + +This resource allows you to create and manage [Code Security Configurations](https://docs.github.com/en/code-security/securing-your-organization/enabling-security-features-in-your-organization/about-enabling-security-features-at-scale) for a GitHub organization or enterprise. Code security configurations bundle security feature settings (Advanced Security, Dependabot, code scanning default setup, secret scanning, private vulnerability reporting) so they can be applied to repositories at scale. + +By default the configuration is created at the organization level, using the organization configured as the provider `owner`. Set `enterprise_slug` to create the configuration at the enterprise level instead. + +Organization-level usage requires an organization admin token. Enterprise-level usage requires enterprise admin access. + +## Example Usage + +```terraform +# Organization-level configuration, set as default for new private/internal +# repos and attached to all repositories without an existing configuration +resource "github_code_security_configuration" "org_baseline" { + name = "org-security-baseline" + description = "Baseline security configuration for all repositories" + + advanced_security = "enabled" + dependency_graph = "enabled" + dependabot_alerts = "enabled" + dependabot_security_updates = "enabled" + code_scanning_default_setup = "enabled" + secret_scanning = "enabled" + secret_scanning_push_protection = "enabled" + secret_scanning_validity_checks = "enabled" + secret_scanning_non_provider_patterns = "disabled" + private_vulnerability_reporting = "enabled" + enforcement = "enforced" + + default_for_new_repos = "private_and_internal" + attach_scope = "all_without_configurations" +} + +# Enterprise-level configuration +resource "github_code_security_configuration" "enterprise_baseline" { + enterprise_slug = "my-enterprise" + name = "enterprise-security-baseline" + description = "Enterprise-wide security baseline" + + dependabot_alerts = "enabled" + secret_scanning = "enabled" + secret_scanning_push_protection = "enabled" + + default_for_new_repos = "all" +} +``` + +## Argument Reference + +The following arguments are supported: + +- `name` - (Required) The name of the code security configuration. Must be unique within the organization or enterprise. + +- `description` - (Optional) A description of the code security configuration. + +- `enterprise_slug` - (Optional) The slug of the enterprise to create the configuration in. If omitted, the configuration is created at the organization level using the provider's configured owner. Changing this forces a new resource. + +- `advanced_security` - (Optional) The enablement status of GitHub Advanced Security. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `dependency_graph` - (Optional) The enablement status of Dependency Graph. Can be `enabled`, `disabled` or `not_set`. Defaults to `enabled`. + +- `dependabot_alerts` - (Optional) The enablement status of Dependabot alerts. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `dependabot_security_updates` - (Optional) The enablement status of Dependabot security updates. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `code_scanning_default_setup` - (Optional) The enablement status of code scanning default setup. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning` - (Optional) The enablement status of secret scanning. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_push_protection` - (Optional) The enablement status of secret scanning push protection. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_validity_checks` - (Optional) The enablement status of secret scanning validity checks. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_non_provider_patterns` - (Optional) The enablement status of secret scanning non-provider patterns. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `private_vulnerability_reporting` - (Optional) The enablement status of private vulnerability reporting. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `enforcement` - (Optional) The enforcement status of the configuration. Can be `enforced` or `unenforced`. Defaults to `enforced`. + +- `default_for_new_repos` - (Optional) Which types of new repositories this configuration should be applied to by default. Can be `all`, `none`, `private_and_internal` or `public`. If omitted, the configuration is not set as a default. + +- `attach_scope` - (Optional) The scope of repositories to attach the configuration to. Can be `all` or `all_without_configurations`. The attach operation runs on create and whenever this value changes. The GitHub API does not expose the scope a configuration was attached with, so this value cannot be read back; removing the attribute does not detach repositories. + +## Attributes Reference + +- `id` - The ID of the code security configuration. + +- `configuration_id` - The numeric ID of the code security configuration. + +- `target_type` - The target type of the configuration (`organization` or `enterprise`). + +- `html_url` - The URL of the configuration in the GitHub UI. + +## Import + +Organization-level code security configurations can be imported using the configuration ID: + +```shell +terraform import github_code_security_configuration.org_baseline 1234 +``` + +Enterprise-level configurations use `:`: + +```shell +terraform import github_code_security_configuration.enterprise_baseline my-enterprise:1234 +``` + +~> **Note** `attach_scope` cannot be recovered on import and will need to be re-specified in configuration if desired. diff --git a/examples/resources/code_security_configuration/example_1.tf b/examples/resources/code_security_configuration/example_1.tf new file mode 100644 index 0000000000..c7daf3d201 --- /dev/null +++ b/examples/resources/code_security_configuration/example_1.tf @@ -0,0 +1,34 @@ +# Organization-level configuration, set as default for new private/internal +# repos and attached to all repositories without an existing configuration +resource "github_code_security_configuration" "org_baseline" { + name = "org-security-baseline" + description = "Baseline security configuration for all repositories" + + advanced_security = "enabled" + dependency_graph = "enabled" + dependabot_alerts = "enabled" + dependabot_security_updates = "enabled" + code_scanning_default_setup = "enabled" + secret_scanning = "enabled" + secret_scanning_push_protection = "enabled" + secret_scanning_validity_checks = "enabled" + secret_scanning_non_provider_patterns = "disabled" + private_vulnerability_reporting = "enabled" + enforcement = "enforced" + + default_for_new_repos = "private_and_internal" + attach_scope = "all_without_configurations" +} + +# Enterprise-level configuration +resource "github_code_security_configuration" "enterprise_baseline" { + enterprise_slug = "my-enterprise" + name = "enterprise-security-baseline" + description = "Enterprise-wide security baseline" + + dependabot_alerts = "enabled" + secret_scanning = "enabled" + secret_scanning_push_protection = "enabled" + + default_for_new_repos = "all" +} diff --git a/templates/resources/code_security_configuration.md.tmpl b/templates/resources/code_security_configuration.md.tmpl new file mode 100644 index 0000000000..8b591ff411 --- /dev/null +++ b/templates/resources/code_security_configuration.md.tmpl @@ -0,0 +1,79 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Manages a GitHub Code Security Configuration at the organization or enterprise level. +--- + +# {{.Name}} ({{.Type}}) + +This resource allows you to create and manage [Code Security Configurations](https://docs.github.com/en/code-security/securing-your-organization/enabling-security-features-in-your-organization/about-enabling-security-features-at-scale) for a GitHub organization or enterprise. Code security configurations bundle security feature settings (Advanced Security, Dependabot, code scanning default setup, secret scanning, private vulnerability reporting) so they can be applied to repositories at scale. + +By default the configuration is created at the organization level, using the organization configured as the provider `owner`. Set `enterprise_slug` to create the configuration at the enterprise level instead. + +Organization-level usage requires an organization admin token. Enterprise-level usage requires enterprise admin access. + +## Example Usage + +{{ tffile "examples/resources/code_security_configuration/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `name` - (Required) The name of the code security configuration. Must be unique within the organization or enterprise. + +- `description` - (Optional) A description of the code security configuration. + +- `enterprise_slug` - (Optional) The slug of the enterprise to create the configuration in. If omitted, the configuration is created at the organization level using the provider's configured owner. Changing this forces a new resource. + +- `advanced_security` - (Optional) The enablement status of GitHub Advanced Security. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `dependency_graph` - (Optional) The enablement status of Dependency Graph. Can be `enabled`, `disabled` or `not_set`. Defaults to `enabled`. + +- `dependabot_alerts` - (Optional) The enablement status of Dependabot alerts. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `dependabot_security_updates` - (Optional) The enablement status of Dependabot security updates. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `code_scanning_default_setup` - (Optional) The enablement status of code scanning default setup. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning` - (Optional) The enablement status of secret scanning. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_push_protection` - (Optional) The enablement status of secret scanning push protection. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_validity_checks` - (Optional) The enablement status of secret scanning validity checks. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `secret_scanning_non_provider_patterns` - (Optional) The enablement status of secret scanning non-provider patterns. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `private_vulnerability_reporting` - (Optional) The enablement status of private vulnerability reporting. Can be `enabled`, `disabled` or `not_set`. Defaults to `disabled`. + +- `enforcement` - (Optional) The enforcement status of the configuration. Can be `enforced` or `unenforced`. Defaults to `enforced`. + +- `default_for_new_repos` - (Optional) Which types of new repositories this configuration should be applied to by default. Can be `all`, `none`, `private_and_internal` or `public`. If omitted, the configuration is not set as a default. + +- `attach_scope` - (Optional) The scope of repositories to attach the configuration to. Can be `all` or `all_without_configurations`. The attach operation runs on create and whenever this value changes. The GitHub API does not expose the scope a configuration was attached with, so this value cannot be read back; removing the attribute does not detach repositories. + +## Attributes Reference + +- `id` - The ID of the code security configuration. + +- `configuration_id` - The numeric ID of the code security configuration. + +- `target_type` - The target type of the configuration (`organization` or `enterprise`). + +- `html_url` - The URL of the configuration in the GitHub UI. + +## Import + +Organization-level code security configurations can be imported using the configuration ID: + +```shell +terraform import github_code_security_configuration.org_baseline 1234 +``` + +Enterprise-level configurations use `:`: + +```shell +terraform import github_code_security_configuration.enterprise_baseline my-enterprise:1234 +``` + +~> **Note** `attach_scope` cannot be recovered on import and will need to be re-specified in configuration if desired. From 5e15072fc4cca441fbae0d469de5ede36d839b5a Mon Sep 17 00:00:00 2001 From: mushhzz Date: Wed, 15 Jul 2026 18:48:48 +1000 Subject: [PATCH 4/4] Address review feedback for github_code_security_configuration - Migrate CRUD callbacks to CreateContext/ReadContext/UpdateContext/DeleteContext returning diag.Diagnostics, threading the provided context through all API calls - Replace stdlib log with structured tflog logging per DECISIONS.md - Treat 404 on delete as successful deletion - Replace go-github attach helpers with a nil-safe direct request path - Set computed fields directly in Create instead of chaining Read - Convert acceptance tests to ConfigStateChecks/statecheck per DECISIONS.md - Add acceptance coverage for default_for_new_repos (set/change/remove + empty-plan check) and attach_scope (set + idempotent plan) Co-Authored-By: Claude Fable 5 --- ...urce_github_code_security_configuration.go | 215 +++++++++++------- ...github_code_security_configuration_test.go | 205 ++++++++++++++--- 2 files changed, 301 insertions(+), 119 deletions(-) diff --git a/github/resource_github_code_security_configuration.go b/github/resource_github_code_security_configuration.go index dcfc0c6b45..a9de1a0119 100644 --- a/github/resource_github_code_security_configuration.go +++ b/github/resource_github_code_security_configuration.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" - "log" "net/http" "strconv" "strings" "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) @@ -18,11 +19,11 @@ func resourceGithubCodeSecurityConfiguration() *schema.Resource { featureValueDiag := validation.ToDiagFunc(validation.StringInSlice([]string{"enabled", "disabled", "not_set"}, false)) return &schema.Resource{ - Description: "Manages a GitHub Code Security Configuration at the organization or enterprise level.", - Create: resourceGithubCodeSecurityConfigurationCreate, - Read: resourceGithubCodeSecurityConfigurationRead, - Update: resourceGithubCodeSecurityConfigurationUpdate, - Delete: resourceGithubCodeSecurityConfigurationDelete, + Description: "Manages a GitHub Code Security Configuration at the organization or enterprise level.", + CreateContext: resourceGithubCodeSecurityConfigurationCreate, + ReadContext: resourceGithubCodeSecurityConfigurationRead, + UpdateContext: resourceGithubCodeSecurityConfigurationUpdate, + DeleteContext: resourceGithubCodeSecurityConfigurationDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubCodeSecurityConfigurationImport, }, @@ -171,10 +172,10 @@ func buildCodeSecurityConfiguration(d *schema.ResourceData) github.CodeSecurityC } } -func resourceGithubCodeSecurityConfigurationCreate(d *schema.ResourceData, meta any) error { - owner := meta.(*Owner) - client := owner.v3client - ctx := context.Background() +func resourceGithubCodeSecurityConfigurationCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name enterpriseSlug := d.Get("enterprise_slug").(string) body := buildCodeSecurityConfiguration(d) @@ -182,44 +183,54 @@ func resourceGithubCodeSecurityConfigurationCreate(d *schema.ResourceData, meta var config *github.CodeSecurityConfiguration var err error if enterpriseSlug != "" { - log.Printf("[DEBUG] Creating code security configuration %q for enterprise: %s", body.Name, enterpriseSlug) + tflog.Debug(ctx, "Creating code security configuration for enterprise", map[string]any{"name": body.Name, "enterprise_slug": enterpriseSlug}) config, _, err = client.Enterprise.CreateCodeSecurityConfiguration(ctx, enterpriseSlug, body) } else { - if err := checkOrganization(meta); err != nil { - return err + if err := checkOrganization(m); err != nil { + return diag.FromErr(err) } - log.Printf("[DEBUG] Creating code security configuration %q for organization: %s", body.Name, owner.name) - config, _, err = client.Organizations.CreateCodeSecurityConfiguration(ctx, owner.name, body) + tflog.Debug(ctx, "Creating code security configuration for organization", map[string]any{"name": body.Name, "owner": owner}) + config, _, err = client.Organizations.CreateCodeSecurityConfiguration(ctx, owner, body) } if err != nil { - return err + return diag.FromErr(err) } d.SetId(strconv.FormatInt(config.GetID(), 10)) + if err := d.Set("configuration_id", config.GetID()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("target_type", config.GetTargetType()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("html_url", config.GetHTMLURL()); err != nil { + return diag.FromErr(err) + } + if v, ok := d.GetOk("default_for_new_repos"); ok { - if err := setCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, config.GetID(), v.(string)); err != nil { - return err + if err := setCodeSecurityConfigurationDefault(ctx, client, owner, enterpriseSlug, config.GetID(), v.(string)); err != nil { + return diag.FromErr(err) } } if v, ok := d.GetOk("attach_scope"); ok { - if err := attachCodeSecurityConfiguration(ctx, client, owner.name, enterpriseSlug, config.GetID(), v.(string)); err != nil { - return err + if err := attachCodeSecurityConfiguration(ctx, client, owner, enterpriseSlug, config.GetID(), v.(string)); err != nil { + return diag.FromErr(err) } } - return resourceGithubCodeSecurityConfigurationRead(d, meta) + return nil } -func resourceGithubCodeSecurityConfigurationRead(d *schema.ResourceData, meta any) error { - owner := meta.(*Owner) - client := owner.v3client - ctx := context.Background() +func resourceGithubCodeSecurityConfigurationRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name configID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return unconvertibleIdErr(d.Id(), err) + return diag.FromErr(unconvertibleIdErr(d.Id(), err)) } enterpriseSlug := d.Get("enterprise_slug").(string) @@ -227,71 +238,70 @@ func resourceGithubCodeSecurityConfigurationRead(d *schema.ResourceData, meta an if enterpriseSlug != "" { config, _, err = client.Enterprise.GetCodeSecurityConfiguration(ctx, enterpriseSlug, configID) } else { - config, _, err = client.Organizations.GetCodeSecurityConfiguration(ctx, owner.name, configID) + config, _, err = client.Organizations.GetCodeSecurityConfiguration(ctx, owner, configID) } if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing code security configuration %s from state because it no longer exists in GitHub", d.Id()) + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing code security configuration from state because it no longer exists in GitHub.", map[string]any{"resource_id": d.Id(), "owner": owner, "enterprise_slug": enterpriseSlug}) d.SetId("") return nil } - return err + return diag.FromErr(err) } if err := d.Set("name", config.Name); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("description", config.Description); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("advanced_security", config.GetAdvancedSecurity()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("dependency_graph", config.GetDependencyGraph()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("dependabot_alerts", config.GetDependabotAlerts()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("dependabot_security_updates", config.GetDependabotSecurityUpdates()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("code_scanning_default_setup", config.GetCodeScanningDefaultSetup()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("secret_scanning", config.GetSecretScanning()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("secret_scanning_push_protection", config.GetSecretScanningPushProtection()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("secret_scanning_validity_checks", config.GetSecretScanningValidityChecks()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("secret_scanning_non_provider_patterns", config.GetSecretScanningNonProviderPatterns()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("private_vulnerability_reporting", config.GetPrivateVulnerabilityReporting()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("enforcement", config.GetEnforcement()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("configuration_id", config.GetID()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("target_type", config.GetTargetType()); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("html_url", config.GetHTMLURL()); err != nil { - return err + return diag.FromErr(err) } // default_for_new_repos is only surfaced via the /defaults listing. - defaultForNewRepos, err := readCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, configID) + defaultForNewRepos, err := readCodeSecurityConfigurationDefault(ctx, client, owner, enterpriseSlug, configID) if err != nil { - return err + return diag.FromErr(err) } // A configuration that is not a default (or is explicitly "none") does not // appear in the defaults listing; preserve an explicit "none" in state. @@ -299,7 +309,7 @@ func resourceGithubCodeSecurityConfigurationRead(d *schema.ResourceData, meta an defaultForNewRepos = "none" } if err := d.Set("default_for_new_repos", defaultForNewRepos); err != nil { - return err + return diag.FromErr(err) } // attach_scope is write-only: the API does not expose the scope a @@ -308,27 +318,40 @@ func resourceGithubCodeSecurityConfigurationRead(d *schema.ResourceData, meta an return nil } -func resourceGithubCodeSecurityConfigurationUpdate(d *schema.ResourceData, meta any) error { - owner := meta.(*Owner) - client := owner.v3client - ctx := context.Background() +func resourceGithubCodeSecurityConfigurationUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name configID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return unconvertibleIdErr(d.Id(), err) + return diag.FromErr(unconvertibleIdErr(d.Id(), err)) } enterpriseSlug := d.Get("enterprise_slug").(string) body := buildCodeSecurityConfiguration(d) + var config *github.CodeSecurityConfiguration if enterpriseSlug != "" { - log.Printf("[DEBUG] Updating code security configuration %d for enterprise: %s", configID, enterpriseSlug) - _, _, err = client.Enterprise.UpdateCodeSecurityConfiguration(ctx, enterpriseSlug, configID, body) + tflog.Debug(ctx, "Updating code security configuration for enterprise", map[string]any{"configuration_id": configID, "enterprise_slug": enterpriseSlug}) + config, _, err = client.Enterprise.UpdateCodeSecurityConfiguration(ctx, enterpriseSlug, configID, body) } else { - log.Printf("[DEBUG] Updating code security configuration %d for organization: %s", configID, owner.name) - _, _, err = client.Organizations.UpdateCodeSecurityConfiguration(ctx, owner.name, configID, body) + tflog.Debug(ctx, "Updating code security configuration for organization", map[string]any{"configuration_id": configID, "owner": owner}) + config, _, err = client.Organizations.UpdateCodeSecurityConfiguration(ctx, owner, configID, body) } if err != nil { - return err + return diag.FromErr(err) + } + + if config != nil { + if err := d.Set("configuration_id", config.GetID()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("target_type", config.GetTargetType()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("html_url", config.GetHTMLURL()); err != nil { + return diag.FromErr(err) + } } if d.HasChange("default_for_new_repos") { @@ -337,41 +360,48 @@ func resourceGithubCodeSecurityConfigurationUpdate(d *schema.ResourceData, meta // Removing the attribute reverts the default to "none". newReposParam = "none" } - if err := setCodeSecurityConfigurationDefault(ctx, client, owner.name, enterpriseSlug, configID, newReposParam); err != nil { - return err + if err := setCodeSecurityConfigurationDefault(ctx, client, owner, enterpriseSlug, configID, newReposParam); err != nil { + return diag.FromErr(err) } } if d.HasChange("attach_scope") { if v, ok := d.GetOk("attach_scope"); ok { - if err := attachCodeSecurityConfiguration(ctx, client, owner.name, enterpriseSlug, configID, v.(string)); err != nil { - return err + if err := attachCodeSecurityConfiguration(ctx, client, owner, enterpriseSlug, configID, v.(string)); err != nil { + return diag.FromErr(err) } } } - return resourceGithubCodeSecurityConfigurationRead(d, meta) + return nil } -func resourceGithubCodeSecurityConfigurationDelete(d *schema.ResourceData, meta any) error { - owner := meta.(*Owner) - client := owner.v3client - ctx := context.Background() +func resourceGithubCodeSecurityConfigurationDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name configID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return unconvertibleIdErr(d.Id(), err) + return diag.FromErr(unconvertibleIdErr(d.Id(), err)) } enterpriseSlug := d.Get("enterprise_slug").(string) if enterpriseSlug != "" { - log.Printf("[DEBUG] Deleting code security configuration %d for enterprise: %s", configID, enterpriseSlug) + tflog.Debug(ctx, "Deleting code security configuration for enterprise", map[string]any{"configuration_id": configID, "enterprise_slug": enterpriseSlug}) _, err = client.Enterprise.DeleteCodeSecurityConfiguration(ctx, enterpriseSlug, configID) } else { - log.Printf("[DEBUG] Deleting code security configuration %d for organization: %s", configID, owner.name) - _, err = client.Organizations.DeleteCodeSecurityConfiguration(ctx, owner.name, configID) + tflog.Debug(ctx, "Deleting code security configuration for organization", map[string]any{"configuration_id": configID, "owner": owner}) + _, err = client.Organizations.DeleteCodeSecurityConfiguration(ctx, owner, configID) } - return err + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Code security configuration no longer exists in GitHub; treating delete as successful.", map[string]any{"resource_id": d.Id(), "owner": owner, "enterprise_slug": enterpriseSlug}) + return nil + } + return diag.FromErr(err) + } + return nil } // resourceGithubCodeSecurityConfigurationImport supports two import ID formats: @@ -401,25 +431,46 @@ func resourceGithubCodeSecurityConfigurationImport(ctx context.Context, d *schem func setCodeSecurityConfigurationDefault(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64, newReposParam string) error { var err error if enterpriseSlug != "" { - log.Printf("[DEBUG] Setting code security configuration %d as default (%s) for enterprise: %s", configID, newReposParam, enterpriseSlug) + tflog.Debug(ctx, "Setting code security configuration as default for enterprise", map[string]any{"configuration_id": configID, "default_for_new_repos": newReposParam, "enterprise_slug": enterpriseSlug}) _, _, err = client.Enterprise.SetDefaultCodeSecurityConfiguration(ctx, enterpriseSlug, configID, newReposParam) } else { - log.Printf("[DEBUG] Setting code security configuration %d as default (%s) for organization: %s", configID, newReposParam, org) + tflog.Debug(ctx, "Setting code security configuration as default for organization", map[string]any{"configuration_id": configID, "default_for_new_repos": newReposParam, "owner": org}) _, _, err = client.Organizations.SetDefaultCodeSecurityConfiguration(ctx, org, configID, newReposParam) } return err } func attachCodeSecurityConfiguration(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64, scope string) error { - var err error + // The go-github v89 Attach helpers dereference the response status code even + // when the request fails before a response exists, which panics on transport + // errors. Issue the request directly so a nil response is handled safely. + var u string if enterpriseSlug != "" { - log.Printf("[DEBUG] Attaching code security configuration %d with scope %s for enterprise: %s", configID, scope, enterpriseSlug) - _, err = client.Enterprise.AttachCodeSecurityConfigurationToRepositories(ctx, enterpriseSlug, configID, scope) + tflog.Debug(ctx, "Attaching code security configuration for enterprise", map[string]any{"configuration_id": configID, "scope": scope, "enterprise_slug": enterpriseSlug}) + u = fmt.Sprintf("enterprises/%s/code-security/configurations/%d/attach", enterpriseSlug, configID) } else { - log.Printf("[DEBUG] Attaching code security configuration %d with scope %s for organization: %s", configID, scope, org) - _, err = client.Organizations.AttachCodeSecurityConfigurationToRepositories(ctx, org, configID, scope, nil) + tflog.Debug(ctx, "Attaching code security configuration for organization", map[string]any{"configuration_id": configID, "scope": scope, "owner": org}) + u = fmt.Sprintf("orgs/%s/code-security/configurations/%d/attach", org, configID) } - return err + + req, err := client.NewRequest(ctx, http.MethodPost, u, map[string]any{"scope": scope}) + if err != nil { + return err + } + + resp, err := client.Do(req, nil) + if err != nil { + // The attach endpoint responds 202 Accepted; go-github surfaces that as + // an AcceptedError, which is a success for our purposes. + if _, ok := errors.AsType[*github.AcceptedError](err); ok { + return nil + } + if resp != nil && resp.StatusCode == http.StatusAccepted { + return nil + } + return err + } + return nil } func readCodeSecurityConfigurationDefault(ctx context.Context, client *github.Client, org, enterpriseSlug string, configID int64) (string, error) { diff --git a/github/resource_github_code_security_configuration_test.go b/github/resource_github_code_security_configuration_test.go index 3de15a28b1..75664a1cd5 100644 --- a/github/resource_github_code_security_configuration_test.go +++ b/github/resource_github_code_security_configuration_test.go @@ -6,7 +6,11 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubCodeSecurityConfiguration(t *testing.T) { @@ -39,37 +43,37 @@ func TestAccGithubCodeSecurityConfiguration(t *testing.T) { `, randomID), } - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "description", "Terraform acceptance test configuration"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependency_graph", "enabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "disabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "private_vulnerability_reporting", "disabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "enforcement", "unenforced"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "target_type", "organization"), - resource.TestCheckResourceAttrSet("github_code_security_configuration.test", "configuration_id"), - resource.TestCheckResourceAttrSet("github_code_security_configuration.test", "html_url"), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_code_security_configuration.test", "description", "Terraform acceptance test configuration (updated)"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "enabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "private_vulnerability_reporting", "enabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "enforcement", "enforced"), - ), - } - resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: configs["before"], - Check: checks["before"], + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("tf-acc-test-%s", randomID))), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("description"), knownvalue.StringExact("Terraform acceptance test configuration")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("dependency_graph"), knownvalue.StringExact("enabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("dependabot_alerts"), knownvalue.StringExact("disabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("private_vulnerability_reporting"), knownvalue.StringExact("disabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("enforcement"), knownvalue.StringExact("unenforced")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("target_type"), knownvalue.StringExact("organization")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("configuration_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("html_url"), knownvalue.NotNull()), + }, }, { Config: configs["after"], - Check: checks["after"], + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_code_security_configuration.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("description"), knownvalue.StringExact("Terraform acceptance test configuration (updated)")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("dependabot_alerts"), knownvalue.StringExact("enabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("private_vulnerability_reporting"), knownvalue.StringExact("enabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("enforcement"), knownvalue.StringExact("enforced")), + }, }, }, }) @@ -95,7 +99,9 @@ func TestAccGithubCodeSecurityConfiguration(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("tf-acc-test-%s", randomID))), + }, }, { ResourceName: "github_code_security_configuration.test", @@ -106,6 +112,131 @@ func TestAccGithubCodeSecurityConfiguration(t *testing.T) { }) }) + t.Run("manages default_for_new_repos on an organization configuration without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + configs := map[string]string{ + "set": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test default configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + + default_for_new_repos = "private_and_internal" + } + `, randomID), + + "changed": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test default configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + + default_for_new_repos = "all" + } + `, randomID), + + "removed": fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test default configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + } + `, randomID), + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configs["set"], + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("tf-acc-test-%s", randomID))), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("default_for_new_repos"), knownvalue.StringExact("private_and_internal")), + }, + }, + { + Config: configs["changed"], + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_code_security_configuration.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("default_for_new_repos"), knownvalue.StringExact("all")), + }, + }, + { + Config: configs["removed"], + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("default_for_new_repos"), knownvalue.StringExact("")), + }, + }, + { + Config: configs["removed"], + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) + }) + + t.Run("attaches an organization configuration to repositories by scope without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + // attach_scope is write-only: the attachment cannot be read back from + // the API, so these steps only assert that the configured value is + // held in state and that re-planning the same config is a no-op. + config := fmt.Sprintf(` + resource "github_code_security_configuration" "test" { + name = "tf-acc-test-%s" + description = "Terraform acceptance test attach configuration" + + dependency_graph = "enabled" + dependabot_alerts = "enabled" + enforcement = "unenforced" + + attach_scope = "all_without_configurations" + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("tf-acc-test-%s", randomID))), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("attach_scope"), knownvalue.StringExact("all_without_configurations")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("configuration_id"), knownvalue.NotNull()), + }, + }, + { + Config: config, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) + }) + t.Run("creates, updates and imports an enterprise configuration without error", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) @@ -135,29 +266,29 @@ func TestAccGithubCodeSecurityConfiguration(t *testing.T) { `, testAccConf.enterpriseSlug, randomID), } - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_code_security_configuration.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "disabled"), - resource.TestCheckResourceAttr("github_code_security_configuration.test", "target_type", "enterprise"), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_code_security_configuration.test", "dependabot_alerts", "enabled"), - ), - } - resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessEnterprise(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: configs["before"], - Check: checks["before"], + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("tf-acc-test-%s", randomID))), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("dependabot_alerts"), knownvalue.StringExact("disabled")), + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("target_type"), knownvalue.StringExact("enterprise")), + }, }, { Config: configs["after"], - Check: checks["after"], + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_code_security_configuration.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_code_security_configuration.test", tfjsonpath.New("dependabot_alerts"), knownvalue.StringExact("enabled")), + }, }, { ResourceName: "github_code_security_configuration.test",