From 2faba293f4e82d35e6245cdd5c09f5db0d0032b7 Mon Sep 17 00:00:00 2001 From: Allan Degnan Date: Thu, 21 May 2026 11:57:33 +0100 Subject: [PATCH] Add github_organization_ip_allow_list_entry resource Adds a managed resource that mirrors github_enterprise_ip_allow_list_entry but is scoped to the organization configured on the provider. Uses the createIpAllowListEntry / updateIpAllowListEntry / deleteIpAllowListEntry GraphQL mutations with the organization node ID as the owner. Includes a tfplugindocs template at templates/resources/ and the generated docs file under docs/resources/, plus an example_1.tf alongside. Closes #1067 --- .../organization_ip_allow_list_entry.md | 37 +++ .../example_1.tf | 5 + github/provider.go | 1 + ...github_organization_ip_allow_list_entry.go | 294 ++++++++++++++++++ ...b_organization_ip_allow_list_entry_test.go | 91 ++++++ github/util_v4.go | 19 ++ .../organization_ip_allow_list_entry.md.tmpl | 31 ++ 7 files changed, 478 insertions(+) create mode 100644 docs/resources/organization_ip_allow_list_entry.md create mode 100644 examples/resources/organization_ip_allow_list_entry/example_1.tf create mode 100644 github/resource_github_organization_ip_allow_list_entry.go create mode 100644 github/resource_github_organization_ip_allow_list_entry_test.go create mode 100644 templates/resources/organization_ip_allow_list_entry.md.tmpl diff --git a/docs/resources/organization_ip_allow_list_entry.md b/docs/resources/organization_ip_allow_list_entry.md new file mode 100644 index 0000000000..c06c21a568 --- /dev/null +++ b/docs/resources/organization_ip_allow_list_entry.md @@ -0,0 +1,37 @@ +--- +page_title: "github_organization_ip_allow_list_entry (Resource) - GitHub" +description: |- + Creates and manages IP allow list entries within a GitHub Organization +--- + +# github_organization_ip_allow_list_entry (Resource) + +This resource allows you to create and manage IP allow list entries for a GitHub Organization. IP allow list entries define IP addresses or ranges that are permitted to access private and internal resources owned by the organization. + +The organization is taken from the `owner` configured on the provider. The organization must be on a GitHub Enterprise Cloud plan, and the IP allow list itself must be enabled in the organization's security settings before entries can be created. + +## Example Usage + +```terraform +resource "github_organization_ip_allow_list_entry" "test" { + ip = "192.168.1.0/20" + name = "My IP Range Name" + is_active = true +} +``` + +## Argument Reference + +The following arguments are supported: + +- `ip` - (Required) An IP address or range of IP addresses in CIDR notation. +- `name` - (Optional) A descriptive name for the IP allow list entry. +- `is_active` - (Optional) Whether the entry is currently active. Default: true. + +## Import + +This resource can be imported using the ID of the IP allow list entry: + +```shell +terraform import github_organization_ip_allow_list_entry.test IALE_kwHOC1234567890a +``` diff --git a/examples/resources/organization_ip_allow_list_entry/example_1.tf b/examples/resources/organization_ip_allow_list_entry/example_1.tf new file mode 100644 index 0000000000..bef37a8dd4 --- /dev/null +++ b/examples/resources/organization_ip_allow_list_entry/example_1.tf @@ -0,0 +1,5 @@ +resource "github_organization_ip_allow_list_entry" "test" { + ip = "192.168.1.0/20" + name = "My IP Range Name" + is_active = true +} diff --git a/github/provider.go b/github/provider.go index b70c58c9f0..588ef1296a 100644 --- a/github/provider.go +++ b/github/provider.go @@ -213,6 +213,7 @@ func NewProvider() func() *schema.Provider { "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_ip_allow_list_entry": resourceGithubEnterpriseIpAllowListEntry(), + "github_organization_ip_allow_list_entry": resourceGithubOrganizationIpAllowListEntry(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), diff --git a/github/resource_github_organization_ip_allow_list_entry.go b/github/resource_github_organization_ip_allow_list_entry.go new file mode 100644 index 0000000000..8b8bb07c93 --- /dev/null +++ b/github/resource_github_organization_ip_allow_list_entry.go @@ -0,0 +1,294 @@ +package github + +import ( + "context" + "strings" + + "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/shurcooL/githubv4" +) + +func resourceGithubOrganizationIpAllowListEntry() *schema.Resource { + return &schema.Resource{ + Description: "Manage a GitHub Organization IP Allow List Entry.", + CreateContext: resourceGithubOrganizationIpAllowListEntryCreate, + ReadContext: resourceGithubOrganizationIpAllowListEntryRead, + UpdateContext: resourceGithubOrganizationIpAllowListEntryUpdate, + DeleteContext: resourceGithubOrganizationIpAllowListEntryDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubOrganizationIpAllowListEntryImport, + }, + + Schema: map[string]*schema.Schema{ + "ip": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "An IP address or range of IP addresses in CIDR notation.", + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "An optional name for the IP allow list entry.", + }, + "is_active": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Whether the entry is currently active.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of when the entry was created.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of when the entry was last updated.", + }, + }, + } +} + +func resourceGithubOrganizationIpAllowListEntryCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v4client + orgName := meta.(*Owner).name + + organizationID, err := getOrganizationID(ctx, client, orgName) + if err != nil { + return diag.FromErr(err) + } + + var mutation struct { + CreateIpAllowListEntry struct { + IpAllowListEntry struct { + ID githubv4.String + AllowListValue githubv4.String + Name githubv4.String + IsActive githubv4.Boolean + CreatedAt githubv4.String + UpdatedAt githubv4.String + } + } `graphql:"createIpAllowListEntry(input: $input)"` + } + + name := d.Get("name").(string) + input := githubv4.CreateIpAllowListEntryInput{ + OwnerID: githubv4.ID(organizationID), + AllowListValue: githubv4.String(d.Get("ip").(string)), + IsActive: githubv4.Boolean(d.Get("is_active").(bool)), + } + + if name != "" { + v := githubv4.String(name) + input.Name = &v + } + + err = client.Mutate(ctx, &mutation, input, nil) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(string(mutation.CreateIpAllowListEntry.IpAllowListEntry.ID)) + + if err := d.Set("created_at", mutation.CreateIpAllowListEntry.IpAllowListEntry.CreatedAt); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", mutation.CreateIpAllowListEntry.IpAllowListEntry.UpdatedAt); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationIpAllowListEntryRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v4client + + var query struct { + Node struct { + IpAllowListEntry struct { + ID githubv4.String + AllowListValue githubv4.String + Name githubv4.String + IsActive githubv4.Boolean + CreatedAt githubv4.String + UpdatedAt githubv4.String + Owner struct { + Organization struct { + Login githubv4.String + } `graphql:"... on Organization"` + } + } `graphql:"... on IpAllowListEntry"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]any{ + "id": githubv4.ID(d.Id()), + } + + err := client.Query(ctx, &query, variables) + if err != nil { + if strings.Contains(err.Error(), "Could not resolve to a node with the global id") { + tflog.Info(ctx, "Removing IP allow list entry from state because it no longer exists in GitHub", map[string]any{ + "id": d.Id(), + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + entry := query.Node.IpAllowListEntry + if err := d.Set("name", entry.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set("ip", entry.AllowListValue); err != nil { + return diag.FromErr(err) + } + if err := d.Set("is_active", entry.IsActive); err != nil { + return diag.FromErr(err) + } + if err := d.Set("created_at", entry.CreatedAt); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", entry.UpdatedAt); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationIpAllowListEntryUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v4client + + var mutation struct { + UpdateIpAllowListEntry struct { + IpAllowListEntry struct { + ID githubv4.String + AllowListValue githubv4.String + Name githubv4.String + IsActive githubv4.Boolean + UpdatedAt githubv4.String + } + } `graphql:"updateIpAllowListEntry(input: $input)"` + } + + name := d.Get("name").(string) + input := githubv4.UpdateIpAllowListEntryInput{ + IPAllowListEntryID: githubv4.ID(d.Id()), + AllowListValue: githubv4.String(d.Get("ip").(string)), + IsActive: githubv4.Boolean(d.Get("is_active").(bool)), + } + + if name != "" { + v := githubv4.String(name) + input.Name = &v + } + + err := client.Mutate(ctx, &mutation, input, nil) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("updated_at", mutation.UpdateIpAllowListEntry.IpAllowListEntry.UpdatedAt); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationIpAllowListEntryDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v4client + + var mutation struct { + DeleteIpAllowListEntry struct { + ClientMutationID githubv4.String + } `graphql:"deleteIpAllowListEntry(input: $input)"` + } + + input := githubv4.DeleteIpAllowListEntryInput{ + IPAllowListEntryID: githubv4.ID(d.Id()), + } + + err := client.Mutate(ctx, &mutation, input, nil) + // GraphQL will return a 200 OK if it couldn't find the global ID + if err != nil && !strings.Contains(err.Error(), "Could not resolve to a node with the global id") { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationIpAllowListEntryImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + if err := checkOrganization(meta); err != nil { + return nil, err + } + + client := meta.(*Owner).v4client + + var query struct { + Node struct { + IpAllowListEntry struct { + ID githubv4.String + AllowListValue githubv4.String + Name githubv4.String + IsActive githubv4.Boolean + CreatedAt githubv4.String + UpdatedAt githubv4.String + Owner struct { + Organization struct { + Login githubv4.String + } `graphql:"... on Organization"` + } + } `graphql:"... on IpAllowListEntry"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]any{ + "id": githubv4.ID(d.Id()), + } + + err := client.Query(ctx, &query, variables) + if err != nil { + return nil, err + } + + entry := query.Node.IpAllowListEntry + + if err := d.Set("ip", string(entry.AllowListValue)); err != nil { + return nil, err + } + if err := d.Set("name", entry.Name); err != nil { + return nil, err + } + if err := d.Set("is_active", entry.IsActive); err != nil { + return nil, err + } + if err := d.Set("created_at", entry.CreatedAt); err != nil { + return nil, err + } + if err := d.Set("updated_at", entry.UpdatedAt); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_organization_ip_allow_list_entry_test.go b/github/resource_github_organization_ip_allow_list_entry_test.go new file mode 100644 index 0000000000..f3e5dc5c41 --- /dev/null +++ b/github/resource_github_organization_ip_allow_list_entry_test.go @@ -0,0 +1,91 @@ +package github + +import ( + "fmt" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubOrganizationIpAllowListEntry(t *testing.T) { + t.Run("basic", func(t *testing.T) { + resourceName := "github_organization_ip_allow_list_entry.test" + ip := "192.168.1.0/24" + name := "Test Entry" + isActive := true + + config := ` +resource "github_organization_ip_allow_list_entry" "test" { + ip = "%s" + name = "%s" + is_active = %t +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessHasOrgs(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, ip, name, isActive), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "ip", ip), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "is_active", strconv.FormatBool(isActive)), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + t.Run("update", func(t *testing.T) { + resourceName := "github_organization_ip_allow_list_entry.test" + ip := "192.168.1.0/24" + name := "Test Entry" + isActive := true + + updatedIP := "10.0.0.0/16" + updatedName := "Updated Entry" + updatedIsActive := false + + config := ` + resource "github_organization_ip_allow_list_entry" "test" { + ip = "%s" + name = "%s" + is_active = %t + } + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessHasOrgs(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, ip, name, isActive), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "ip", ip), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "is_active", fmt.Sprintf("%t", isActive)), + ), + }, + { + Config: fmt.Sprintf(config, updatedIP, updatedName, updatedIsActive), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "ip", updatedIP), + resource.TestCheckResourceAttr(resourceName, "name", updatedName), + resource.TestCheckResourceAttr(resourceName, "is_active", fmt.Sprintf("%t", updatedIsActive)), + ), + }, + }, + }) + }) +} diff --git a/github/util_v4.go b/github/util_v4.go index 9be0eacf8f..eb791abeae 100644 --- a/github/util_v4.go +++ b/github/util_v4.go @@ -67,3 +67,22 @@ func getEnterpriseID(ctx context.Context, client *githubv4.Client, enterpriseSlu return query.Enterprise.ID.(string), nil } + +func getOrganizationID(ctx context.Context, client *githubv4.Client, orgName string) (string, error) { + var query struct { + Organization struct { + ID githubv4.ID + } `graphql:"organization(login: $login)"` + } + + variables := map[string]any{ + "login": githubv4.String(orgName), + } + + err := client.Query(ctx, &query, variables) + if err != nil { + return "", err + } + + return query.Organization.ID.(string), nil +} diff --git a/templates/resources/organization_ip_allow_list_entry.md.tmpl b/templates/resources/organization_ip_allow_list_entry.md.tmpl new file mode 100644 index 0000000000..04a9899498 --- /dev/null +++ b/templates/resources/organization_ip_allow_list_entry.md.tmpl @@ -0,0 +1,31 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Creates and manages IP allow list entries within a GitHub Organization +--- + +# {{.Name}} ({{.Type}}) + +This resource allows you to create and manage IP allow list entries for a GitHub Organization. IP allow list entries define IP addresses or ranges that are permitted to access private and internal resources owned by the organization. + +The organization is taken from the `owner` configured on the provider. The organization must be on a GitHub Enterprise Cloud plan, and the IP allow list itself must be enabled in the organization's security settings before entries can be created. + +## Example Usage + +{{tffile "examples/resources/organization_ip_allow_list_entry/example_1.tf"}} + +## Argument Reference + +The following arguments are supported: + +- `ip` - (Required) An IP address or range of IP addresses in CIDR notation. +- `name` - (Optional) A descriptive name for the IP allow list entry. +- `is_active` - (Optional) Whether the entry is currently active. Default: true. + +## Import + +This resource can be imported using the ID of the IP allow list entry: + +```shell +terraform import github_organization_ip_allow_list_entry.test IALE_kwHOC1234567890a +```