-
Notifications
You must be signed in to change notification settings - Fork 961
feat: add auth_mode for explicit auth configuration #3246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
a6adf15
4c9ede5
06ad80f
7761220
a2010f5
91625cf
e07604b
c33ccb5
e3264c4
af6fb59
3e17470
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
|
@@ -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": { | ||||||
|
|
@@ -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"], | ||||||
| }, | ||||||
| // https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination | ||||||
| "max_per_page": { | ||||||
| Type: schema.TypeInt, | ||||||
|
|
@@ -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", | ||||||
|
|
||||||
|
|
@@ -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. " + | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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") | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you test if we can use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 := "" | ||||||
|
|
@@ -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.", | ||||||
|
laughedelic marked this conversation as resolved.
|
||||||
| }) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| writeDelay := d.Get("write_delay_ms").(int) | ||||||
|
|
@@ -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. | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add
ConflictsWithfields so that it's not possible to set both App auth configs at the same time? Or does that interfere with theDefaultFuncbeing the same?There was a problem hiding this comment.
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.