Skip to content
Open
2 changes: 1 addition & 1 deletion examples/app_authentication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ You may use variables passed via command line:
export GITHUB_OWNER=
export GITHUB_APP_ID=
export GITHUB_APP_INSTALLATION_ID=
export GITHUB_APP_PEM_FILE=
export GITHUB_APP_PRIVATE_KEY=
```

```console
Expand Down
10 changes: 4 additions & 6 deletions examples/app_authentication/providers.tf
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
provider "github" {
owner = var.owner
app_auth {
// Empty block to allow the provider configurations to be specified through
// environment variables.
// See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142
}
owner = var.owner
auth_mode = "app"
# Credentials are specified through environment variables:
# GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY
}

terraform {
Expand Down
182 changes: 147 additions & 35 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@ import (

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)

func Provider() *schema.Provider {
p := &schema.Provider{
Schema: map[string]*schema.Schema{
"auth_mode": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_AUTH_MODE", nil),
Description: descriptions["auth_mode"],
ValidateFunc: validation.StringInSlice([]string{"anonymous", "token", "app"}, false),
},
"token": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_TOKEN", nil),
Description: descriptions["token"],
// ConflictsWith: []string{"app_auth"}, // TODO: Enable as part of v7.
},
"owner": {
Type: schema.TypeString,
Expand Down Expand Up @@ -98,7 +105,7 @@ func Provider() *schema.Provider {
Optional: true,
MaxItems: 1,
Description: descriptions["app_auth"],
// ConflictsWith: []string{"token"}, // TODO: Enable as part of v7.
Deprecated: "Use top-level app_id, app_installation_id, and app_private_key instead.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {
Expand All @@ -123,6 +130,25 @@ func Provider() *schema.Provider {
},
},
},
"app_id": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil),
Description: descriptions["app_id"],
},
"app_installation_id": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil),
Description: descriptions["app_installation_id"],
},
"app_private_key": {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PRIVATE_KEY", nil),
Description: descriptions["app_private_key"],
},
Comment on lines +135 to +156
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add ConflictsWith fields so that it's not possible to set both App auth configs at the same time? Or does that interfere with the DefaultFunc being the same?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it interferes with the default value, but it probably doesn't raise the conflict when everything is set through env vars. In any case, I added mutually exclusive ConflictsWith in e3264c4. And further validation is done in providerConfigure.

