diff --git a/docs/index.md b/docs/index.md
index bc73dadd87..6a29124b20 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -112,6 +112,169 @@ provider "github" {
}
```
+### Opt-In Enterprise App Lifecycle
+
+The optional `enterprise_app_auth` block enables an App-only path for
+`github_enterprise_organization`. It does not change default/PAT authentication
+or route other organization resources dynamically. Use a separate normally
+configured provider for repositories, teams, and other organization resources.
+This feature requires a provider build containing this opt-in implementation.
+
+Set `auth_mode = "app"`. The existing `app_auth` credentials are the enterprise
+creation App; its installation must target the configured enterprise. `owner`
+is not required or looked up in this mode. No PAT or GitHub CLI fallback is
+allowed: remove `token` and the environment variable selected by `token_env_name`
+(normally `GITHUB_TOKEN`). Clients renew installation tokens automatically.
+
+```terraform
+terraform {
+ required_providers {
+ github = {
+ source = "integrations/github"
+ }
+ }
+}
+
+provider "github" {
+ auth_mode = "app"
+
+ app_auth {
+ id = var.creation_app_id
+ installation_id = var.creation_installation_id
+ pem_file = var.creation_private_key
+ }
+
+ enterprise_app_auth {
+ enterprise_slug = var.enterprise_slug
+ installer_app_id = var.installer_app_id
+ installer_installation_id = var.installer_installation_id
+ installer_pem_file = var.installer_private_key
+ automation_app_id = var.automation_app_id
+ automation_client_id = var.automation_client_id
+ automation_pem_file = var.automation_private_key
+ }
+}
+
+data "github_enterprise" "selected" {
+ slug = var.enterprise_slug
+}
+
+resource "github_enterprise_organization" "managed" {
+ for_each = var.organizations
+
+ enterprise_id = data.github_enterprise.selected.id
+ name = each.key
+ billing_email = each.value.billing_email
+ admin_logins = each.value.admin_logins
+
+ lifecycle {
+ prevent_destroy = true
+ }
+}
+
+variable "enterprise_slug" {
+ type = string
+ description = "Enterprise managed by the creation and installer Apps."
+}
+
+variable "creation_app_id" {
+ type = string
+ description = "Enterprise creation App ID or client ID."
+}
+
+variable "creation_installation_id" {
+ type = string
+ description = "Enterprise creation App installation ID."
+}
+
+variable "creation_private_key" {
+ type = string
+ sensitive = true
+ description = "Creation App private key PEM content."
+}
+
+variable "installer_app_id" {
+ type = string
+ description = "Installer App ID or client ID."
+}
+
+variable "installer_installation_id" {
+ type = string
+ description = "Installer enterprise installation ID."
+}
+
+variable "installer_private_key" {
+ type = string
+ sensitive = true
+ description = "Installer App private key PEM content."
+}
+
+variable "automation_app_id" {
+ type = string
+ description = "Numeric automation App ID."
+}
+
+variable "automation_client_id" {
+ type = string
+ description = "Automation App client ID matching the numeric ID and private key."
+}
+
+variable "automation_private_key" {
+ type = string
+ sensitive = true
+ description = "Automation App private key PEM content."
+}
+
+variable "organizations" {
+ type = map(object({
+ billing_email = string
+ admin_logins = set(string)
+ }))
+ description = "Organizations to create or import into the configured enterprise."
+}
+```
+
+Supply the new block's credentials as attributes or with these dedicated
+environment defaults. Environment variables alone do not enable the feature;
+the block with `enterprise_slug` must be present.
+
+| Attribute | Environment Default |
+| --- | --- |
+| `installer_app_id` | `INSTALLER_APP_CLIENT_ID` |
+| `installer_installation_id` | `INSTALLER_APP_INSTALLATION_ID` |
+| `installer_pem_file` | `INSTALLER_APP_PRIVATE_KEY` |
+| `automation_app_id` | `ORG_APP_ID` |
+| `automation_client_id` | `ORG_APP_CLIENT_ID` |
+| `automation_pem_file` | `ORG_APP_PRIVATE_KEY` |
+
+`installer_app_id` accepts the numeric App ID or client ID JWT issuer.
+`automation_app_id` is the numeric App ID; `automation_client_id` must identify
+the same App. PEM attributes/environment variables contain private key content,
+not file paths. Keep all private key inputs sensitive in HCP Terraform.
+All installer and automation credentials are required, whether supplied as
+attributes or environment variables. Missing credentials fail configuration.
+
+The installer must have access to the enterprise's installable organization
+inventory and permission to install organization Apps. The automation App must
+have `organization_administration = write` and `members = read` (or `write`).
+Repository permissions other than metadata are rejected because bootstrap uses
+`repository_selection = "none"`. The creation App needs the enterprise
+organization-creation permission. See the GitHub
+[enterprise organization APIs](https://docs.github.com/en/rest/enterprise-admin/organizations)
+and [App installation APIs](https://docs.github.com/en/rest/apps/installations)
+for endpoint permission requirements.
+
+Read/import never install, update, or unsuspend Apps. Creation installs the
+automation App only if its organization installation is absent. Existing
+installations must be active and already have the required permissions; this
+provider does not upgrade them. The `automation_installation_id` resource
+attribute exposes the discovered installation ID, but does not configure other
+provider instances in the same run.
+
+Updates and deletion of enterprise organizations are explicitly blocked in
+this mode. For partial-create recovery and import behavior, see
+[github_enterprise_organization](resources/enterprise_organization).
+
### OAuth or Personal Access Token (PAT)
To authenticate using OAuth tokens, ensure that the `token` argument or the `GITHUB_TOKEN` environment variable is set.
@@ -155,6 +318,7 @@ provider "github" {
- `auth_mode` (String) The authentication mode to use; this can be one of `auto`, `app`, `token` or `none` and defaults to `auto` which will detect the highest priority authentication mode available (`app` -> `token` -> `none`). This can also be set by the `GITHUB_AUTH_MODE` environment variable.
- `base_url` (String) The base URL for the GitHub API; this defaults to the GitHub API URL. If you are using GitHub Enterprise Server (GHES) or GitHub Enterprise Cloud with Data Residency (GHEC-DR), this is required. This can also be set by the `GITHUB_BASE_URL` environment variable.
- `cache_path` (String) The path to the cache directory for persisting GitHub API requests between runs; if not set there will be no caching between runs. This can also be set by the `GITHUB_CACHE_PATH` environment variable.
+- `enterprise_app_auth` (Block List, Max: 1) Opt-in enterprise App lifecycle credentials. All credentials must be supplied here or through their dedicated environment variables. (see [below for nested schema](#nestedblock--enterprise_app_auth))
- `insecure` (Boolean, Deprecated) Allow insecure server connections when using SSL.
- `legacy_client` (Boolean) Use the legacy GitHub client implementation; if set to `false`, the new client implementation is used. This can also be set by the `GITHUB_LEGACY_CLIENT` environment variable.
- `max_per_page` (Number) The maximum number of results per page for paginated API requests; this defaults to `100`. This can also be set by the `GITHUB_MAX_PER_PAGE` environment variable.
@@ -177,3 +341,20 @@ Optional:
- `id` (String) The GitHub App's identifier. This can also be set by the `GITHUB_APP_ID` environment variable when `app_auth_env_prefix` is `GITHUB_APP_` (modify the prefix as needed).
- `installation_id` (String) The GitHub App's installation identifier. This can also be set by the `GITHUB_APP_INSTALLATION_ID` environment variable when `app_auth_env_prefix` is `GITHUB_APP_` (modify the prefix as needed).
- `pem_file` (String, Sensitive) The GitHub App's PEM file content; `\n` can be used for newlines. This can also be set by the `GITHUB_APP_PEM_FILE` environment variable when `app_auth_env_prefix` is `GITHUB_APP_` (modify the prefix as needed).
+
+
+
+### Nested Schema for `enterprise_app_auth`
+
+Required:
+
+- `enterprise_slug` (String) Enterprise slug to manage. Opts into App-only enterprise organization lifecycle; deletion and updates are blocked.
+
+Optional:
+
+- `automation_app_id` (String) Automation App numeric ID. Defaults to ORG_APP_ID.
+- `automation_client_id` (String) Automation App client ID. Defaults to ORG_APP_CLIENT_ID.
+- `automation_pem_file` (String, Sensitive) Automation private key PEM content, not a filename. Defaults to ORG_APP_PRIVATE_KEY.
+- `installer_app_id` (String) Installer App numeric ID or client ID. Defaults to INSTALLER_APP_CLIENT_ID.
+- `installer_installation_id` (String) Installer enterprise installation ID. Defaults to INSTALLER_APP_INSTALLATION_ID.
+- `installer_pem_file` (String, Sensitive) Installer private key PEM content, not a filename. Defaults to INSTALLER_APP_PRIVATE_KEY.
diff --git a/docs/resources/enterprise_organization.md b/docs/resources/enterprise_organization.md
index e745b32261..5bbb6e36a4 100644
--- a/docs/resources/enterprise_organization.md
+++ b/docs/resources/enterprise_organization.md
@@ -38,6 +38,46 @@ The following additional attributes are exported:
- `id` - The node ID of the organization for use with the v4 API.
- `database_id` - The ID of the organization.
+- `automation_installation_id` - The automation App installation ID discovered
+ when the provider opts into `enterprise_app_auth`. Unset in legacy mode.
+
+## Opt-In App Lifecycle
+
+With the provider's `enterprise_app_auth` block, this resource creates an
+organization with the existing enterprise creation App and bootstraps the
+automation App with the enterprise installer. The enterprise ID must match the
+configured enterprise slug. Node IDs and numeric organization IDs are checked
+against enterprise inventory and the organization APIs before accepting state.
+Propagation retries are bounded by the create timeout and stop on permission,
+suspension, and identity errors. A failed installation POST is not retried.
+
+Refresh and import use the installer's paginated enterprise inventory plus
+organization-scoped automation clients. Billing comes from REST `billing_email`,
+not GraphQL `organizationBillingEmail`. Missing/null billing is an error; existing
+billing state is not replaced with an empty value. Owners are paginated and only
+`ADMIN` memberships are retained. Denied, ambiguous, missing-installation, or
+identity-mismatch reads return errors and retain the resource ID, rather than
+silently planning a replacement. Organizations renamed outside Terraform require
+explicit reconciliation/import; refresh does not guess an identity from a new login.
+
+Updates are not supported in this opt-in path, including changes to owners,
+billing, display name, and description. Restore the previous configuration or
+perform approved external changes and reconcile configuration before refreshing.
+Deletion is blocked before any API request until an approved permission and
+deletion contract is available. `lifecycle { prevent_destroy = true }` is
+recommended. Neither this mode nor the installation ID output dynamically routes
+ordinary organization resources such as repositories or teams.
+
+If creation succeeds but installation/profile bootstrap fails, the error reports
+the enterprise/org import ID and retains the returned node/database IDs, including
+in the SDK apply response. Terraform can mark a failed create as tainted; do not
+blindly retry or replace it. Inspect the organization and App installation,
+correct the missing permissions/installation through approved administration,
+then untaint the existing object if state is complete or explicitly reconcile
+state and import it. Import and refresh will not finish a missing installation.
+Deletion remains blocked even for a tainted object. If the creation API response
+itself is lost, check enterprise inventory and import any created organization
+before retrying, because a trustworthy node ID may not have been returned.
## Import
@@ -46,3 +86,8 @@ GitHub Enterprise Organization can be imported using the `slug` of the enterpris
```shell
terraform import github_enterprise_organization.org enterp/some-awesome-org
```
+
+In `enterprise_app_auth` mode the enterprise slug in the import ID must match
+the configured slug. The automation App must already be installed, active, and
+authorized for organization administration and member reads. Import makes no
+installation writes and fails on missing permissions instead of discarding state.
diff --git a/examples/provider/enterprise_app_auth/main.tf b/examples/provider/enterprise_app_auth/main.tf
new file mode 100644
index 0000000000..79a9fa6d23
--- /dev/null
+++ b/examples/provider/enterprise_app_auth/main.tf
@@ -0,0 +1,105 @@
+terraform {
+ required_providers {
+ github = {
+ source = "integrations/github"
+ }
+ }
+}
+
+provider "github" {
+ auth_mode = "app"
+
+ app_auth {
+ id = var.creation_app_id
+ installation_id = var.creation_installation_id
+ pem_file = var.creation_private_key
+ }
+
+ enterprise_app_auth {
+ enterprise_slug = var.enterprise_slug
+ installer_app_id = var.installer_app_id
+ installer_installation_id = var.installer_installation_id
+ installer_pem_file = var.installer_private_key
+ automation_app_id = var.automation_app_id
+ automation_client_id = var.automation_client_id
+ automation_pem_file = var.automation_private_key
+ }
+}
+
+data "github_enterprise" "selected" {
+ slug = var.enterprise_slug
+}
+
+resource "github_enterprise_organization" "managed" {
+ for_each = var.organizations
+
+ enterprise_id = data.github_enterprise.selected.id
+ name = each.key
+ billing_email = each.value.billing_email
+ admin_logins = each.value.admin_logins
+
+ lifecycle {
+ prevent_destroy = true
+ }
+}
+
+variable "enterprise_slug" {
+ type = string
+ description = "Enterprise managed by the creation and installer Apps."
+}
+
+variable "creation_app_id" {
+ type = string
+ description = "Enterprise creation App ID or client ID."
+}
+
+variable "creation_installation_id" {
+ type = string
+ description = "Enterprise creation App installation ID."
+}
+
+variable "creation_private_key" {
+ type = string
+ sensitive = true
+ description = "Creation App private key PEM content."
+}
+
+variable "installer_app_id" {
+ type = string
+ description = "Installer App ID or client ID."
+}
+
+variable "installer_installation_id" {
+ type = string
+ description = "Installer enterprise installation ID."
+}
+
+variable "installer_private_key" {
+ type = string
+ sensitive = true
+ description = "Installer App private key PEM content."
+}
+
+variable "automation_app_id" {
+ type = string
+ description = "Numeric automation App ID."
+}
+
+variable "automation_client_id" {
+ type = string
+ description = "Automation App client ID matching the numeric ID and private key."
+}
+
+variable "automation_private_key" {
+ type = string
+ sensitive = true
+ description = "Automation App private key PEM content."
+}
+
+variable "organizations" {
+ type = map(object({
+ billing_email = string
+ admin_logins = set(string)
+ }))
+ description = "Organizations to create or import into the configured enterprise."
+}
\ No newline at end of file
diff --git a/github/config.go b/github/config.go
index c6cf571f15..52e8ba49f3 100644
--- a/github/config.go
+++ b/github/config.go
@@ -16,6 +16,7 @@ import (
)
type Config struct {
+ EnterpriseApp *enterpriseAppConfig
AppID *string
AppInstallationID *string
AppPEM []byte
@@ -36,6 +37,7 @@ type Config struct {
}
type Owner struct {
+ enterpriseApp *enterpriseAppLifecycle
name string
id int64
v3client *github.Client
diff --git a/github/enterprise_app_auth.go b/github/enterprise_app_auth.go
new file mode 100644
index 0000000000..54acc524ec
--- /dev/null
+++ b/github/enterprise_app_auth.go
@@ -0,0 +1,90 @@
+package github
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/google/go-github/v92/github"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+ "github.com/shurcooL/githubv4"
+
+ "github.com/integrations/terraform-provider-github/v6/internal/ghclient"
+)
+
+type enterpriseAppConfig struct {
+ slug, installerID, installerInstallationID, installerPEM string
+ automationID, automationClientID, automationPEM string
+}
+
+type enterpriseAppLifecycle struct {
+ config *enterpriseAppConfig
+ enterpriseID string
+ creationJWT *github.Client
+ installerJWT *github.Client
+ installer *github.Client
+ automationJWT *github.Client
+ automationPermissions map[string]string
+ opts ghclient.ClientOptions
+ orgClients func(int64) (*github.Client, *githubv4.Client, error)
+}
+
+func enterpriseAppAuthSchema() *schema.Schema {
+ fields := map[string]*schema.Schema{}
+ for name, description := range map[string]string{
+ "enterprise_slug": "Enterprise slug to manage. Opts into App-only enterprise organization lifecycle; deletion and updates are blocked.",
+ "installer_app_id": "Installer App numeric ID or client ID. Defaults to INSTALLER_APP_CLIENT_ID.",
+ "installer_installation_id": "Installer enterprise installation ID. Defaults to INSTALLER_APP_INSTALLATION_ID.",
+ "installer_pem_file": "Installer private key PEM content, not a filename. Defaults to INSTALLER_APP_PRIVATE_KEY.",
+ "automation_app_id": "Automation App numeric ID. Defaults to ORG_APP_ID.",
+ "automation_client_id": "Automation App client ID. Defaults to ORG_APP_CLIENT_ID.",
+ "automation_pem_file": "Automation private key PEM content, not a filename. Defaults to ORG_APP_PRIVATE_KEY.",
+ } {
+ fields[name] = &schema.Schema{
+ Type: schema.TypeString, Optional: true, Description: description,
+ ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotWhiteSpace),
+ }
+ }
+ fields["enterprise_slug"].Optional = false
+ fields["enterprise_slug"].Required = true
+ fields["enterprise_slug"].ValidateDiagFunc = validation.ToDiagFunc(validation.StringMatch(regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`), "must be an enterprise slug"))
+ for _, name := range []string{"automation_app_id", "installer_installation_id"} {
+ fields[name].ValidateDiagFunc = validation.ToDiagFunc(validation.StringMatch(regexp.MustCompile(`^[1-9][0-9]*$`), "must be a positive numeric ID"))
+ }
+ fields["installer_pem_file"].Sensitive = true
+ fields["automation_pem_file"].Sensitive = true
+ return &schema.Schema{Type: schema.TypeList, Optional: true, MaxItems: 1, Description: "Opt-in enterprise App lifecycle credentials. All credentials must be supplied here or through their dedicated environment variables.", Elem: &schema.Resource{Schema: fields}}
+}
+
+func parseEnterpriseAppAuth(data *schema.ResourceData) (*enterpriseAppConfig, error) {
+ value, ok := data.GetOk("enterprise_app_auth")
+ if !ok {
+ return nil, nil
+ }
+ blocks := value.([]any)
+ if len(blocks) != 1 || blocks[0] == nil {
+ return nil, fmt.Errorf("enterprise_app_auth requires a complete block")
+ }
+ fields := blocks[0].(map[string]any)
+ get := func(name, env string) string {
+ if value, _ := fields[name].(string); value != "" {
+ return value
+ }
+ return os.Getenv(env)
+ }
+ config := &enterpriseAppConfig{
+ slug: get("enterprise_slug", ""), installerID: get("installer_app_id", "INSTALLER_APP_CLIENT_ID"),
+ installerInstallationID: get("installer_installation_id", "INSTALLER_APP_INSTALLATION_ID"),
+ installerPEM: get("installer_pem_file", "INSTALLER_APP_PRIVATE_KEY"),
+ automationID: get("automation_app_id", "ORG_APP_ID"), automationClientID: get("automation_client_id", "ORG_APP_CLIENT_ID"),
+ automationPEM: get("automation_pem_file", "ORG_APP_PRIVATE_KEY"),
+ }
+ if config.slug == "" || config.installerID == "" || config.installerInstallationID == "" || config.installerPEM == "" || config.automationID == "" || config.automationClientID == "" || config.automationPEM == "" {
+ return nil, fmt.Errorf("enterprise_app_auth requires enterprise_slug and all installer and automation credentials (attributes or dedicated environment variables)")
+ }
+ config.installerPEM = strings.ReplaceAll(config.installerPEM, `\n`, "\n")
+ config.automationPEM = strings.ReplaceAll(config.automationPEM, `\n`, "\n")
+ return config, nil
+}
diff --git a/github/enterprise_app_auth_test.go b/github/enterprise_app_auth_test.go
new file mode 100644
index 0000000000..a2040150e1
--- /dev/null
+++ b/github/enterprise_app_auth_test.go
@@ -0,0 +1,162 @@
+package github
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func TestEnterpriseAppAuthGuard(t *testing.T) {
+ provider := NewProvider("test", "test")()
+ if err := provider.InternalValidate(); err != nil {
+ t.Fatal(err)
+ }
+ data := schema.TestResourceDataRaw(t, provider.Schema, map[string]any{
+ "auth_mode": "app", "token": "test-pat",
+ "app_auth": []any{map[string]any{"id": "1", "installation_id": "2", "pem_file": "invalid-test-key"}},
+ "enterprise_app_auth": []any{map[string]any{
+ "enterprise_slug": "test-enterprise", "installer_app_id": "3", "installer_installation_id": "4", "installer_pem_file": "invalid-test-key",
+ "automation_app_id": "5", "automation_client_id": "test-client", "automation_pem_file": "invalid-test-key",
+ }},
+ })
+ _, diagnostics := configureProvider("test", "test")(context.Background(), data)
+ if !diagnostics.HasError() || diagnostics[0].Summary != "enterprise_app_auth requires auth_mode = app, complete creation app_auth credentials, and no token attribute or token environment variable" {
+ t.Fatalf("expected pre-network App-only guard, got %v", diagnostics)
+ }
+}
+
+func TestEnterpriseAppConfigureWithoutOwnerDiscovery(t *testing.T) {
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ requests++
+ writer.Header().Set("Content-Type", "application/json")
+ var identity struct {
+ Issuer string `json:"iss"`
+ }
+ parts := strings.Split(strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer "), ".")
+ if len(parts) == 3 {
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ t.Error(err)
+ }
+ if err := json.Unmarshal(payload, &identity); err != nil {
+ t.Error(err)
+ }
+ }
+ switch request.URL.Path {
+ case "/app":
+ if identity.Issuer == "21" {
+ fmt.Fprint(writer, `{"id":21,"client_id":"automation-client","permissions":{"organization_administration":"write","members":"read"}}`)
+ } else if identity.Issuer == "installer-client" {
+ fmt.Fprint(writer, `{"id":11,"client_id":"installer-client"}`)
+ } else if identity.Issuer == "1" {
+ fmt.Fprint(writer, `{"id":1,"client_id":"creation-client"}`)
+ } else {
+ t.Errorf("unexpected JWT issuer %q", identity.Issuer)
+ writer.WriteHeader(http.StatusForbidden)
+ }
+ case "/app/installations/2":
+ fmt.Fprint(writer, `{"id":2,"app_id":1,"target_type":"Enterprise","enterprise":{"slug":"test-enterprise"}}`)
+ case "/app/installations/12":
+ fmt.Fprint(writer, `{"id":12,"app_id":11,"target_type":"Enterprise","account":{"slug":"test-enterprise"},"permissions":{"enterprise_organization_installations":"write"}}`)
+ case "/app/installations/2/access_tokens":
+ fmt.Fprint(writer, `{"token":"mock-creation-token","expires_at":"2099-01-01T00:00:00Z"}`)
+ case "/graphql":
+ if request.Header.Get("Authorization") != "Bearer mock-creation-token" {
+ t.Errorf("wrong enterprise auth")
+ }
+ fmt.Fprint(writer, `{"data":{"enterprise":{"id":"E_test"}}}`)
+ default:
+ t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
+ writer.WriteHeader(http.StatusInternalServerError)
+ }
+ }))
+ defer server.Close()
+ baseURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ owner, err := configureProviderMeta(t.Context(), "test", &Config{
+ BaseURL: baseURL, Owner: "test-enterprise", AppID: new("1"), AppInstallationID: new("2"), AppPEM: key,
+ EnterpriseApp: &enterpriseAppConfig{slug: "test-enterprise", installerID: "installer-client", installerInstallationID: "12", installerPEM: string(key), automationID: "21", automationClientID: "automation-client", automationPEM: string(key)},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if owner.enterpriseApp == nil || owner.enterpriseApp.enterpriseID != "E_test" || owner.IsOrganization || requests != 7 {
+ t.Fatalf("wrong enterprise metadata or request count %d", requests)
+ }
+}
+
+func TestEnterpriseAppAuthEnvironmentDefaults(t *testing.T) {
+ for name, value := range map[string]string{
+ "INSTALLER_APP_CLIENT_ID": "installer-client", "INSTALLER_APP_INSTALLATION_ID": "12", "INSTALLER_APP_PRIVATE_KEY": "installer-test-key",
+ "ORG_APP_ID": "21", "ORG_APP_CLIENT_ID": "automation-client", "ORG_APP_PRIVATE_KEY": "automation-test-key",
+ } {
+ t.Setenv(name, value)
+ }
+ data := schema.TestResourceDataRaw(t, NewProvider("test", "test")().Schema, map[string]any{"enterprise_app_auth": []any{map[string]any{"enterprise_slug": "test-enterprise"}}})
+ config, err := parseEnterpriseAppAuth(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.installerID != "installer-client" || config.installerInstallationID != "12" || config.automationID != "21" || config.automationClientID != "automation-client" {
+ t.Fatal("environment defaults not loaded")
+ }
+}
+
+func TestEnterpriseAppDefaultOwnerLookupStillFails(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ requests++
+ if request.URL.Path != "/users/test-enterprise" {
+ t.Errorf("unexpected default-mode request: %s", request.URL.Path)
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusNotFound)
+ fmt.Fprint(writer, `{"message":"Not Found"}`)
+ }))
+ defer server.Close()
+ baseURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = configureProviderMeta(t.Context(), "test", &Config{BaseURL: baseURL, Owner: "test-enterprise", Token: "mock-pat"})
+ if err == nil || !strings.Contains(err.Error(), "failed to lookup owner") || requests != 1 {
+ t.Fatalf("default owner 404 was bypassed: requests=%d err=%v", requests, err)
+ }
+}
+
+func TestEnterpriseAppTokenEnvironmentGuard(t *testing.T) {
+ t.Setenv("ENTERPRISE_TEST_PAT", "mock-pat")
+ provider := NewProvider("test", "test")()
+ data := schema.TestResourceDataRaw(t, provider.Schema, map[string]any{
+ "auth_mode": "app", "token_env_name": "ENTERPRISE_TEST_PAT",
+ "app_auth": []any{map[string]any{"id": "1", "installation_id": "2", "pem_file": "invalid-test-key"}},
+ "enterprise_app_auth": []any{map[string]any{
+ "enterprise_slug": "test-enterprise", "installer_app_id": "3", "installer_installation_id": "4", "installer_pem_file": "invalid-test-key",
+ "automation_app_id": "5", "automation_client_id": "test-client", "automation_pem_file": "invalid-test-key",
+ }},
+ })
+ _, diagnostics := configureProvider("test", "test")(t.Context(), data)
+ if !diagnostics.HasError() || !strings.Contains(diagnostics[0].Summary, "no token attribute or token environment") {
+ t.Fatalf("token environment was not rejected before network access: %v", diagnostics)
+ }
+}
diff --git a/github/enterprise_app_core_test.go b/github/enterprise_app_core_test.go
new file mode 100644
index 0000000000..de9b38a7f9
--- /dev/null
+++ b/github/enterprise_app_core_test.go
@@ -0,0 +1,151 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
+)
+
+func TestEnterpriseAppTerraformCoreRecovery(t *testing.T) {
+ terraform := os.Getenv("TF_CORE_TEST_BINARY")
+ if terraform == "" {
+ t.Skip("set TF_CORE_TEST_BINARY to run the offline Terraform CLI recovery test")
+ }
+ t.Run("successful-bootstrap", func(t *testing.T) { enterpriseAppTerraformCoreScenario(t, terraform, false) })
+ t.Run("failed-bootstrap-recovery", func(t *testing.T) { enterpriseAppTerraformCoreScenario(t, terraform, true) })
+}
+
+func enterpriseAppTerraformCoreScenario(t *testing.T, terraform string, failBootstrap bool) {
+ t.Helper()
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.missing = !failBootstrap
+ if failBootstrap {
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ }
+ provider := &schema.Provider{
+ ResourcesMap: map[string]*schema.Resource{"github_enterprise_organization": resourceGithubEnterpriseOrganization()},
+ ConfigureContextFunc: func(context.Context, *schema.ResourceData) (any, diag.Diagnostics) {
+ return fixture.owner, nil
+ },
+ }
+ serverContext, cancel := context.WithCancel(t.Context())
+ reattach, closed, err := plugin.DebugServe(serverContext, &plugin.ServeOpts{
+ ProviderFunc: func() *schema.Provider { return provider }, NoLogOutputOverride: true,
+ })
+ if err != nil {
+ cancel()
+ t.Fatal(err)
+ }
+ defer func() {
+ cancel()
+ select {
+ case <-closed:
+ case <-time.After(10 * time.Second):
+ t.Error("local provider server did not stop")
+ }
+ }()
+ attachment, err := json.Marshal(map[string]plugin.ReattachConfig{"registry.terraform.io/integrations/github": reattach})
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ configuration := `terraform {
+ required_providers {
+ github = { source = "integrations/github" }
+ }
+}
+resource "github_enterprise_organization" "pilot" {
+ enterprise_id = "E_test"
+ name = "test-org"
+ display_name = "Test Org"
+ description = "managed"
+ billing_email = "billing@example.test"
+ admin_logins = ["owner-one", "owner-two"]
+ lifecycle { prevent_destroy = true }
+}
+`
+ if err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(configuration), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cliConfig := filepath.Join(root, "test.tfrc")
+ if err := os.WriteFile(cliConfig, []byte("provider_installation { dev_overrides { \"integrations/github\" = "+strconv.Quote(filepath.ToSlash(root))+" } direct {} }"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ environment := []string{}
+ for _, entry := range os.Environ() {
+ name, _, _ := strings.Cut(entry, "=")
+ upper := strings.ToUpper(name)
+ if !strings.HasPrefix(upper, "TF_") && !strings.HasPrefix(upper, "TFC_") && !strings.Contains(upper, "GITHUB") && !strings.Contains(upper, "APP_PRIVATE_KEY") {
+ environment = append(environment, entry)
+ }
+ }
+ environment = append(environment, "TF_REATTACH_PROVIDERS="+string(attachment), "TF_CLI_CONFIG_FILE="+cliConfig, "TF_INPUT=0", "CHECKPOINT_DISABLE=1")
+ run := func(wantSuccess bool, arguments ...string) string {
+ t.Helper()
+ commandContext, stop := context.WithTimeout(t.Context(), 30*time.Second)
+ defer stop()
+ command := exec.CommandContext(commandContext, terraform, append([]string{"-chdir=" + root}, arguments...)...)
+ command.Env = environment
+ output, commandErr := command.CombinedOutput()
+ if (commandErr == nil) != wantSuccess {
+ t.Fatalf("terraform %v: %v\n%s", arguments, commandErr, output)
+ }
+ return string(output)
+ }
+ output := run(!failBootstrap, "apply", "-auto-approve", "-no-color")
+ if failBootstrap && !strings.Contains(output, "ID retained") {
+ t.Fatalf("unexpected create failure: %s", output)
+ }
+ stateBytes, err := os.ReadFile(filepath.Join(root, "terraform.tfstate"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state struct {
+ Resources []struct {
+ Instances []struct {
+ Status string `json:"status"`
+ Attributes struct {
+ ID string `json:"id"`
+ DatabaseID int64 `json:"database_id"`
+ } `json:"attributes"`
+ } `json:"instances"`
+ } `json:"resources"`
+ }
+ if err := json.Unmarshal(stateBytes, &state); err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Resources) != 1 || len(state.Resources[0].Instances) != 1 {
+ t.Fatalf("missing partial-create state: %s", stateBytes)
+ }
+ instance := state.Resources[0].Instances[0]
+ wantStatus, wantInstalls, wantProfiles := "", 1, 1
+ if failBootstrap {
+ wantStatus, wantInstalls, wantProfiles = "tainted", 0, 0
+ }
+ if instance.Status != wantStatus || instance.Attributes.ID != "O_test" || instance.Attributes.DatabaseID != 42 {
+ t.Fatalf("unexpected partial-create identity/status: %s", stateBytes)
+ }
+ if failBootstrap {
+ fixture.suspended = nil
+ output = run(false, "plan", "-no-color")
+ if !strings.Contains(output, "prevent_destroy") {
+ t.Fatalf("tainted replacement was not guarded: %s", output)
+ }
+ run(true, "untaint", "-no-color", "github_enterprise_organization.pilot")
+ }
+ output = run(true, "plan", "-no-color", "-detailed-exitcode")
+ if !strings.Contains(output, "No changes.") || fixture.mutations != 1 || fixture.installPosts != wantInstalls || fixture.profileWrites != wantProfiles {
+ t.Fatalf("recovery repeated a mutation or retained drift: %s; creates=%d installs=%d profile=%d", output, fixture.mutations, fixture.installPosts, fixture.profileWrites)
+ }
+ t.Log("PASS: Terraform Core state identity, bootstrap mutation counts and stable subsequent plan verified")
+}
diff --git a/github/enterprise_app_lifecycle.go b/github/enterprise_app_lifecycle.go
new file mode 100644
index 0000000000..9e195199ae
--- /dev/null
+++ b/github/enterprise_app_lifecycle.go
@@ -0,0 +1,573 @@
+package github
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/go-github/v92/github"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/shurcooL/githubv4"
+
+ "github.com/integrations/terraform-provider-github/v6/internal/ghclient"
+)
+
+var (
+ errEnterpriseInventoryAbsent = errors.New("organization absent from enterprise inventory")
+ errAutomationInstallationAbsent = errors.New("automation installation absent")
+)
+
+func enterprisePropagationError(err error) bool {
+ if errors.Is(err, errEnterpriseInventoryAbsent) || errors.Is(err, errAutomationInstallationAbsent) {
+ return true
+ }
+ response, ok := errors.AsType[*github.ErrorResponse](err)
+ return ok && response.Response != nil && response.Response.StatusCode == http.StatusNotFound
+}
+
+type enterpriseAppIdentity struct {
+ ID int64 `json:"id"`
+ ClientID string `json:"client_id"`
+ Permissions map[string]string `json:"permissions"`
+}
+
+type enterpriseAppInstallation struct {
+ ID int64 `json:"id"`
+ AppID int64 `json:"app_id"`
+ Account struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+ Slug string `json:"slug"`
+ } `json:"account"`
+ Enterprise struct {
+ Slug string `json:"slug"`
+ } `json:"enterprise"`
+ TargetType string `json:"target_type"`
+ SuspendedAt *time.Time `json:"suspended_at"`
+ Permissions map[string]string `json:"permissions"`
+}
+
+type enterpriseInventoryOrganization struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+}
+
+func enterpriseAppRequest(ctx context.Context, client *github.Client, method, path string, body, result any) (*github.Response, error) {
+ request, err := client.NewRequest(ctx, method, path, body)
+ if err != nil {
+ return nil, err
+ }
+ return client.Do(request, result)
+}
+
+func positiveAppID(value, field string) (int64, error) {
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed <= 0 {
+ return 0, fmt.Errorf("%s must be a positive int64 ID", field)
+ }
+ return parsed, nil
+}
+
+func configureEnterpriseApp(ctx context.Context, version string, config *Config) (*Owner, error) {
+ if config.AppID == nil || config.AppInstallationID == nil || len(config.AppPEM) == 0 || config.Token != "" {
+ return nil, fmt.Errorf("enterprise_app_auth requires creation App credentials and forbids PAT authentication")
+ }
+ opts := ghclient.ClientOptions{BaseURL: config.BaseURL.String(), IsGHES: config.IsGHES, UserAgent: providerName + "/" + version}
+ creationInstallation, err := positiveAppID(*config.AppInstallationID, "app_auth.installation_id")
+ if err != nil {
+ return nil, err
+ }
+ installerInstallation, err := positiveAppID(config.EnterpriseApp.installerInstallationID, "installer_installation_id")
+ if err != nil {
+ return nil, err
+ }
+ if _, err := positiveAppID(config.EnterpriseApp.automationID, "automation_app_id"); err != nil {
+ return nil, err
+ }
+ lifecycle := &enterpriseAppLifecycle{config: config.EnterpriseApp, opts: opts}
+ lifecycle.creationJWT, err = ghclient.NewAppRESTClient(*config.AppID, config.AppPEM, nil, opts)
+ if err != nil {
+ return nil, fmt.Errorf("invalid creation App credentials")
+ }
+ lifecycle.installerJWT, err = ghclient.NewAppRESTClient(lifecycle.config.installerID, []byte(lifecycle.config.installerPEM), nil, opts)
+ if err != nil {
+ return nil, fmt.Errorf("invalid installer App credentials")
+ }
+ lifecycle.automationJWT, err = ghclient.NewAppRESTClient(lifecycle.config.automationID, []byte(lifecycle.config.automationPEM), nil, opts)
+ if err != nil {
+ return nil, fmt.Errorf("invalid automation App credentials")
+ }
+ lifecycle.installer, err = ghclient.NewAppRESTClient(lifecycle.config.installerID, []byte(lifecycle.config.installerPEM), &installerInstallation, opts)
+ if err != nil {
+ return nil, err
+ }
+ owner := &Owner{enterpriseApp: lifecycle, maxPerPage: config.MaxPerPage}
+ owner.v3client, err = ghclient.NewAppRESTClient(*config.AppID, config.AppPEM, &creationInstallation, opts)
+ if err != nil {
+ return nil, err
+ }
+ owner.v4client, err = ghclient.NewAppGraphQLClient(*config.AppID, config.AppPEM, &creationInstallation, opts)
+ if err != nil {
+ return nil, err
+ }
+ if err := lifecycle.validateEnterpriseInstallation(ctx, lifecycle.creationJWT, *config.AppID, creationInstallation, ""); err != nil {
+ return nil, fmt.Errorf("creation App: %w", err)
+ }
+ if err := lifecycle.validateEnterpriseInstallation(ctx, lifecycle.installerJWT, lifecycle.config.installerID, installerInstallation, "enterprise_organization_installations"); err != nil {
+ return nil, fmt.Errorf("installer App: %w", err)
+ }
+ lifecycle.automationPermissions, err = lifecycle.validateAutomationApp(ctx)
+ if err != nil {
+ return nil, err
+ }
+ lifecycle.enterpriseID, err = getEnterpriseID(ctx, owner.v4client, lifecycle.config.slug)
+ if err != nil || lifecycle.enterpriseID == "" {
+ return nil, fmt.Errorf("cannot validate enterprise %q using creation App: %v", lifecycle.config.slug, err)
+ }
+ lifecycle.orgClients = func(installationID int64) (*github.Client, *githubv4.Client, error) {
+ rest, err := ghclient.NewAppRESTClient(lifecycle.config.automationID, []byte(lifecycle.config.automationPEM), &installationID, opts)
+ if err != nil {
+ return nil, nil, err
+ }
+ graphql, err := ghclient.NewAppGraphQLClient(lifecycle.config.automationID, []byte(lifecycle.config.automationPEM), &installationID, opts)
+ return rest, graphql, err
+ }
+ return owner, nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) validateEnterpriseInstallation(ctx context.Context, client *github.Client, issuer string, installationID int64, requiredPermission string) error {
+ var app enterpriseAppIdentity
+ if _, err := enterpriseAppRequest(ctx, client, http.MethodGet, "app", nil, &app); err != nil {
+ return fmt.Errorf("cannot verify App JWT identity: %w", err)
+ }
+ if app.ID <= 0 || (issuer != strconv.FormatInt(app.ID, 10) && issuer != app.ClientID) {
+ return fmt.Errorf("App JWT identity does not match configured issuer")
+ }
+ var installation enterpriseAppInstallation
+ if _, err := enterpriseAppRequest(ctx, client, http.MethodGet, fmt.Sprintf("app/installations/%d", installationID), nil, &installation); err != nil {
+ return err
+ }
+ enterpriseSlug := installation.Account.Slug
+ if enterpriseSlug == "" {
+ enterpriseSlug = installation.Enterprise.Slug
+ }
+ if enterpriseSlug == "" {
+ enterpriseSlug = installation.Account.Login
+ }
+ if installation.ID != installationID || installation.AppID != app.ID || installation.SuspendedAt != nil || installation.TargetType != "Enterprise" ||
+ !strings.EqualFold(enterpriseSlug, lifecycle.config.slug) {
+ return fmt.Errorf("installation must be active, belong to this App, and target configured enterprise %q; no installation changes were made", lifecycle.config.slug)
+ }
+ if requiredPermission != "" && installation.Permissions[requiredPermission] != "write" {
+ return fmt.Errorf("installer App installation requires %s=write; creation blocked, no installation changes were made", requiredPermission)
+ }
+ return nil
+}
+
+func checkAutomationPermissions(permissions map[string]string) error {
+ if permissions["organization_administration"] != "write" || (permissions["members"] != "read" && permissions["members"] != "write") {
+ return fmt.Errorf("automation App requires organization_administration=write and members=read or write")
+ }
+ for permission, level := range permissions {
+ if level != "" && level != "none" && permission != "metadata" && permission != "members" && !strings.HasPrefix(permission, "organization_") {
+ return fmt.Errorf("automation App requests unsupported permission %q; repository permissions are not supported with repository_selection=none", permission)
+ }
+ }
+ return nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) validateAutomationApp(ctx context.Context) (map[string]string, error) {
+ var app enterpriseAppIdentity
+ if _, err := enterpriseAppRequest(ctx, lifecycle.automationJWT, http.MethodGet, "app", nil, &app); err != nil {
+ return nil, fmt.Errorf("cannot verify automation App JWT identity: %w", err)
+ }
+ if strconv.FormatInt(app.ID, 10) != lifecycle.config.automationID || app.ClientID != lifecycle.config.automationClientID {
+ return nil, fmt.Errorf("automation App numeric ID, client ID and private key do not identify the same App")
+ }
+ if err := checkAutomationPermissions(app.Permissions); err != nil {
+ return nil, err
+ }
+ return app.Permissions, nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) inventoryOrganization(ctx context.Context, name string) (*enterpriseInventoryOrganization, error) {
+ var found *enterpriseInventoryOrganization
+ for page := 1; ; page++ {
+ var organizations []enterpriseInventoryOrganization
+ path := fmt.Sprintf("enterprises/%s/apps/installable_organizations?per_page=100&page=%d", url.PathEscape(lifecycle.config.slug), page)
+ response, err := enterpriseAppRequest(ctx, lifecycle.installer, http.MethodGet, path, nil, &organizations)
+ if err != nil {
+ return nil, fmt.Errorf("cannot verify enterprise inventory; state retained: %w", err)
+ }
+ for _, organization := range organizations {
+ if strings.EqualFold(organization.Login, name) {
+ if found != nil || organization.ID <= 0 {
+ return nil, fmt.Errorf("ambiguous enterprise inventory for %q; state retained", name)
+ }
+ found = new(organization)
+ }
+ }
+ if response.NextPage == 0 && len(organizations) < 100 {
+ break
+ }
+ if response.NextPage > page {
+ page = response.NextPage - 1
+ }
+ }
+ if found == nil {
+ return nil, fmt.Errorf("%w for %q; verify membership and installer access, state retained", errEnterpriseInventoryAbsent, name)
+ }
+ return found, nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) installation(ctx context.Context, name string) (*enterpriseAppInstallation, bool, error) {
+ var installation enterpriseAppInstallation
+ response, err := enterpriseAppRequest(ctx, lifecycle.automationJWT, http.MethodGet, "orgs/"+url.PathEscape(name)+"/installation", nil, &installation)
+ if err != nil {
+ if response != nil && response.StatusCode == http.StatusNotFound {
+ return nil, true, nil
+ }
+ return nil, false, err
+ }
+ if installation.ID <= 0 || strconv.FormatInt(installation.AppID, 10) != lifecycle.config.automationID || installation.SuspendedAt != nil || !strings.EqualFold(installation.Account.Login, name) {
+ return nil, false, fmt.Errorf("automation installation is suspended or has an unexpected App/account identity; no changes were made")
+ }
+ if err := checkAutomationPermissions(installation.Permissions); err != nil {
+ return nil, false, err
+ }
+ for permission, requested := range lifecycle.automationPermissions {
+ granted := installation.Permissions[permission]
+ if requested == "write" && granted != "write" || requested == "read" && granted != "read" && granted != "write" {
+ return nil, false, fmt.Errorf("automation installation has not granted requested permission %s=%s; approve permissions externally, no installation upgrade was attempted", permission, requested)
+ }
+ }
+ return &installation, false, nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) organizationClients(ctx context.Context, name string, databaseID int64) (*github.Client, *githubv4.Client, int64, error) {
+ installation, missing, err := lifecycle.installation(ctx, name)
+ if err != nil {
+ return nil, nil, 0, err
+ }
+ if missing {
+ return nil, nil, 0, fmt.Errorf("%w; install the configured App through approved bootstrap, then import/refresh; read and import never install Apps", errAutomationInstallationAbsent)
+ }
+ if installation.Account.ID != databaseID {
+ return nil, nil, 0, fmt.Errorf("automation installation organization ID mismatch; state retained")
+ }
+ rest, graphql, err := lifecycle.orgClients(installation.ID)
+ return rest, graphql, installation.ID, err
+}
+
+func (lifecycle *enterpriseAppLifecycle) checkEnterprise(data *schema.ResourceData) error {
+ if data.Get("enterprise_id").(string) != lifecycle.enterpriseID {
+ return fmt.Errorf("resource enterprise_id does not match configured enterprise %q; no changes were made", lifecycle.config.slug)
+ }
+ return nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) read(ctx context.Context, data *schema.ResourceData) error {
+ if err := lifecycle.checkEnterprise(data); err != nil {
+ return err
+ }
+ name := data.Get("name").(string)
+ inventory, err := lifecycle.inventoryOrganization(ctx, name)
+ if err != nil {
+ return err
+ }
+ if stored := data.Get("database_id").(int); stored != 0 && int64(stored) != inventory.ID {
+ return fmt.Errorf("enterprise inventory database ID mismatch; state retained")
+ }
+ rest, graphql, installationID, err := lifecycle.organizationClients(ctx, name, inventory.ID)
+ if err != nil {
+ return err
+ }
+ organization, _, err := rest.Organizations.Get(ctx, name)
+ if err != nil {
+ return fmt.Errorf("cannot read organization; state retained: %w", err)
+ }
+ if organization.GetNodeID() == "" || organization.GetNodeID() != data.Id() || organization.GetID() != inventory.ID || !strings.EqualFold(organization.GetLogin(), name) {
+ return fmt.Errorf("REST organization node/database/login identity mismatch; state retained")
+ }
+ if organization.BillingEmail == nil {
+ return fmt.Errorf("organization billing_email is missing or null; check automation App organization administration permission; state retained")
+ }
+ var owners []any
+ var cursor *githubv4.String
+ for {
+ var query struct {
+ Organization struct {
+ ID githubv4.ID
+ DatabaseID githubv4.Int
+ Login githubv4.String
+ MembersWithRole struct {
+ Edges []struct {
+ Node struct{ Login githubv4.String }
+ Role githubv4.String
+ }
+ PageInfo PageInfo
+ } `graphql:"membersWithRole(first:100, after:$cursor)"`
+ } `graphql:"organization(login:$login)"`
+ }
+ if err := graphql.Query(ctx, &query, map[string]any{"login": githubv4.String(name), "cursor": cursor}); err != nil {
+ return fmt.Errorf("cannot read organization owners; state retained: %w", err)
+ }
+ if fmt.Sprint(query.Organization.ID) != data.Id() || int64(query.Organization.DatabaseID) != inventory.ID || !strings.EqualFold(string(query.Organization.Login), name) {
+ return fmt.Errorf("GraphQL organization identity mismatch; state retained")
+ }
+ for _, edge := range query.Organization.MembersWithRole.Edges {
+ if edge.Role == "ADMIN" {
+ owners = append(owners, string(edge.Node.Login))
+ }
+ }
+ if !query.Organization.MembersWithRole.PageInfo.HasNextPage {
+ break
+ }
+ next := query.Organization.MembersWithRole.PageInfo.EndCursor
+ if next == "" || (cursor != nil && next == *cursor) {
+ return fmt.Errorf("invalid owners pagination cursor; state retained")
+ }
+ cursor = new(next)
+ }
+ displayName := organization.GetName()
+ if displayName == organization.GetLogin() && data.Get("display_name").(string) == "" {
+ displayName = ""
+ }
+ for field, value := range map[string]any{
+ "name": organization.GetLogin(), "database_id": organization.GetID(), "billing_email": *organization.BillingEmail,
+ "display_name": displayName, "description": organization.GetDescription(),
+ "admin_logins": owners, "automation_installation_id": installationID,
+ } {
+ if err := data.Set(field, value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) importOrganization(ctx context.Context, data *schema.ResourceData, owner *Owner) ([]*schema.ResourceData, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+ defer cancel()
+ parts := strings.Split(data.Id(), "/")
+ if len(parts) != 2 || !strings.EqualFold(parts[0], lifecycle.config.slug) || parts[1] == "" {
+ return nil, fmt.Errorf("import ID must be /")
+ }
+ enterpriseID, err := getEnterpriseID(ctx, owner.v4client, parts[0])
+ if err != nil {
+ return nil, err
+ }
+ if enterpriseID != lifecycle.enterpriseID {
+ return nil, fmt.Errorf("enterprise identity changed; import blocked")
+ }
+ inventory, err := lifecycle.inventoryOrganization(ctx, parts[1])
+ if err != nil {
+ return nil, err
+ }
+ rest, _, _, err := lifecycle.organizationClients(ctx, parts[1], inventory.ID)
+ if err != nil {
+ return nil, err
+ }
+ organization, _, err := rest.Organizations.Get(ctx, parts[1])
+ if err != nil {
+ return nil, err
+ }
+ if organization.GetID() != inventory.ID || organization.GetNodeID() == "" || !strings.EqualFold(organization.GetLogin(), parts[1]) {
+ return nil, fmt.Errorf("organization identity mismatch; import blocked")
+ }
+ for field, value := range map[string]any{"enterprise_id": enterpriseID, "name": organization.GetLogin(), "database_id": organization.GetID()} {
+ if err := data.Set(field, value); err != nil {
+ return nil, err
+ }
+ }
+ data.SetId(organization.GetNodeID())
+ if err := lifecycle.read(ctx, data); err != nil {
+ return nil, err
+ }
+ return []*schema.ResourceData{data}, nil
+}
+
+func (lifecycle *enterpriseAppLifecycle) create(ctx context.Context, data *schema.ResourceData, owner *Owner) error {
+ if err := lifecycle.checkEnterprise(data); err != nil {
+ return err
+ }
+ permissions, err := lifecycle.validateAutomationApp(ctx)
+ if err != nil {
+ return err
+ }
+ if !maps.Equal(permissions, lifecycle.automationPermissions) {
+ return fmt.Errorf("automation App requested permissions changed since provider configuration; re-plan before creation")
+ }
+ installerID, err := positiveAppID(lifecycle.config.installerInstallationID, "installer_installation_id")
+ if err != nil {
+ return err
+ }
+ if err := lifecycle.validateEnterpriseInstallation(ctx, lifecycle.installerJWT, lifecycle.config.installerID, installerID, "enterprise_organization_installations"); err != nil {
+ return err
+ }
+ var organizations []enterpriseInventoryOrganization
+ if _, err := enterpriseAppRequest(ctx, lifecycle.installer, http.MethodGet, "enterprises/"+url.PathEscape(lifecycle.config.slug)+"/apps/installable_organizations?per_page=1", nil, &organizations); err != nil {
+ return fmt.Errorf("installer cannot access enterprise inventory; creation blocked: %w", err)
+ }
+ var mutation struct {
+ CreateEnterpriseOrganization struct {
+ Organization struct {
+ ID githubv4.ID
+ DatabaseID githubv4.Int
+ }
+ } `graphql:"createEnterpriseOrganization(input:$input)"`
+ }
+ var admins []githubv4.String
+ for _, login := range data.Get("admin_logins").(*schema.Set).List() {
+ admins = append(admins, githubv4.String(login.(string)))
+ }
+ err = owner.v4client.Mutate(ctx, &mutation, githubv4.CreateEnterpriseOrganizationInput{
+ EnterpriseID: lifecycle.enterpriseID, Login: githubv4.String(data.Get("name").(string)),
+ ProfileName: githubv4.String(data.Get("name").(string)), BillingEmail: githubv4.String(data.Get("billing_email").(string)), AdminLogins: admins,
+ }, nil)
+ created := mutation.CreateEnterpriseOrganization.Organization
+ if nodeID, ok := created.ID.(string); ok && nodeID != "" {
+ data.SetId(nodeID)
+ if setErr := data.Set("database_id", int(created.DatabaseID)); setErr != nil {
+ return setErr
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("enterprise organization creation failed or has an ambiguous outcome; do not retry blindly, check enterprise inventory and import %s/%s if created: %w", lifecycle.config.slug, data.Get("name"), err)
+ }
+ if data.Id() == "" || created.DatabaseID <= 0 {
+ return fmt.Errorf("creation returned incomplete organization identity; check enterprise inventory and import %s/%s before retrying", lifecycle.config.slug, data.Get("name"))
+ }
+ if err := lifecycle.bootstrap(ctx, data); err != nil {
+ return fmt.Errorf("organization %s/%s was created (ID %s) but bootstrap failed; ID retained. Correct App permissions/installation, then import %s/%s if Terraform did not persist state; do not recreate: %w", lifecycle.config.slug, data.Get("name"), data.Id(), lifecycle.config.slug, data.Get("name"), err)
+ }
+ return nil
+}
+
+func propagationWait(ctx context.Context, attempt int) error {
+ delay := min(time.Second*time.Duration(1< page {
+ page = response.NextPage - 1
+ }
+ }
+}
+
+func (lifecycle *enterpriseAppLifecycle) bootstrap(ctx context.Context, data *schema.ResourceData) error {
+ name := data.Get("name").(string)
+ var inventory *enterpriseInventoryOrganization
+ var err error
+ for attempt := range 8 {
+ inventory, err = lifecycle.inventoryOrganization(ctx, name)
+ if err == nil {
+ break
+ }
+ if !enterprisePropagationError(err) || attempt == 7 {
+ return err
+ }
+ if waitErr := propagationWait(ctx, attempt); waitErr != nil {
+ return waitErr
+ }
+ }
+ if err != nil {
+ return err
+ }
+ if inventory.ID != int64(data.Get("database_id").(int)) {
+ return fmt.Errorf("created organization does not match enterprise inventory; installation blocked")
+ }
+ _, missing, err := lifecycle.installation(ctx, name)
+ if err != nil {
+ return err
+ }
+ if missing {
+ listed, err := lifecycle.enterpriseListsAutomationInstallation(ctx, name)
+ if err != nil {
+ return err
+ }
+ if listed {
+ return fmt.Errorf("enterprise already lists the automation App installation but the App cannot access it; inspect suspension, grants and propagation externally, no installation POST or upgrade was attempted")
+ }
+ body := struct {
+ ClientID string `json:"client_id"`
+ RepositorySelection string `json:"repository_selection"`
+ }{lifecycle.config.automationClientID, "none"}
+ path := "enterprises/" + url.PathEscape(lifecycle.config.slug) + "/apps/organizations/" + url.PathEscape(name) + "/installations"
+ if _, err := enterpriseAppRequest(ctx, lifecycle.installer, http.MethodPost, path, body, nil); err != nil {
+ return fmt.Errorf("installation request failed or outcome is ambiguous; inspect installation before retrying (no automatic POST retry): %w", err)
+ }
+ }
+ var rest *github.Client
+ for attempt := range 8 {
+ var installationID int64
+ rest, _, installationID, err = lifecycle.organizationClients(ctx, name, inventory.ID)
+ if err == nil {
+ organization, _, getErr := rest.Organizations.Get(ctx, name)
+ err = getErr
+ if err == nil {
+ if organization.GetNodeID() != data.Id() || organization.GetID() != inventory.ID {
+ return fmt.Errorf("automation organization identity mismatch; profile changes blocked")
+ }
+ if err := data.Set("automation_installation_id", installationID); err != nil {
+ return err
+ }
+ break
+ }
+ }
+ if !enterprisePropagationError(err) || attempt == 7 {
+ return err
+ }
+ if waitErr := propagationWait(ctx, attempt); waitErr != nil {
+ return waitErr
+ }
+ }
+ if err != nil {
+ return err
+ }
+ if data.Get("description").(string) != "" || data.Get("display_name").(string) != "" {
+ profile := &github.Organization{}
+ if description := data.Get("description").(string); description != "" {
+ profile.Description = new(description)
+ }
+ if displayName := data.Get("display_name").(string); displayName != "" {
+ profile.Name = new(displayName)
+ }
+ _, _, err = rest.Organizations.Edit(ctx, name, profile)
+ }
+ return err
+}
diff --git a/github/enterprise_app_lifecycle_test.go b/github/enterprise_app_lifecycle_test.go
new file mode 100644
index 0000000000..0362b30a20
--- /dev/null
+++ b/github/enterprise_app_lifecycle_test.go
@@ -0,0 +1,442 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/google/go-github/v92/github"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/shurcooL/githubv4"
+)
+
+type enterpriseLifecycleFixture struct {
+ lifecycle *enterpriseAppLifecycle
+ owner *Owner
+ data *schema.ResourceData
+ billing any
+ orgNode string
+ inventoryID int
+ installationID int
+ suspended any
+ permission string
+ installerPermission string
+ missing bool
+ listed bool
+ listingStatus int
+ listingPages int
+ listingMalformed bool
+ deny bool
+ installPosts int
+ profileWrites int
+ ownerPages int
+ inventoryPages int
+ mutations int
+}
+
+func newEnterpriseLifecycleFixture(t *testing.T) *enterpriseLifecycleFixture {
+ t.Helper()
+ fixture := &enterpriseLifecycleFixture{billing: "billing@example.test", orgNode: "O_test", inventoryID: 42, installationID: 51, permission: "write", installerPermission: "write"}
+ config := &enterpriseAppConfig{slug: "test-enterprise", installerID: "11", installerInstallationID: "12", automationID: "21", automationClientID: "test-client"}
+ fixture.lifecycle = &enterpriseAppLifecycle{config: config, enterpriseID: "E_test", automationPermissions: map[string]string{"organization_administration": "write", "members": "read"}}
+ handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ write := func(value any) {
+ if err := json.NewEncoder(writer).Encode(value); err != nil {
+ t.Error(err)
+ }
+ }
+ switch {
+ case strings.HasPrefix(request.URL.Path, "/users/"):
+ t.Error("unexpected owner discovery")
+ writer.WriteHeader(http.StatusInternalServerError)
+ case request.URL.Path == "/app":
+ if request.Header.Get("X-Test-Identity") == "installer" {
+ write(map[string]any{"id": 11, "client_id": "installer-client"})
+ } else {
+ write(map[string]any{"id": 21, "client_id": "test-client", "permissions": map[string]string{"organization_administration": "write", "members": "read"}})
+ }
+ case request.URL.Path == "/app/installations/12":
+ write(map[string]any{"id": 12, "app_id": 11, "target_type": "Enterprise", "account": map[string]any{"slug": "test-enterprise"}, "permissions": map[string]string{"enterprise_organization_installations": fixture.installerPermission}})
+ case strings.Contains(request.URL.Path, "installable_organizations"):
+ fixture.inventoryPages++
+ if fixture.deny {
+ writer.WriteHeader(http.StatusForbidden)
+ write(map[string]string{"message": "denied"})
+ return
+ }
+ if request.URL.Query().Get("page") == "1" {
+ writer.Header().Set("Link", fmt.Sprintf("; rel=\"next\"", request.Host, request.URL.Path))
+ write([]any{map[string]any{"id": 99, "login": "other-org"}})
+ } else {
+ write([]any{map[string]any{"id": fixture.inventoryID, "login": "test-org"}})
+ }
+ case request.URL.Path == "/orgs/test-org/installation":
+ if fixture.missing {
+ writer.WriteHeader(http.StatusNotFound)
+ write(map[string]string{"message": "Not Found"})
+ return
+ }
+ write(map[string]any{
+ "id": fixture.installationID, "app_id": 21, "account": map[string]any{"id": 42, "login": "test-org"}, "suspended_at": fixture.suspended,
+ "permissions": map[string]string{"organization_administration": fixture.permission, "members": "read"},
+ })
+ case request.URL.Path == "/enterprises/test-enterprise/apps/organizations/test-org/installations" && request.Method == http.MethodGet:
+ fixture.listingPages++
+ if fixture.listingStatus != 0 {
+ writer.WriteHeader(fixture.listingStatus)
+ write(map[string]string{"message": "listing unavailable"})
+ return
+ }
+ if fixture.listingMalformed {
+ write([]any{map[string]any{"id": 51}})
+ return
+ }
+ if request.URL.Query().Get("page") == "1" {
+ writer.Header().Set("Link", fmt.Sprintf("; rel=\"next\"", request.Host, request.URL.Path))
+ write([]any{map[string]any{"id": 70, "client_id": "unrelated-client"}})
+ } else if fixture.listed {
+ write([]any{map[string]any{"id": 51, "client_id": "test-client", "permissions": map[string]string{"organization_administration": fixture.permission}}})
+ } else {
+ write([]any{})
+ }
+ case request.URL.Path == "/enterprises/test-enterprise/apps/organizations/test-org/installations" && request.Method == http.MethodPost:
+ fixture.installPosts++
+ var body map[string]any
+ if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ if body["client_id"] != "test-client" || body["repository_selection"] != "none" {
+ t.Errorf("unsafe install payload: %v", body)
+ }
+ fixture.missing = false
+ write(map[string]any{"id": 51})
+ case request.URL.Path == "/orgs/test-org":
+ if request.Method == http.MethodPatch {
+ fixture.profileWrites++
+ }
+ write(map[string]any{"id": 42, "node_id": fixture.orgNode, "login": "test-org", "name": "Test Org", "description": "managed", "billing_email": fixture.billing})
+ case request.Method == http.MethodPost && (request.URL.Path == "/graphql" || request.URL.Path == "/"):
+ var body struct {
+ Query string
+ Variables map[string]any
+ }
+ if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ if strings.Contains(body.Query, "organizationBillingEmail") {
+ t.Error("forbidden billing query")
+ }
+ if strings.Contains(body.Query, "createEnterpriseOrganization") {
+ fixture.mutations++
+ write(map[string]any{"data": map[string]any{"createEnterpriseOrganization": map[string]any{"organization": map[string]any{"id": "O_test", "databaseId": 42}}}})
+ return
+ }
+ if strings.Contains(body.Query, "enterprise(") {
+ write(map[string]any{"data": map[string]any{"enterprise": map[string]string{"id": "E_test"}}})
+ return
+ }
+ fixture.ownerPages++
+ next := body.Variables["cursor"] == nil
+ login, role := "owner-one", "ADMIN"
+ if !next {
+ login = "owner-two"
+ }
+ write(map[string]any{"data": map[string]any{"organization": map[string]any{
+ "id": "O_test", "databaseId": 42, "login": "test-org", "membersWithRole": map[string]any{
+ "edges": []any{map[string]any{"node": map[string]string{"login": login}, "role": role}, map[string]any{"node": map[string]string{"login": "regular-member"}, "role": "MEMBER"}},
+ "pageInfo": map[string]any{"hasNextPage": next, "endCursor": "end"},
+ },
+ }}})
+ default:
+ t.Errorf("unexpected request: %s %s", request.Method, request.URL)
+ writer.WriteHeader(http.StatusInternalServerError)
+ }
+ })
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+ client := mustCreateTestGitHubClient(t, server.URL+"/")
+ fixture.lifecycle.installer = client
+ fixture.lifecycle.automationJWT = client
+ fixture.lifecycle.installerJWT = mustCreateTestGitHubClient(t, server.URL+"/", github.WithTransport(enterpriseTestIdentityTransport{base: http.DefaultTransport}))
+ graphql := githubv4.NewEnterpriseClient(server.URL+"/graphql", server.Client())
+ fixture.lifecycle.orgClients = func(int64) (*github.Client, *githubv4.Client, error) { return client, graphql, nil }
+ fixture.owner = &Owner{enterpriseApp: fixture.lifecycle, v3client: client, v4client: graphql}
+ fixture.data = schema.TestResourceDataRaw(t, resourceGithubEnterpriseOrganization().Schema, map[string]any{
+ "enterprise_id": "E_test", "name": "test-org", "billing_email": "old@example.test", "admin_logins": []any{"owner-one"}, "database_id": 42,
+ })
+ fixture.data.SetId("O_test")
+ return fixture
+}
+
+type enterpriseTestIdentityTransport struct{ base http.RoundTripper }
+
+func (transport enterpriseTestIdentityTransport) RoundTrip(request *http.Request) (*http.Response, error) {
+ copy := request.Clone(request.Context())
+ copy.Header.Set("X-Test-Identity", "installer")
+ return transport.base.RoundTrip(copy)
+}
+
+func TestEnterpriseAppLifecycleReadImport(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ if diagnostics := enterpriseOrganizationReadContext(t.Context(), fixture.data, fixture.owner); diagnostics.HasError() {
+ t.Fatal(diagnostics)
+ }
+ if fixture.data.Get("billing_email") != "billing@example.test" || fixture.data.Get("automation_installation_id") != 51 {
+ t.Fatal("REST billing/installation not populated")
+ }
+ owners := fixture.data.Get("admin_logins").(*schema.Set)
+ if owners.Len() != 2 || owners.Contains("regular-member") || fixture.ownerPages != 2 || fixture.inventoryPages != 2 {
+ t.Fatalf("pagination/owners filtering failed: %v", owners.List())
+ }
+ fixture.data.SetId("test-enterprise/test-org")
+ if _, err := enterpriseOrganizationImportContext(t.Context(), fixture.data, fixture.owner); err != nil {
+ t.Fatal(err)
+ }
+ if fixture.data.Id() != "O_test" || fixture.installPosts != 0 || fixture.profileWrites != 0 {
+ t.Fatal("import identity or write isolation failed")
+ }
+}
+
+func TestEnterpriseAppLifecycleReadRetainsState(t *testing.T) {
+ for _, scenario := range []string{"billing-null", "inventory-denied", "wrong-node", "wrong-database", "suspended", "missing-installation", "missing-permission", "wrong-enterprise"} {
+ t.Run(scenario, func(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ switch scenario {
+ case "billing-null":
+ fixture.billing = nil
+ case "inventory-denied":
+ fixture.deny = true
+ case "wrong-node":
+ fixture.orgNode = "O_wrong"
+ case "wrong-database":
+ fixture.inventoryID = 43
+ case "suspended":
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ case "missing-installation":
+ fixture.missing = true
+ case "missing-permission":
+ fixture.permission = "read"
+ case "wrong-enterprise":
+ if err := fixture.data.Set("enterprise_id", "E_wrong"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := fixture.lifecycle.read(t.Context(), fixture.data); err == nil {
+ t.Fatal("expected read failure")
+ }
+ if fixture.data.Id() != "O_test" || fixture.data.Get("billing_email") != "old@example.test" || fixture.installPosts != 0 {
+ t.Fatal("failure changed identity/billing or installed App")
+ }
+ })
+ }
+}
+
+func TestEnterpriseAppLifecycleBlockedOperations(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ if !enterpriseOrganizationDeleteContext(t.Context(), fixture.data, fixture.owner).HasError() {
+ t.Fatal("delete must be blocked")
+ }
+ if !enterpriseOrganizationUpdateContext(t.Context(), fixture.data, fixture.owner).HasError() {
+ t.Fatal("update must be blocked")
+ }
+ fixture.data.SetId("another-enterprise/test-org")
+ if _, err := enterpriseOrganizationImportContext(t.Context(), fixture.data, fixture.owner); err == nil {
+ t.Fatal("cross-enterprise import must fail")
+ }
+ if fixture.inventoryPages != 0 || fixture.installPosts != 0 {
+ t.Fatal("blocked operation made API requests")
+ }
+}
+
+func TestEnterpriseAppLifecycleBootstrap(t *testing.T) {
+ for _, scenario := range []string{"existing", "absent", "suspended", "mismatch", "denied"} {
+ t.Run(scenario, func(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.missing = scenario == "absent"
+ if scenario == "suspended" {
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ }
+ if scenario == "mismatch" {
+ fixture.inventoryID = 43
+ }
+ if scenario == "denied" {
+ fixture.deny = true
+ }
+ err := fixture.lifecycle.bootstrap(t.Context(), fixture.data)
+ failure := scenario == "suspended" || scenario == "mismatch" || scenario == "denied"
+ if (err != nil) != failure {
+ t.Fatalf("bootstrap error = %v", err)
+ }
+ expected := 0
+ if scenario == "absent" {
+ expected = 1
+ }
+ if fixture.installPosts != expected {
+ t.Fatalf("installation POST count = %d, want %d", fixture.installPosts, expected)
+ }
+ })
+ }
+}
+
+func TestEnterpriseAppLifecyclePartialCreate(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ fixture.data.SetId("")
+ err := fixture.lifecycle.create(t.Context(), fixture.data, fixture.owner)
+ if err == nil || !strings.Contains(err.Error(), "ID retained") {
+ t.Fatalf("expected actionable partial-create error: %v", err)
+ }
+ if fixture.data.Id() != "O_test" || fixture.data.Get("database_id") != 42 || fixture.mutations != 1 || fixture.installPosts != 0 {
+ t.Fatal("partial-create identity lost or suspended installation changed")
+ }
+}
+
+func TestEnterpriseAppLifecycleCreate(t *testing.T) {
+ for _, missing := range []bool{false, true} {
+ t.Run(fmt.Sprintf("missing-installation-%t", missing), func(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.missing = missing
+ fixture.data.SetId("")
+ if err := fixture.lifecycle.create(t.Context(), fixture.data, fixture.owner); err != nil {
+ t.Fatal(err)
+ }
+ if fixture.data.Id() != "O_test" || fixture.data.Get("database_id") != 42 || fixture.data.Get("automation_installation_id") != 51 || fixture.mutations != 1 {
+ t.Fatal("create did not preserve complete organization/installation identity")
+ }
+ expectedPosts := 0
+ if missing {
+ expectedPosts = 1
+ }
+ if fixture.installPosts != expectedPosts {
+ t.Fatalf("installation POSTs = %d, want %d", fixture.installPosts, expectedPosts)
+ }
+ })
+ }
+}
+
+func TestEnterpriseAppLifecycleStaleInstallationPermissions(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.lifecycle.automationPermissions["organization_custom_roles"] = "write"
+ err := fixture.lifecycle.bootstrap(t.Context(), fixture.data)
+ if err == nil || !strings.Contains(err.Error(), "no installation upgrade was attempted") || fixture.installPosts != 0 {
+ t.Fatalf("stale grants were not rejected without an upgrade: %v", err)
+ }
+}
+
+func TestEnterpriseAppLifecyclePermissionsAndCancellation(t *testing.T) {
+ if checkAutomationPermissions(map[string]string{"organization_administration": "write", "members": "read", "contents": "read"}) == nil {
+ t.Fatal("repository permissions must fail")
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if propagationWait(ctx, 0) == nil {
+ t.Fatal("propagation must respect cancellation")
+ }
+}
+
+func TestEnterpriseAppLifecycleEnterpriseIdentity(t *testing.T) {
+ for _, scenario := range []struct {
+ name string
+ accountSlug string
+ enterpriseSlug string
+ login string
+ targetType string
+ appID int
+ installationID int
+ suspended any
+ issuer string
+ wantError bool
+ }{
+ {name: "live-account-slug", accountSlug: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11"},
+ {name: "case-insensitive-client-id", accountSlug: "TEST-ENTERPRISE", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "installer-client"},
+ {name: "enterprise-slug", enterpriseSlug: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11"},
+ {name: "legacy-login", login: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11"},
+ {name: "wrong-slug", accountSlug: "other-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11", wantError: true},
+ {name: "conflicting-legacy-identity", accountSlug: "other-enterprise", enterpriseSlug: "test-enterprise", login: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11", wantError: true},
+ {name: "missing-slug", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "11", wantError: true},
+ {name: "wrong-target", accountSlug: "test-enterprise", targetType: "Organization", appID: 11, installationID: 12, issuer: "11", wantError: true},
+ {name: "wrong-app", accountSlug: "test-enterprise", targetType: "Enterprise", appID: 22, installationID: 12, issuer: "11", wantError: true},
+ {name: "wrong-installation", accountSlug: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 13, issuer: "11", wantError: true},
+ {name: "suspended", accountSlug: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, suspended: "2026-01-01T00:00:00Z", issuer: "11", wantError: true},
+ {name: "wrong-issuer", accountSlug: "test-enterprise", targetType: "Enterprise", appID: 11, installationID: 12, issuer: "22", wantError: true},
+ } {
+ t.Run(scenario.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ var body any
+ switch request.URL.Path {
+ case "/app":
+ body = map[string]any{"id": 11, "client_id": "installer-client"}
+ case "/app/installations/12":
+ body = map[string]any{"id": scenario.installationID, "app_id": scenario.appID, "target_type": scenario.targetType, "suspended_at": scenario.suspended,
+ "account": map[string]string{"slug": scenario.accountSlug, "login": scenario.login}, "enterprise": map[string]string{"slug": scenario.enterpriseSlug}}
+ default:
+ t.Errorf("unexpected request: %s %s", request.Method, request.URL)
+ }
+ if err := json.NewEncoder(writer).Encode(body); err != nil {
+ t.Error(err)
+ }
+ }))
+ t.Cleanup(server.Close)
+ lifecycle := &enterpriseAppLifecycle{config: &enterpriseAppConfig{slug: "test-enterprise"}}
+ err := lifecycle.validateEnterpriseInstallation(t.Context(), mustCreateTestGitHubClient(t, server.URL+"/"), scenario.issuer, 12, "")
+ if (err != nil) != scenario.wantError {
+ t.Fatalf("identity validation error = %v, want error %t", err, scenario.wantError)
+ }
+ })
+ }
+}
+
+func TestEnterpriseAppLifecycleInstallerPermissionBeforeCreate(t *testing.T) {
+ for _, permission := range []string{"", "read", "none", "write"} {
+ t.Run("grant-"+permission, func(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.installerPermission = permission
+ fixture.data.SetId("")
+ err := fixture.lifecycle.create(t.Context(), fixture.data, fixture.owner)
+ if permission == "write" {
+ if err != nil || fixture.mutations != 1 {
+ t.Fatalf("write grant blocked creation: %v", err)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), "enterprise_organization_installations=write") || fixture.mutations != 0 || fixture.installPosts != 0 || fixture.data.Id() != "" {
+ t.Fatalf("insufficient installer grant did not block before creation: %v", err)
+ }
+ })
+ }
+}
+
+func TestEnterpriseAppLifecycleBootstrapListingGuard(t *testing.T) {
+ for _, scenario := range []string{"listed", "listed-suspended", "listed-stale-grants", "denied", "not-found", "malformed"} {
+ t.Run(scenario, func(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.missing = true
+ fixture.listed = true
+ switch scenario {
+ case "listed-suspended":
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ case "listed-stale-grants":
+ fixture.permission = "read"
+ case "denied":
+ fixture.listingStatus = http.StatusForbidden
+ case "not-found":
+ fixture.listingStatus = http.StatusNotFound
+ case "malformed":
+ fixture.listingMalformed = true
+ }
+ err := fixture.lifecycle.bootstrap(t.Context(), fixture.data)
+ if err == nil || fixture.installPosts != 0 || fixture.profileWrites != 0 || fixture.data.Id() != "O_test" {
+ t.Fatalf("listing guard did not block duplicate installation without losing state: %v", err)
+ }
+ if strings.HasPrefix(scenario, "listed") && (fixture.listingPages != 2 || !strings.Contains(err.Error(), "no installation POST or upgrade was attempted")) {
+ t.Fatalf("paginated client_id match not detected: pages=%d, error=%v", fixture.listingPages, err)
+ }
+ })
+ }
+}
diff --git a/github/enterprise_app_protocol_test.go b/github/enterprise_app_protocol_test.go
new file mode 100644
index 0000000000..623d3b14c7
--- /dev/null
+++ b/github/enterprise_app_protocol_test.go
@@ -0,0 +1,74 @@
+package github
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/go-cty/cty"
+ "github.com/hashicorp/go-cty/cty/msgpack"
+ "github.com/hashicorp/terraform-plugin-go/tfprotov5"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func TestEnterpriseAppPartialCreateProtocolState(t *testing.T) {
+ fixture := newEnterpriseLifecycleFixture(t)
+ fixture.suspended = "2026-01-01T00:00:00Z"
+ resource := resourceGithubEnterpriseOrganization()
+ provider := &schema.Provider{ResourcesMap: map[string]*schema.Resource{"github_enterprise_organization": resource}}
+ provider.SetMeta(fixture.owner)
+ server := schema.NewGRPCProviderServer(provider)
+ resourceType := resource.CoreConfigSchema().ImpliedType()
+ attributes := map[string]cty.Value{}
+ for name, attributeType := range resourceType.AttributeTypes() {
+ attributes[name] = cty.NullVal(attributeType)
+ }
+ attributes["enterprise_id"] = cty.StringVal("E_test")
+ attributes["name"] = cty.StringVal("test-org")
+ attributes["billing_email"] = cty.StringVal("billing@example.test")
+ attributes["admin_logins"] = cty.SetVal([]cty.Value{cty.StringVal("owner-one")})
+ encode := func(value cty.Value) *tfprotov5.DynamicValue {
+ encoded, err := msgpack.Marshal(value, resourceType)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &tfprotov5.DynamicValue{MsgPack: encoded}
+ }
+ config := encode(cty.ObjectVal(attributes))
+ prior := encode(cty.NullVal(resourceType))
+ plan, err := server.PlanResourceChange(t.Context(), &tfprotov5.PlanResourceChangeRequest{
+ TypeName: "github_enterprise_organization", PriorState: prior, ProposedNewState: config, Config: config,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, diagnostic := range plan.Diagnostics {
+ if diagnostic.Severity == tfprotov5.DiagnosticSeverityError {
+ t.Fatal(diagnostic)
+ }
+ }
+ response, err := server.ApplyResourceChange(t.Context(), &tfprotov5.ApplyResourceChangeRequest{
+ TypeName: "github_enterprise_organization", PriorState: prior, PlannedState: plan.PlannedState, Config: config, PlannedPrivate: plan.PlannedPrivate,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ failed := false
+ for _, diagnostic := range response.Diagnostics {
+ if diagnostic.Severity == tfprotov5.DiagnosticSeverityError && strings.Contains(diagnostic.Summary+diagnostic.Detail, "ID retained") {
+ failed = true
+ }
+ }
+ if !failed || response.NewState == nil {
+ t.Fatalf("missing recovery error/state: %v", response.Diagnostics)
+ }
+ state, err := msgpack.Unmarshal(response.NewState.MsgPack, resourceType)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.GetAttr("id").AsString() != "O_test" || !state.GetAttr("database_id").RawEquals(cty.NumberIntVal(42)) {
+ t.Fatalf("SDK discarded partial-create identity: %s", state.GoString())
+ }
+ if fixture.mutations != 1 || fixture.installPosts != 0 {
+ t.Fatal("unexpected create/install count")
+ }
+}
diff --git a/github/provider.go b/github/provider.go
index c391d8454c..4718ad06c2 100644
--- a/github/provider.go
+++ b/github/provider.go
@@ -34,6 +34,7 @@ func NewProvider(version, commit string) func() *schema.Provider {
return func() *schema.Provider {
return &schema.Provider{
Schema: map[string]*schema.Schema{
+ "enterprise_app_auth": enterpriseAppAuthSchema(),
"base_url": {
Type: schema.TypeString,
Optional: true,
@@ -365,6 +366,10 @@ func configureProvider(version, commit string) func(context.Context, *schema.Res
config := &Config{
BaseURL: baseURL,
}
+ config.EnterpriseApp, err = parseEnterpriseAppAuth(d)
+ if err != nil {
+ return nil, diag.FromErr(err)
+ }
if v, ok := d.GetOk("legacy_client"); ok {
if b, ok := v.(bool); ok {
@@ -421,7 +426,7 @@ func configureProvider(version, commit string) func(context.Context, *schema.Res
config.AppPEM = appPEM
}
- if config.AppID != nil && config.Owner == "" {
+ if config.AppID != nil && config.Owner == "" && config.EnterpriseApp == nil {
return nil, diag.Errorf("owner must be set for github app authentication")
}
}
@@ -453,6 +458,14 @@ func configureProvider(version, commit string) func(context.Context, *schema.Res
}
}
+ if config.EnterpriseApp != nil {
+ tokenEnvName, _ := d.Get("token_env_name").(string)
+ if config.AppID == nil || authMode != "app" || d.Get("token").(string) != "" || os.Getenv(tokenEnvName) != "" {
+ return nil, diag.Errorf("enterprise_app_auth requires auth_mode = app, complete creation app_auth credentials, and no token attribute or token environment variable")
+ }
+ config.LegacyClient = false
+ }
+
if config.LegacyClient {
if config.AppID != nil {
pathSuffix := RESTAPIPath
@@ -569,6 +582,9 @@ func configureProvider(version, commit string) func(context.Context, *schema.Res
// configureProviderMeta initializes the provider metadata, including setting up the GitHub API clients based on the provided configuration. It returns the initialized metadata or an error if the configuration is invalid or if there are issues initializing the clients.
func configureProviderMeta(ctx context.Context, version string, c *Config) (*Owner, error) {
+ if c.EnterpriseApp != nil {
+ return configureEnterpriseApp(ctx, version, c)
+ }
owner := &Owner{
name: c.Owner,
maxPerPage: c.MaxPerPage,
diff --git a/github/resource_github_enterprise_organization.go b/github/resource_github_enterprise_organization.go
index 97457609d5..3685071e28 100644
--- a/github/resource_github_enterprise_organization.go
+++ b/github/resource_github_enterprise_organization.go
@@ -6,8 +6,10 @@ import (
"fmt"
"log"
"strings"
+ "time"
"github.com/google/go-github/v92/github"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/shurcooL/githubv4"
)
@@ -26,14 +28,23 @@ func isSAMLEnforcementError(err error) bool {
func resourceGithubEnterpriseOrganization() *schema.Resource {
return &schema.Resource{
- Create: resourceGithubEnterpriseOrganizationCreate,
- Read: resourceGithubEnterpriseOrganizationRead,
- Delete: resourceGithubEnterpriseOrganizationDelete,
- Update: resourceGithubEnterpriseOrganizationUpdate,
+ CreateContext: enterpriseOrganizationCreateContext,
+ ReadContext: enterpriseOrganizationReadContext,
+ DeleteContext: enterpriseOrganizationDeleteContext,
+ UpdateContext: enterpriseOrganizationUpdateContext,
+ Timeouts: &schema.ResourceTimeout{
+ Create: schema.DefaultTimeout(10 * time.Minute),
+ Read: schema.DefaultTimeout(5 * time.Minute),
+ },
Importer: &schema.ResourceImporter{
- State: resourceGithubEnterpriseOrganizationImport,
+ StateContext: enterpriseOrganizationImportContext,
},
Schema: map[string]*schema.Schema{
+ "automation_installation_id": {
+ Type: schema.TypeInt,
+ Computed: true,
+ Description: "Automation App installation ID discovered in opt-in enterprise_app_auth mode. Not populated in legacy mode.",
+ },
"enterprise_id": {
Type: schema.TypeString,
Required: true,
@@ -83,6 +94,41 @@ func resourceGithubEnterpriseOrganization() *schema.Resource {
}
}
+func enterpriseOrganizationCreateContext(ctx context.Context, data *schema.ResourceData, meta any) diag.Diagnostics {
+ if owner := meta.(*Owner); owner.enterpriseApp != nil {
+ return diag.FromErr(owner.enterpriseApp.create(ctx, data, owner))
+ }
+ return diag.FromErr(resourceGithubEnterpriseOrganizationCreate(data, meta))
+}
+
+func enterpriseOrganizationReadContext(ctx context.Context, data *schema.ResourceData, meta any) diag.Diagnostics {
+ if owner := meta.(*Owner); owner.enterpriseApp != nil {
+ return diag.FromErr(owner.enterpriseApp.read(ctx, data))
+ }
+ return diag.FromErr(resourceGithubEnterpriseOrganizationRead(data, meta))
+}
+
+func enterpriseOrganizationUpdateContext(_ context.Context, data *schema.ResourceData, meta any) diag.Diagnostics {
+ if meta.(*Owner).enterpriseApp != nil {
+ return diag.Errorf("enterprise_app_auth organization updates are not supported; no API request was made. Restore the previous configuration or manage the change outside this resource")
+ }
+ return diag.FromErr(resourceGithubEnterpriseOrganizationUpdate(data, meta))
+}
+
+func enterpriseOrganizationDeleteContext(_ context.Context, data *schema.ResourceData, meta any) diag.Diagnostics {
+ if meta.(*Owner).enterpriseApp != nil {
+ return diag.Errorf("enterprise_app_auth organization deletion is blocked pending an approved permission and API contract; no API request was made. Keep the resource or explicitly remove it from Terraform management without destroying the organization")
+ }
+ return diag.FromErr(resourceGithubEnterpriseOrganizationDelete(data, meta))
+}
+
+func enterpriseOrganizationImportContext(ctx context.Context, data *schema.ResourceData, meta any) ([]*schema.ResourceData, error) {
+ if owner := meta.(*Owner); owner.enterpriseApp != nil {
+ return owner.enterpriseApp.importOrganization(ctx, data, owner)
+ }
+ return resourceGithubEnterpriseOrganizationImport(data, meta)
+}
+
func resourceGithubEnterpriseOrganizationCreate(data *schema.ResourceData, m any) error {
meta, _ := m.(*Owner)
var mutate struct {
diff --git a/github/util_v4.go b/github/util_v4.go
index 9be0eacf8f..8fa3c22ef5 100644
--- a/github/util_v4.go
+++ b/github/util_v4.go
@@ -2,6 +2,7 @@ package github
import (
"context"
+ "fmt"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/shurcooL/githubv4"
@@ -65,5 +66,9 @@ func getEnterpriseID(ctx context.Context, client *githubv4.Client, enterpriseSlu
return "", err
}
- return query.Enterprise.ID.(string), nil
+ enterpriseID, ok := query.Enterprise.ID.(string)
+ if !ok || enterpriseID == "" {
+ return "", fmt.Errorf("enterprise %q returned no node ID", enterpriseSlug)
+ }
+ return enterpriseID, nil
}
diff --git a/internal/ghclient/graphql.go b/internal/ghclient/graphql.go
index 3e2dd3ab85..408849f22d 100644
--- a/internal/ghclient/graphql.go
+++ b/internal/ghclient/graphql.go
@@ -31,7 +31,7 @@ func NewAppGraphQLClient(clientID string, privateKey []byte, installationID *int
authOpts = append(authOpts, githubauth.WithBaseURL(*u))
}
- tokenSource = githubauth.NewInstallationTokenSource(*installationID, tokenSource, authOpts...)
+ tokenSource = newInstallationTokenSource(*installationID, tokenSource, opts, authOpts...)
}
return newGraphQLClient(tokenSource, opts)
diff --git a/internal/ghclient/installation.go b/internal/ghclient/installation.go
new file mode 100644
index 0000000000..0490c76e90
--- /dev/null
+++ b/internal/ghclient/installation.go
@@ -0,0 +1,77 @@
+package ghclient
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/jferrl/go-githubauth"
+ "golang.org/x/oauth2"
+)
+
+const installationTokenTimeout = 30 * time.Second
+
+type installationTokenSource struct {
+ id int64
+ app oauth2.TokenSource
+ client *http.Client
+ opts []githubauth.InstallationTokenSourceOpt
+ gate chan struct{}
+ token *oauth2.Token
+}
+
+func newInstallationTokenSource(id int64, app oauth2.TokenSource, opts ClientOptions, authOpts ...githubauth.InstallationTokenSourceOpt) *installationTokenSource {
+ return &installationTokenSource{
+ id: id, app: app, opts: authOpts,
+ client: &http.Client{Transport: getHTTPTransport(http.DefaultTransport, opts), Timeout: installationTokenTimeout},
+ gate: make(chan struct{}, 1),
+ }
+}
+
+func (source *installationTokenSource) Token() (*oauth2.Token, error) {
+ return source.TokenContext(context.Background())
+}
+
+func (source *installationTokenSource) TokenContext(ctx context.Context) (*oauth2.Token, error) {
+ ctx, cancel := context.WithTimeout(ctx, source.client.Timeout)
+ defer cancel()
+
+ select {
+ case source.gate <- struct{}{}:
+ defer func() { <-source.gate }()
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if source.token != nil && source.token.AccessToken != "" && (source.token.Expiry.IsZero() || time.Until(source.token.Expiry) > githubauth.DefaultExpirySkew) {
+ return source.token, nil
+ }
+
+ authOpts := append([]githubauth.InstallationTokenSourceOpt{}, source.opts...)
+ authOpts = append(authOpts, githubauth.WithHTTPClient(source.client), githubauth.WithContext(ctx))
+ token, err := githubauth.NewInstallationTokenSource(source.id, source.app, authOpts...).Token()
+ if err != nil {
+ return nil, err
+ }
+ source.token = token
+ return token, nil
+}
+
+type installationTransport struct {
+ base http.RoundTripper
+ source *installationTokenSource
+}
+
+func (transport *installationTransport) RoundTrip(request *http.Request) (*http.Response, error) {
+ token, err := transport.source.TokenContext(request.Context())
+ if err != nil {
+ if request.Body != nil {
+ request.Body.Close()
+ }
+ return nil, err
+ }
+ auth := &oauth2.Transport{Base: transport.base, Source: oauth2.StaticTokenSource(token)}
+ return auth.RoundTrip(request)
+}
diff --git a/internal/ghclient/installation_test.go b/internal/ghclient/installation_test.go
new file mode 100644
index 0000000000..554a2f0208
--- /dev/null
+++ b/internal/ghclient/installation_test.go
@@ -0,0 +1,193 @@
+package ghclient
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/jferrl/go-githubauth"
+ "github.com/shurcooL/githubv4"
+ "golang.org/x/oauth2"
+)
+
+func TestInstallationTokenStalledBody(t *testing.T) {
+ t.Parallel()
+ flushed := make(chan struct{})
+ stopped := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusCreated)
+ writer.(http.Flusher).Flush()
+ close(flushed)
+ select {
+ case <-request.Context().Done():
+ case <-stopped:
+ }
+ }))
+ defer server.Close()
+ defer close(stopped)
+ source := newInstallationTokenSource(42, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-jwt"}), ClientOptions{}, githubauth.WithBaseURL(server.URL))
+ if source.client.Timeout != installationTokenTimeout || source.client.Timeout <= 0 {
+ t.Fatal("installation token HTTP client must have a finite total timeout")
+ }
+ source.client.Timeout = 100 * time.Millisecond
+ result := make(chan error, 1)
+ go func() {
+ _, err := source.Token()
+ result <- err
+ }()
+ select {
+ case <-flushed:
+ case <-time.After(2 * time.Second):
+ t.Fatal("token request did not reach flushed headers")
+ }
+ select {
+ case err := <-result:
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected total timeout reading stalled body, got %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("token body read exceeded total timeout")
+ }
+}
+
+func TestAppInstallationOperationCancellationAndRenewal(t *testing.T) {
+ t.Parallel()
+ for _, protocol := range []string{"REST", "GraphQL"} {
+ for _, isGHES := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/GHES=%t", protocol, isGHES), func(t *testing.T) {
+ t.Parallel()
+ var tokenPosts atomic.Int32
+ flushed := make(chan struct{})
+ disconnected := make(chan struct{})
+ stopped := make(chan struct{})
+ tokenPath := "/app/installations/42/access_tokens"
+ if isGHES {
+ tokenPath = "/api/v3" + tokenPath
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ if request.URL.Path == tokenPath {
+ if request.Method != http.MethodPost {
+ t.Errorf("token method = %s, want POST", request.Method)
+ }
+ attempt := tokenPosts.Add(1)
+ if attempt == 1 {
+ writer.WriteHeader(http.StatusCreated)
+ writer.(http.Flusher).Flush()
+ close(flushed)
+ select {
+ case <-request.Context().Done():
+ close(disconnected)
+ case <-stopped:
+ }
+ return
+ }
+ expiry := time.Now().Add(time.Hour)
+ if attempt == 2 {
+ expiry = time.Now().Add(10 * time.Second)
+ }
+ fmt.Fprintf(writer, `{"token":"token-%d","expires_at":%q}`, attempt, expiry.UTC().Format(time.RFC3339))
+ return
+ }
+ apiPath := "/user"
+ if isGHES {
+ apiPath = "/api/v3/user"
+ }
+ if protocol == "GraphQL" {
+ apiPath = "/graphql"
+ if isGHES {
+ apiPath = "/api/graphql"
+ }
+ }
+ if request.URL.Path != apiPath {
+ t.Errorf("unexpected API path %s", request.URL.Path)
+ http.NotFound(writer, request)
+ return
+ }
+ if got, want := request.Header.Get("Authorization"), fmt.Sprintf("Bearer token-%d", tokenPosts.Load()); got != want {
+ t.Errorf("API authorization = %q, want %q", got, want)
+ }
+ fmt.Fprint(writer, `{"data":{"viewer":{"login":"test-user"}}}`)
+ }))
+ defer server.Close()
+ defer close(stopped)
+ opts := ClientOptions{BaseURL: server.URL, IsGHES: isGHES}
+ var call func(context.Context) error
+ if protocol == "REST" {
+ client, err := NewAppRESTClient("123456789", mustReadAppPrivateKey(t), new(int64(42)), opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ call = func(ctx context.Context) error {
+ request, err := client.NewRequest(ctx, http.MethodGet, "user", nil)
+ if err != nil {
+ return err
+ }
+ _, err = client.Do(request, &struct{}{})
+ return err
+ }
+ } else {
+ client, err := NewAppGraphQLClient("123456789", mustReadAppPrivateKey(t), new(int64(42)), opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ call = func(ctx context.Context) error {
+ query := struct {
+ Viewer struct{ Login githubv4.String }
+ }{}
+ return client.Query(ctx, &query, nil)
+ }
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ result := make(chan error, 1)
+ go func() { result <- call(ctx) }()
+ select {
+ case <-flushed:
+ case <-time.After(2 * time.Second):
+ t.Fatal("token request did not flush headers")
+ }
+ waitingCtx, cancelWaiting := context.WithTimeout(t.Context(), 100*time.Millisecond)
+ defer cancelWaiting()
+ waiting := make(chan error, 1)
+ go func() { waiting <- call(waitingCtx) }()
+ select {
+ case err := <-waiting:
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("waiting caller: expected deadline, got %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("waiting caller ignored its deadline")
+ }
+ cancel()
+ select {
+ case err := <-result:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected operation cancellation, got %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("stalled token body ignored operation cancellation")
+ }
+ select {
+ case <-disconnected:
+ case <-time.After(2 * time.Second):
+ t.Fatal("token HTTP request survived operation cancellation")
+ }
+ for _, wantPosts := range []int32{2, 3, 3} {
+ if err := call(t.Context()); err != nil {
+ t.Fatalf("reusable client failed after cancellation: %v", err)
+ }
+ if got := tokenPosts.Load(); got != wantPosts {
+ t.Fatalf("token POSTs = %d, want %d (refresh then reuse)", got, wantPosts)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/internal/ghclient/rest.go b/internal/ghclient/rest.go
index 45b96012cd..848d1918cb 100644
--- a/internal/ghclient/rest.go
+++ b/internal/ghclient/rest.go
@@ -30,7 +30,7 @@ func NewAppRESTClient(clientID string, privateKey []byte, installationID *int64,
authOpts = append(authOpts, githubauth.WithBaseURL(*u))
}
- tokenSource = githubauth.NewInstallationTokenSource(*installationID, tokenSource, authOpts...)
+ tokenSource = newInstallationTokenSource(*installationID, tokenSource, opts, authOpts...)
}
return newRESTClient(tokenSource, opts)
diff --git a/internal/ghclient/transport.go b/internal/ghclient/transport.go
index 1cb6d57615..63fada1629 100644
--- a/internal/ghclient/transport.go
+++ b/internal/ghclient/transport.go
@@ -62,7 +62,9 @@ func newTransport(tokenSource oauth2.TokenSource, opts ClientOptions) (http.Roun
tr = ghct.NewTransport(store, tr)
}
- if tokenSource != nil {
+ if source, ok := tokenSource.(*installationTokenSource); ok {
+ tr = &installationTransport{base: tr, source: source}
+ } else if tokenSource != nil {
tr = &oauth2.Transport{
Base: tr,
Source: tokenSource,
diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl
index e8a800d363..45493b52cf 100644
--- a/templates/index.md.tmpl
+++ b/templates/index.md.tmpl
@@ -61,6 +61,63 @@ Some API operations may not be available when using a GitHub App installation co
{{ tffile "examples/provider/app_auth_mixed/main.tf" }}
+### Opt-In Enterprise App Lifecycle
+
+The optional `enterprise_app_auth` block enables an App-only path for
+`github_enterprise_organization`. It does not change default/PAT authentication
+or route other organization resources dynamically. Use a separate normally
+configured provider for repositories, teams, and other organization resources.
+This feature requires a provider build containing this opt-in implementation.
+
+Set `auth_mode = "app"`. The existing `app_auth` credentials are the enterprise
+creation App; its installation must target the configured enterprise. `owner`
+is not required or looked up in this mode. No PAT or GitHub CLI fallback is
+allowed: remove `token` and the environment variable selected by `token_env_name`
+(normally `GITHUB_TOKEN`). Clients renew installation tokens automatically.
+
+{{ tffile "examples/provider/enterprise_app_auth/main.tf" }}
+
+Supply the new block's credentials as attributes or with these dedicated
+environment defaults. Environment variables alone do not enable the feature;
+the block with `enterprise_slug` must be present.
+
+| Attribute | Environment Default |
+| --- | --- |
+| `installer_app_id` | `INSTALLER_APP_CLIENT_ID` |
+| `installer_installation_id` | `INSTALLER_APP_INSTALLATION_ID` |
+| `installer_pem_file` | `INSTALLER_APP_PRIVATE_KEY` |
+| `automation_app_id` | `ORG_APP_ID` |
+| `automation_client_id` | `ORG_APP_CLIENT_ID` |
+| `automation_pem_file` | `ORG_APP_PRIVATE_KEY` |
+
+`installer_app_id` accepts the numeric App ID or client ID JWT issuer.
+`automation_app_id` is the numeric App ID; `automation_client_id` must identify
+the same App. PEM attributes/environment variables contain private key content,
+not file paths. Keep all private key inputs sensitive in HCP Terraform.
+All installer and automation credentials are required, whether supplied as
+attributes or environment variables. Missing credentials fail configuration.
+
+The installer must have access to the enterprise's installable organization
+inventory and permission to install organization Apps. The automation App must
+have `organization_administration = write` and `members = read` (or `write`).
+Repository permissions other than metadata are rejected because bootstrap uses
+`repository_selection = "none"`. The creation App needs the enterprise
+organization-creation permission. See the GitHub
+[enterprise organization APIs](https://docs.github.com/en/rest/enterprise-admin/organizations)
+and [App installation APIs](https://docs.github.com/en/rest/apps/installations)
+for endpoint permission requirements.
+
+Read/import never install, update, or unsuspend Apps. Creation installs the
+automation App only if its organization installation is absent. Existing
+installations must be active and already have the required permissions; this
+provider does not upgrade them. The `automation_installation_id` resource
+attribute exposes the discovered installation ID, but does not configure other
+provider instances in the same run.
+
+Updates and deletion of enterprise organizations are explicitly blocked in
+this mode. For partial-create recovery and import behavior, see
+[github_enterprise_organization](resources/enterprise_organization).
+
### OAuth or Personal Access Token (PAT)
To authenticate using OAuth tokens, ensure that the `token` argument or the `GITHUB_TOKEN` environment variable is set.
diff --git a/templates/resources/enterprise_organization.md.tmpl b/templates/resources/enterprise_organization.md.tmpl
index 0a9252cd7d..27a69fcf21 100644
--- a/templates/resources/enterprise_organization.md.tmpl
+++ b/templates/resources/enterprise_organization.md.tmpl
@@ -38,6 +38,46 @@ The following additional attributes are exported:
- `id` - The node ID of the organization for use with the v4 API.
- `database_id` - The ID of the organization.
+- `automation_installation_id` - The automation App installation ID discovered
+ when the provider opts into `enterprise_app_auth`. Unset in legacy mode.
+
+## Opt-In App Lifecycle
+
+With the provider's `enterprise_app_auth` block, this resource creates an
+organization with the existing enterprise creation App and bootstraps the
+automation App with the enterprise installer. The enterprise ID must match the
+configured enterprise slug. Node IDs and numeric organization IDs are checked
+against enterprise inventory and the organization APIs before accepting state.
+Propagation retries are bounded by the create timeout and stop on permission,
+suspension, and identity errors. A failed installation POST is not retried.
+
+Refresh and import use the installer's paginated enterprise inventory plus
+organization-scoped automation clients. Billing comes from REST `billing_email`,
+not GraphQL `organizationBillingEmail`. Missing/null billing is an error; existing
+billing state is not replaced with an empty value. Owners are paginated and only
+`ADMIN` memberships are retained. Denied, ambiguous, missing-installation, or
+identity-mismatch reads return errors and retain the resource ID, rather than
+silently planning a replacement. Organizations renamed outside Terraform require
+explicit reconciliation/import; refresh does not guess an identity from a new login.
+
+Updates are not supported in this opt-in path, including changes to owners,
+billing, display name, and description. Restore the previous configuration or
+perform approved external changes and reconcile configuration before refreshing.
+Deletion is blocked before any API request until an approved permission and
+deletion contract is available. `lifecycle { prevent_destroy = true }` is
+recommended. Neither this mode nor the installation ID output dynamically routes
+ordinary organization resources such as repositories or teams.
+
+If creation succeeds but installation/profile bootstrap fails, the error reports
+the enterprise/org import ID and retains the returned node/database IDs, including
+in the SDK apply response. Terraform can mark a failed create as tainted; do not
+blindly retry or replace it. Inspect the organization and App installation,
+correct the missing permissions/installation through approved administration,
+then untaint the existing object if state is complete or explicitly reconcile
+state and import it. Import and refresh will not finish a missing installation.
+Deletion remains blocked even for a tainted object. If the creation API response
+itself is lost, check enterprise inventory and import any created organization
+before retrying, because a trustworthy node ID may not have been returned.
## Import
@@ -46,3 +86,8 @@ GitHub Enterprise Organization can be imported using the `slug` of the enterpris
```shell
terraform import github_enterprise_organization.org enterp/some-awesome-org
```
+
+In `enterprise_app_auth` mode the enterprise slug in the import ID must match
+the configured slug. The automation App must already be installed, active, and
+authorized for organization administration and member reads. Import makes no
+installation writes and fails on missing permissions instead of discarding state.