// https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination
"max_per_page": {
Type: schema.TypeInt,
Expand Down Expand Up @@ -307,8 +333,11 @@ var descriptions map[string]string

func init() {
descriptions = map[string]string{
"token": "The OAuth token used to connect to GitHub. Anonymous mode is enabled if both `token` and " +
"`app_auth` are not set.",
"auth_mode": "Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. " +
"When not set, the provider auto-detects the mode based on provided credentials for backward compatibility.",

"token": "The OAuth token used to connect to GitHub. " +
"When `auth_mode` is not set, anonymous mode is enabled if no credentials are provided.",

"base_url": "The GitHub Base API URL",

Expand All @@ -320,11 +349,14 @@ func init() {
"organization": "The GitHub organization name to manage. " +
"Use this field instead of `owner` when managing organization accounts.",

"app_auth": "The GitHub App credentials used to connect to GitHub. Conflicts with " +
"`token`. Anonymous mode is enabled if both `token` and `app_auth` are not set.",
"app_auth": "Deprecated: use top-level `app_id`, `app_installation_id`, and `app_private_key` instead. " +
"The GitHub App credentials used to connect to GitHub.",
"app_auth.id": "The GitHub App ID.",
"app_auth.installation_id": "The GitHub App installation instance ID.",
"app_auth.pem_file": "The GitHub App PEM file contents.",
"app_id": "The GitHub App ID.",
"app_installation_id": "The GitHub App installation instance ID.",
"app_private_key": "The GitHub App private key in PEM format.",
"write_delay_ms": "Amount of time in milliseconds to sleep in between writes to GitHub API. " +
"Defaults to 1000ms or 1s if not set.",
"read_delay_ms": "Amount of time in milliseconds to sleep in between non-write requests to GitHub API. " +
Expand All @@ -347,9 +379,11 @@ func init() {

func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
return func(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) {
var diags diag.Diagnostics

owner := d.Get("owner").(string)
token := d.Get("token").(string)
insecure := d.Get("insecure").(bool)
authMode := d.Get("auth_mode").(string)

// BEGIN backwards compatibility
// OwnerOrOrgEnvDefaultFunc used to be the default value for both
Expand Down Expand Up @@ -378,34 +412,37 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
owner = org
}

if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil {
appAuthAttr := appAuth[0].(map[string]any)
var token string

var appID, appInstallationID, appPemFile string
switch authMode {
case "anonymous":
log.Printf("[INFO] Auth mode: anonymous")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you test if we can use tflog inside this func?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be fine. Done in e07604b.


if v, ok := appAuthAttr["id"].(string); ok && v != "" {
appID = v
} else {
return nil, wrapErrors([]error{fmt.Errorf("app_auth.id must be set and contain a non-empty value")})
case "token":
token = d.Get("token").(string)
if token == "" {
return nil, diag.FromErr(fmt.Errorf(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, diag.FromErr(fmt.Errorf(
return nil, diag.Errorf(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c33ccb5

"auth_mode is set to \"token\" but no token was provided; " +
"set the `token` argument or `GITHUB_TOKEN` environment variable"))
}
log.Printf("[INFO] Auth mode: token")

if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" {
appInstallationID = v
} else {
return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")})
case "app":
appID, appInstallationID, appPemFile := getAppCredentials(d)
var missingFields []string
if appID == "" {
missingFields = append(missingFields, "app_id (GITHUB_APP_ID)")
}

if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" {
// The Go encoding/pem package only decodes PEM formatted blocks
// that contain new lines. Some platforms, like Terraform Cloud,
// do not support new lines within Environment Variables.
// Any occurrence of \n in the `pem_file` argument's value
// (explicit value, or default value taken from
// GITHUB_APP_PEM_FILE Environment Variable) is replaced with an
// actual new line character before decoding.
appPemFile = strings.ReplaceAll(v, `\n`, "\n")
} else {
return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")})
if appInstallationID == "" {
missingFields = append(missingFields, "app_installation_id (GITHUB_APP_INSTALLATION_ID)")
}
if appPemFile == "" {
missingFields = append(missingFields, "app_private_key (GITHUB_APP_PRIVATE_KEY)")
}
if len(missingFields) > 0 {
return nil, diag.FromErr(fmt.Errorf(
"auth_mode is set to \"app\" but the following app credentials are missing: %s",
strings.Join(missingFields, ", ")))
}

apiPath := ""
Expand All @@ -419,11 +456,42 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
}

token = appToken
}
log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID)

default: // auto-detect (backward compatibility)
token = d.Get("token").(string)

if token == "" {
appID, appInstallationID, appPemFile := getAppCredentials(d)
if appID != "" && appInstallationID != "" && appPemFile != "" {
apiPath := ""
if isGHES {
apiPath = GHESRESTAPIPath
}

if token == "" {
log.Printf("[INFO] No token found, using GitHub CLI to get token from hostname %s", baseURL.Host)
token = tokenFromGHCLI(baseURL)
appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile)
if err != nil {
return nil, wrapErrors([]error{err})
}
token = appToken
log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID)
}
}

if token == "" {
log.Printf("[INFO] No token found, trying GitHub CLI to get token from hostname %s", baseURL.Host)
ghToken := tokenFromGHCLI(baseURL)
if ghToken != "" {
token = ghToken
diags = append(diags, diag.Diagnostic{
Severity: diag.Warning,
Summary: "GitHub CLI token fallback is deprecated",
Detail: "Automatic token detection from `gh auth token` is deprecated and will be removed in a future major release. " +
"Please set the `token` provider argument or `GITHUB_TOKEN` environment variable explicitly. " +
"You can use `export GITHUB_TOKEN=$(gh auth token)` as a replacement.",
Comment thread
laughedelic marked this conversation as resolved.
})
}
}
}

writeDelay := d.Get("write_delay_ms").(int)
Expand Down Expand Up @@ -493,10 +561,54 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
return nil, wrapErrors([]error{err})
}

return meta, nil
return meta, diags
}
}

func getAppCredentials(d *schema.ResourceData) (appID, appInstallationID, appPemFile string) {
// Try top-level fields first
if v, ok := d.Get("app_id").(string); ok && v != "" {
appID = v
}
if v, ok := d.Get("app_installation_id").(string); ok && v != "" {
appInstallationID = v
}
if v, ok := d.Get("app_private_key").(string); ok && v != "" {
// The Go encoding/pem package only decodes PEM formatted blocks
// that contain new lines. Some platforms, like Terraform Cloud,
// do not support new lines within Environment Variables.
// Any occurrence of \n in the `app_private_key` argument's value
// (explicit value, or default value taken from
// GITHUB_APP_PRIVATE_KEY Environment Variable) is replaced with an
// actual new line character before decoding.
appPemFile = strings.ReplaceAll(v, `\n`, "\n")
}

// Fall back to app_auth block for any missing values
if appID == "" || appInstallationID == "" || appPemFile == "" {
if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil {
appAuthAttr := appAuth[0].(map[string]any)
if appID == "" {
if v, ok := appAuthAttr["id"].(string); ok && v != "" {
appID = v
}
}
if appInstallationID == "" {
if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" {
appInstallationID = v
}
}
if appPemFile == "" {
if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" {
appPemFile = strings.ReplaceAll(v, `\n`, "\n")
}
}
}
}

return
}

// ghCLIHostFromAPIHost maps an API hostname to the corresponding
// gh-CLI --hostname value. For example api.github.com -> github.com
// and api.<slug>.ghe.com -> <slug>.ghe.com.
Expand Down
Loading