From d2e73803d48d7519b518d1fed07e4c6f964e07d6 Mon Sep 17 00:00:00 2001 From: Modular Magician Date: Mon, 2 Dec 2024 22:11:25 +0000 Subject: [PATCH] `FEATURE`: release ephemeral resources support (#12469) Co-authored-by: Sarah French <15078782+SarahFrench@users.noreply.github.com> Co-authored-by: Zhenhua Li Co-authored-by: Sarah French [upstream:5256daabf1311f147328ec216605bce4b202da11] Signed-off-by: Modular Magician --- .changelog/12469.txt | 12 + go.mod | 10 +- go.sum | 16 +- google-beta/fwprovider/framework_provider.go | 42 ++- .../fwprovider/framework_validators_test.go | 121 ------- google-beta/fwutils/utils.go | 14 + .../framework_validators.go | 93 +++++- .../fwvalidators/framework_validators_test.go | 311 ++++++++++++++++++ ..._source_google_service_account_id_token.go | 2 +- ...ral_google_service_account_access_token.go | 154 +++++++++ ...oogle_service_account_access_token_test.go | 111 +++++++ ...hemeral_google_service_account_id_token.go | 168 ++++++++++ ...al_google_service_account_id_token_test.go | 139 ++++++++ .../ephemeral_google_service_account_jwt.go | 145 ++++++++ ...hemeral_google_service_account_jwt_test.go | 108 ++++++ .../ephemeral_google_service_account_key.go | 128 +++++++ ...hemeral_google_service_account_key_test.go | 55 ++++ ...service_account_access_token.html.markdown | 74 +++++ .../service_account_id_token.html.markdown | 72 ++++ .../service_account_jwt.html.markdown | 43 +++ .../service_account_key.html.markdown | 45 +++ .../using_ephemeral_resources.html.markdown | 108 ++++++ 22 files changed, 1821 insertions(+), 150 deletions(-) create mode 100644 .changelog/12469.txt delete mode 100644 google-beta/fwprovider/framework_validators_test.go create mode 100644 google-beta/fwutils/utils.go rename google-beta/{fwprovider => fwvalidators}/framework_validators.go (55%) create mode 100644 google-beta/fwvalidators/framework_validators_test.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_access_token.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_access_token_test.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_id_token.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_id_token_test.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_jwt.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_jwt_test.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_key.go create mode 100644 google-beta/services/resourcemanager/ephemeral_google_service_account_key_test.go create mode 100644 website/docs/ephemeral-resources/service_account_access_token.html.markdown create mode 100644 website/docs/ephemeral-resources/service_account_id_token.html.markdown create mode 100644 website/docs/ephemeral-resources/service_account_jwt.html.markdown create mode 100644 website/docs/ephemeral-resources/service_account_key.html.markdown create mode 100644 website/docs/guides/using_ephemeral_resources.html.markdown diff --git a/.changelog/12469.txt b/.changelog/12469.txt new file mode 100644 index 0000000000..fd0e924e99 --- /dev/null +++ b/.changelog/12469.txt @@ -0,0 +1,12 @@ +```release-note:enhancement +`google_service_account_access_token` +``` +```release-note:enhancement +`google_service_account_jwt` +``` +```release-note:enhancement +`google_service_account_key` +``` +```release-note:enhancement +`google_service_account_id_token` +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 40b42eac1a..bc19792ba0 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,11 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/terraform-json v0.22.1 - github.com/hashicorp/terraform-plugin-framework v1.7.0 + github.com/hashicorp/terraform-plugin-framework v1.13.0 github.com/hashicorp/terraform-plugin-framework-validators v0.9.0 - github.com/hashicorp/terraform-plugin-go v0.23.0 + github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-log v0.9.0 - github.com/hashicorp/terraform-plugin-mux v0.15.0 + github.com/hashicorp/terraform-plugin-mux v0.17.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 github.com/hashicorp/terraform-plugin-testing v1.5.1 github.com/mitchellh/go-homedir v1.1.0 @@ -73,7 +73,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hc-install v0.6.4 // indirect github.com/hashicorp/hcl/v2 v2.20.1 // indirect @@ -118,4 +118,4 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) \ No newline at end of file +) diff --git a/go.sum b/go.sum index fcc915d85f..4397c6fe45 100644 --- a/go.sum +++ b/go.sum @@ -154,8 +154,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= -github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -171,16 +171,16 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-framework v1.7.0 h1:wOULbVmfONnJo9iq7/q+iBOBJul5vRovaYJIu2cY/Pw= -github.com/hashicorp/terraform-plugin-framework v1.7.0/go.mod h1:jY9Id+3KbZ17OMpulgnWLSfwxNVYSoYBQFTgsx044CI= +github.com/hashicorp/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= +github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= github.com/hashicorp/terraform-plugin-framework-validators v0.9.0 h1:LYz4bXh3t7bTEydXOmPDPupRRnA480B/9+jV8yZvxBA= github.com/hashicorp/terraform-plugin-framework-validators v0.9.0/go.mod h1:+BVERsnfdlhYR2YkXMBtPnmn9UsL19U3qUtSZ+Y/5MY= -github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= -github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= +github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= +github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-mux v0.15.0 h1:+/+lDx0WUsIOpkAmdwBIoFU8UP9o2eZASoOnLsWbKME= -github.com/hashicorp/terraform-plugin-mux v0.15.0/go.mod h1:9ezplb1Dyq394zQ+ldB0nvy/qbNAz3mMoHHseMTMaKo= +github.com/hashicorp/terraform-plugin-mux v0.17.0 h1:/J3vv3Ps2ISkbLPiZOLspFcIZ0v5ycUXCEQScudGCCw= +github.com/hashicorp/terraform-plugin-mux v0.17.0/go.mod h1:yWuM9U1Jg8DryNfvCp+lH70WcYv6D8aooQxxxIzFDsE= github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 h1:qHprzXy/As0rxedphECBEQAh3R4yp6pKksKHcqZx5G8= github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0/go.mod h1:H+8tjs9TjV2w57QFVSMBQacf8k/E1XwLXGCARgViC6A= github.com/hashicorp/terraform-plugin-testing v1.5.1 h1:T4aQh9JAhmWo4+t1A7x+rnxAJHCDIYW9kXyo4sVO92c= diff --git a/google-beta/fwprovider/framework_provider.go b/google-beta/fwprovider/framework_provider.go index b951e3a46e..98f1468ba1 100644 --- a/google-beta/fwprovider/framework_provider.go +++ b/google-beta/fwprovider/framework_provider.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -20,6 +21,7 @@ import ( "github.com/hashicorp/terraform-provider-google-beta/google-beta/functions" "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwmodels" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwvalidators" "github.com/hashicorp/terraform-provider-google-beta/google-beta/services/firebase" "github.com/hashicorp/terraform-provider-google-beta/google-beta/services/resourcemanager" "github.com/hashicorp/terraform-provider-google-beta/version" @@ -29,9 +31,10 @@ import ( // Ensure the implementation satisfies the expected interfaces var ( - _ provider.Provider = &FrameworkProvider{} - _ provider.ProviderWithMetaSchema = &FrameworkProvider{} - _ provider.ProviderWithFunctions = &FrameworkProvider{} + _ provider.Provider = &FrameworkProvider{} + _ provider.ProviderWithMetaSchema = &FrameworkProvider{} + _ provider.ProviderWithFunctions = &FrameworkProvider{} + _ provider.ProviderWithEphemeralResources = &FrameworkProvider{} ) // New is a helper function to simplify provider server and testing implementation. @@ -80,8 +83,8 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, stringvalidator.ConflictsWith(path.Expressions{ path.MatchRoot("access_token"), }...), - CredentialsValidator(), - NonEmptyStringValidator(), + fwvalidators.CredentialsValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "access_token": schema.StringAttribute{ @@ -90,13 +93,13 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, stringvalidator.ConflictsWith(path.Expressions{ path.MatchRoot("credentials"), }...), - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "impersonate_service_account": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "impersonate_service_account_delegates": schema.ListAttribute{ @@ -106,25 +109,25 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, "project": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "billing_project": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "region": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "zone": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), + fwvalidators.NonEmptyStringValidator(), }, }, "scopes": schema.ListAttribute{ @@ -137,8 +140,8 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, "request_timeout": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonEmptyStringValidator(), - NonNegativeDurationValidator(), + fwvalidators.NonEmptyStringValidator(), + fwvalidators.NonNegativeDurationValidator(), }, }, "request_reason": schema.StringAttribute{ @@ -1088,7 +1091,7 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, "send_after": schema.StringAttribute{ Optional: true, Validators: []validator.String{ - NonNegativeDurationValidator(), + fwvalidators.NonNegativeDurationValidator(), }, }, "enable_batching": schema.BoolAttribute{ @@ -1129,6 +1132,7 @@ func (p *FrameworkProvider) Configure(ctx context.Context, req provider.Configur meta := p.Primary.Meta().(*transport_tpg.Config) resp.DataSourceData = meta resp.ResourceData = meta + resp.EphemeralResourceData = meta } // DataSources defines the data sources implemented in the provider. @@ -1158,3 +1162,13 @@ func (p *FrameworkProvider) Functions(_ context.Context) []func() function.Funct functions.NewZoneFromIdFunction, } } + +// EphemeralResources defines the resources that are of ephemeral type implemented in the provider. +func (p *FrameworkProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + resourcemanager.GoogleEphemeralServiceAccountAccessToken, + resourcemanager.GoogleEphemeralServiceAccountIdToken, + resourcemanager.GoogleEphemeralServiceAccountJwt, + resourcemanager.GoogleEphemeralServiceAccountKey, + } +} diff --git a/google-beta/fwprovider/framework_validators_test.go b/google-beta/fwprovider/framework_validators_test.go deleted file mode 100644 index a6edb38b87..0000000000 --- a/google-beta/fwprovider/framework_validators_test.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 -package fwprovider_test - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" - "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwprovider" - - transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" -) - -func TestFrameworkProvider_CredentialsValidator(t *testing.T) { - cases := map[string]struct { - ConfigValue types.String - ExpectedWarningCount int - ExpectedErrorCount int - }{ - "configuring credentials as a path to a credentials JSON file is valid": { - ConfigValue: types.StringValue(transport_tpg.TestFakeCredentialsPath), // Path to a test fixture - }, - "configuring credentials as a path to a non-existant file is NOT valid": { - ConfigValue: types.StringValue("./this/path/doesnt/exist.json"), // Doesn't exist - ExpectedErrorCount: 1, - }, - "configuring credentials as a credentials JSON string is valid": { - ConfigValue: types.StringValue(acctest.GenerateFakeCredentialsJson("CredentialsValidator")), - }, - "configuring credentials as an empty string is not valid": { - ConfigValue: types.StringValue(""), - ExpectedErrorCount: 1, - }, - "leaving credentials unconfigured is valid": { - ConfigValue: types.StringNull(), - }, - } - - for tn, tc := range cases { - t.Run(tn, func(t *testing.T) { - // Arrange - req := validator.StringRequest{ - ConfigValue: tc.ConfigValue, - } - - resp := validator.StringResponse{ - Diagnostics: diag.Diagnostics{}, - } - - cv := fwprovider.CredentialsValidator() - - // Act - cv.ValidateString(context.Background(), req, &resp) - - // Assert - if resp.Diagnostics.WarningsCount() > tc.ExpectedWarningCount { - t.Errorf("Expected %d warnings, got %d", tc.ExpectedWarningCount, resp.Diagnostics.WarningsCount()) - } - if resp.Diagnostics.ErrorsCount() > tc.ExpectedErrorCount { - t.Errorf("Expected %d errors, got %d", tc.ExpectedErrorCount, resp.Diagnostics.ErrorsCount()) - } - }) - } -} - -func TestFrameworkProvider_NonNegativeDurationValidator(t *testing.T) { - cases := map[string]struct { - ConfigValue types.String - ExpectedWarningCount int - ExpectedErrorCount int - }{ - "3m is a valid, non-negative duration": { - ConfigValue: types.StringValue("3m"), - }, - "3m0s is a valid, non-negative duration": { - ConfigValue: types.StringValue("3m0s"), - }, - "0s is a valid, non-negative duration": { - ConfigValue: types.StringValue("0s"), - }, - "-0s not valid, as it is a negative duration": { - ConfigValue: types.StringValue("-0s"), - ExpectedErrorCount: 1, - }, - "empty strings are not valid, non-negative durations": { - ConfigValue: types.StringValue(""), - ExpectedErrorCount: 1, - }, - } - - for tn, tc := range cases { - t.Run(tn, func(t *testing.T) { - // Arrange - req := validator.StringRequest{ - ConfigValue: tc.ConfigValue, - } - - resp := validator.StringResponse{ - Diagnostics: diag.Diagnostics{}, - } - - cv := fwprovider.NonNegativeDurationValidator() - - // Act - cv.ValidateString(context.Background(), req, &resp) - - // Assert - if resp.Diagnostics.WarningsCount() > tc.ExpectedWarningCount { - t.Errorf("Expected %d warnings, got %d", tc.ExpectedWarningCount, resp.Diagnostics.WarningsCount()) - } - if resp.Diagnostics.ErrorsCount() > tc.ExpectedErrorCount { - t.Errorf("Expected %d errors, got %d", tc.ExpectedErrorCount, resp.Diagnostics.ErrorsCount()) - } - }) - } -} diff --git a/google-beta/fwutils/utils.go b/google-beta/fwutils/utils.go new file mode 100644 index 0000000000..26a7656750 --- /dev/null +++ b/google-beta/fwutils/utils.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package fwutils + +import "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + +func StringSet(d basetypes.SetValue) []string { + + StringSlice := make([]string, 0) + for _, v := range d.Elements() { + StringSlice = append(StringSlice, v.(basetypes.StringValue).ValueString()) + } + return StringSlice +} diff --git a/google-beta/fwprovider/framework_validators.go b/google-beta/fwvalidators/framework_validators.go similarity index 55% rename from google-beta/fwprovider/framework_validators.go rename to google-beta/fwvalidators/framework_validators.go index 02afbd3233..939697e4cc 100644 --- a/google-beta/fwprovider/framework_validators.go +++ b/google-beta/fwvalidators/framework_validators.go @@ -1,11 +1,12 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package fwprovider +package fwvalidators import ( "context" "fmt" "os" + "regexp" "time" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -117,3 +118,93 @@ func (v nonEmptyStringValidator) ValidateString(ctx context.Context, request val func NonEmptyStringValidator() validator.String { return nonEmptyStringValidator{} } + +// Define the possible service account name patterns +var ServiceAccountEmailPatterns = []string{ + `^.+@.+\.iam\.gserviceaccount\.com$`, // Standard IAM service account + `^.+@developer\.gserviceaccount\.com$`, // Legacy developer service account + `^.+@appspot\.gserviceaccount\.com$`, // App Engine service account + `^.+@cloudservices\.gserviceaccount\.com$`, // Google Cloud services service account + `^.+@cloudbuild\.gserviceaccount\.com$`, // Cloud Build service account + `^service-[0-9]+@.+-compute\.iam\.gserviceaccount\.com$`, // Compute Engine service account +} + +// Create a custom validator for service account names +type ServiceAccountEmailValidator struct{} + +func (v ServiceAccountEmailValidator) Description(ctx context.Context) string { + return "value must be a valid service account email address" +} + +func (v ServiceAccountEmailValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v ServiceAccountEmailValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + + // Check for empty string + if value == "" { + resp.Diagnostics.AddError("Invalid Service Account Name", "Service account name must not be empty") + return + } + + valid := false + for _, pattern := range ServiceAccountEmailPatterns { + if matched, _ := regexp.MatchString(pattern, value); matched { + valid = true + break + } + } + + if !valid { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Service Account Name", + "Service account name must match one of the expected patterns for Google service accounts", + ) + } +} + +// Create a custom validator for duration +type BoundedDuration struct { + MinDuration time.Duration + MaxDuration time.Duration +} + +func (v BoundedDuration) Description(ctx context.Context) string { + return fmt.Sprintf("value must be a valid duration string between %v and %v", v.MinDuration, v.MaxDuration) +} + +func (v BoundedDuration) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v BoundedDuration) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + duration, err := time.ParseDuration(value) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Duration Format", + "Duration must be a valid duration string (e.g., '3600s', '1h')", + ) + return + } + + if duration < v.MinDuration || duration > v.MaxDuration { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Duration", + fmt.Sprintf("Duration must be between %v and %v", v.MinDuration, v.MaxDuration), + ) + } +} diff --git a/google-beta/fwvalidators/framework_validators_test.go b/google-beta/fwvalidators/framework_validators_test.go new file mode 100644 index 0000000000..c0b9b6c0cc --- /dev/null +++ b/google-beta/fwvalidators/framework_validators_test.go @@ -0,0 +1,311 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package fwvalidators_test + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwvalidators" + + transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" +) + +func TestFrameworkProvider_CredentialsValidator(t *testing.T) { + cases := map[string]struct { + ConfigValue types.String + ExpectedWarningCount int + ExpectedErrorCount int + }{ + "configuring credentials as a path to a credentials JSON file is valid": { + ConfigValue: types.StringValue(transport_tpg.TestFakeCredentialsPath), // Path to a test fixture + }, + "configuring credentials as a path to a non-existant file is NOT valid": { + ConfigValue: types.StringValue("./this/path/doesnt/exist.json"), // Doesn't exist + ExpectedErrorCount: 1, + }, + "configuring credentials as a credentials JSON string is valid": { + ConfigValue: types.StringValue(acctest.GenerateFakeCredentialsJson("CredentialsValidator")), + }, + "configuring credentials as an empty string is not valid": { + ConfigValue: types.StringValue(""), + ExpectedErrorCount: 1, + }, + "leaving credentials unconfigured is valid": { + ConfigValue: types.StringNull(), + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.ConfigValue, + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + cv := fwvalidators.CredentialsValidator() + + // Act + cv.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.WarningsCount() > tc.ExpectedWarningCount { + t.Errorf("Expected %d warnings, got %d", tc.ExpectedWarningCount, resp.Diagnostics.WarningsCount()) + } + if resp.Diagnostics.ErrorsCount() > tc.ExpectedErrorCount { + t.Errorf("Expected %d errors, got %d", tc.ExpectedErrorCount, resp.Diagnostics.ErrorsCount()) + } + }) + } +} + +func TestServiceAccountEmailValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + value types.String + expectError bool + errorContains string + } + + tests := map[string]testCase{ + "correct service account name": { + value: types.StringValue("test@test.iam.gserviceaccount.com"), + expectError: false, + }, + "developer service account": { + value: types.StringValue("test@developer.gserviceaccount.com"), + expectError: false, + }, + "app engine service account": { + value: types.StringValue("test@appspot.gserviceaccount.com"), + expectError: false, + }, + "cloud services service account": { + value: types.StringValue("test@cloudservices.gserviceaccount.com"), + expectError: false, + }, + "cloud build service account": { + value: types.StringValue("test@cloudbuild.gserviceaccount.com"), + expectError: false, + }, + "compute engine service account": { + value: types.StringValue("service-123456@compute-system.iam.gserviceaccount.com"), + expectError: false, + }, + "incorrect service account name": { + value: types.StringValue("test"), + expectError: true, + errorContains: "Service account name must match one of the expected patterns for Google service accounts", + }, + "empty string": { + value: types.StringValue(""), + expectError: true, + errorContains: "Service account name must not be empty", + }, + "null value": { + value: types.StringNull(), + expectError: false, + }, + "unknown value": { + value: types.StringUnknown(), + expectError: false, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.value, + } + response := validator.StringResponse{} + validator := fwvalidators.ServiceAccountEmailValidator{} + + validator.ValidateString(context.Background(), request, &response) + + if test.expectError && !response.Diagnostics.HasError() { + t.Errorf("expected error, got none") + } + + if !test.expectError && response.Diagnostics.HasError() { + t.Errorf("got unexpected error: %s", response.Diagnostics.Errors()) + } + + if test.errorContains != "" { + foundError := false + for _, err := range response.Diagnostics.Errors() { + if err.Detail() == test.errorContains { + foundError = true + break + } + } + if !foundError { + t.Errorf("expected error with summary %q, got none", test.errorContains) + } + } + }) + } +} + +func TestBoundedDuration(t *testing.T) { + t.Parallel() + + type testCase struct { + value types.String + minDuration time.Duration + maxDuration time.Duration + expectError bool + errorContains string + } + + tests := map[string]testCase{ + "valid duration between min and max": { + value: types.StringValue("1800s"), + minDuration: time.Hour / 2, + maxDuration: time.Hour, + expectError: false, + }, + "valid duration at min": { + value: types.StringValue("1800s"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: false, + }, + "valid duration at max": { + value: types.StringValue("3600s"), + minDuration: time.Hour / 2, + maxDuration: time.Hour, + expectError: false, + }, + "valid duration with different unit": { + value: types.StringValue("1h"), + minDuration: 30 * time.Minute, + maxDuration: 2 * time.Hour, + expectError: false, + }, + "duration below min": { + value: types.StringValue("900s"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration", + }, + "duration exceeds max - seconds": { + value: types.StringValue("7200s"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration", + }, + "duration exceeds max - minutes": { + value: types.StringValue("120m"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration", + }, + "duration exceeds max - hours": { + value: types.StringValue("2h"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration", + }, + "invalid duration format": { + value: types.StringValue("invalid"), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration Format", + }, + "setting min to 0": { + value: types.StringValue("10s"), + minDuration: 0, + maxDuration: time.Hour, + expectError: false, + }, + "setting max to be less than min": { + value: types.StringValue("10s"), + minDuration: 30 * time.Minute, + maxDuration: 10 * time.Second, + expectError: true, + errorContains: "Invalid Duration", + }, + "empty string": { + value: types.StringValue(""), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: true, + errorContains: "Invalid Duration Format", + }, + "null value": { + value: types.StringNull(), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: false, + }, + "unknown value": { + value: types.StringUnknown(), + minDuration: 30 * time.Minute, + maxDuration: time.Hour, + expectError: false, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.value, + } + response := validator.StringResponse{} + validator := fwvalidators.BoundedDuration{ + MinDuration: test.minDuration, + MaxDuration: test.maxDuration, + } + + validator.ValidateString(context.Background(), request, &response) + + if test.expectError && !response.Diagnostics.HasError() { + t.Errorf("expected error, got none") + } + + if !test.expectError && response.Diagnostics.HasError() { + t.Errorf("got unexpected error: %s", response.Diagnostics.Errors()) + } + + if test.errorContains != "" { + foundError := false + for _, err := range response.Diagnostics.Errors() { + if err.Summary() == test.errorContains { + foundError = true + break + } + } + if !foundError { + t.Errorf("expected error with summary %q, got none", test.errorContains) + } + } + }) + } +} diff --git a/google-beta/services/resourcemanager/data_source_google_service_account_id_token.go b/google-beta/services/resourcemanager/data_source_google_service_account_id_token.go index 8c4f82822e..5ab56a883d 100644 --- a/google-beta/services/resourcemanager/data_source_google_service_account_id_token.go +++ b/google-beta/services/resourcemanager/data_source_google_service_account_id_token.go @@ -76,7 +76,7 @@ func dataSourceGoogleServiceAccountIdTokenRead(d *schema.ResourceData, meta inte targetAudience := d.Get("target_audience").(string) creds, err := config.GetCredentials([]string{userInfoScope}, false) if err != nil { - return fmt.Errorf("error calling getCredentials(): %v", err) + return fmt.Errorf("error calling GetCredentials(): %v", err) } targetServiceAccount := d.Get("target_service_account").(string) diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token.go new file mode 100644 index 0000000000..492b32a4ef --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token.go @@ -0,0 +1,154 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwutils" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwvalidators" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/tpgresource" + transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountAccessToken{} + +func GoogleEphemeralServiceAccountAccessToken() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountAccessToken{} +} + +type googleEphemeralServiceAccountAccessToken struct { + providerConfig *transport_tpg.Config +} + +func (p *googleEphemeralServiceAccountAccessToken) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_access_token" +} + +type ephemeralServiceAccountAccessTokenModel struct { + TargetServiceAccount types.String `tfsdk:"target_service_account"` + AccessToken types.String `tfsdk:"access_token"` + Scopes types.Set `tfsdk:"scopes"` + Delegates types.Set `tfsdk:"delegates"` + Lifetime types.String `tfsdk:"lifetime"` +} + +func (p *googleEphemeralServiceAccountAccessToken) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema.Description = "This ephemeral resource provides a google oauth2 access_token for a different service account than the one initially running the script." + resp.Schema.MarkdownDescription = "This ephemeral resource provides a google oauth2 access_token for a different service account than the one initially running the script." + + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "target_service_account": schema.StringAttribute{ + Description: "The service account to impersonate (e.g. `service_B@your-project-id.iam.gserviceaccount.com`)", + Required: true, + Validators: []validator.String{ + fwvalidators.ServiceAccountEmailValidator{}, + }, + }, + "access_token": schema.StringAttribute{ + Description: "The `access_token` representing the new generated identity.", + Sensitive: true, + Computed: true, + }, + "lifetime": schema.StringAttribute{ + Description: "Lifetime of the impersonated token (defaults to its max: `3600s`)", + Optional: true, + Computed: true, + Validators: []validator.String{ + fwvalidators.BoundedDuration{ + MinDuration: 0, + MaxDuration: 3600 * time.Second, + }, + }, + }, + "scopes": schema.SetAttribute{ + Description: "The scopes the new credential should have (e.g. `['cloud-platform']`)", + Required: true, + ElementType: types.StringType, + }, + "delegates": schema.SetAttribute{ + Description: "Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. (e.g. `['projects/-/serviceAccounts/delegate-svc-account@project-id.iam.gserviceaccount.com']`)", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(fwvalidators.ServiceAccountEmailValidator{}), + }, + }, + }, + } +} + +func (p *googleEphemeralServiceAccountAccessToken) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + pd, ok := req.ProviderData.(*transport_tpg.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *transport_tpg.Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + // Required for accessing userAgent and passing as an argument into a util function + p.providerConfig = pd +} + +func (p *googleEphemeralServiceAccountAccessToken) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data ephemeralServiceAccountAccessTokenModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // This is the default value for the lifetime of the access token + // Both ephemeral resources and data sources do not allow you to set a value for this attribute in the schema + if data.Lifetime.IsNull() { + data.Lifetime = types.StringValue("3600s") + } + + service := p.providerConfig.NewIamCredentialsClient(p.providerConfig.UserAgent) + name := fmt.Sprintf("projects/-/serviceAccounts/%s", data.TargetServiceAccount.ValueString()) + + ScopesSetValue, diags := data.Scopes.ToSetValue(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var delegates []string + if !data.Delegates.IsNull() { + delegates = fwutils.StringSet(data.Delegates) + } + + tokenRequest := &iamcredentials.GenerateAccessTokenRequest{ + Lifetime: data.Lifetime.ValueString(), + Delegates: delegates, + Scope: tpgresource.CanonicalizeServiceScopes(fwutils.StringSet(ScopesSetValue)), + } + + at, err := service.Projects.ServiceAccounts.GenerateAccessToken(name, tokenRequest).Do() + if err != nil { + resp.Diagnostics.AddError( + "Error generating access token", + fmt.Sprintf("Error generating access token: %s", err), + ) + return + } + + data.AccessToken = types.StringValue(at.AccessToken) + resp.Diagnostics.Append(resp.Result.Set(ctx, data)...) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token_test.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token_test.go new file mode 100644 index 0000000000..0dcfef6309 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_access_token_test.go @@ -0,0 +1,111 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/envvar" +) + +func TestAccEphemeralServiceAccountToken_basic(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "basic", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountToken_basic(targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountToken_withDelegates(t *testing.T) { + t.Parallel() + + project := envvar.GetTestProjectFromEnv() + initialServiceAccount := envvar.GetTestServiceAccountFromEnv(t) + delegateServiceAccountEmailOne := acctest.BootstrapServiceAccount(t, "delegate1", initialServiceAccount) // SA_2 + delegateServiceAccountEmailTwo := acctest.BootstrapServiceAccount(t, "delegate2", delegateServiceAccountEmailOne) // SA_3 + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "target", delegateServiceAccountEmailTwo) // SA_4 + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountToken_withDelegates(initialServiceAccount, delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail, project), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountToken_withCustomLifetime(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "lifetime", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountToken_withCustomLifetime(targetServiceAccountEmail), + }, + }, + }) +} + +func testAccEphemeralServiceAccountToken_basic(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_access_token" "token" { + target_service_account = "%s" + scopes = ["https://www.googleapis.com/auth/cloud-platform"] +} +`, serviceAccountEmail) +} + +func testAccEphemeralServiceAccountToken_withDelegates(initialServiceAccountEmail, delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail, project string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_access_token" "test" { + target_service_account = "%s" + delegates = [ + "%s", + "%s", + ] + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + lifetime = "3600s" +} + +# The delegation chain is: +# SA_1 (initialServiceAccountEmail) -> SA_2 (delegateServiceAccountEmailOne) -> SA_3 (delegateServiceAccountEmailTwo) -> SA_4 (targetServiceAccountEmail) +`, targetServiceAccountEmail, delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo) +} + +func testAccEphemeralServiceAccountToken_withCustomLifetime(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_access_token" "token" { + target_service_account = "%s" + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + lifetime = "3600s" +} +`, serviceAccountEmail) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token.go new file mode 100644 index 0000000000..ff77a4be23 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager + +import ( + "context" + "fmt" + + "google.golang.org/api/idtoken" + "google.golang.org/api/option" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwutils" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwvalidators" + transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountIdToken{} + +func GoogleEphemeralServiceAccountIdToken() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountIdToken{} +} + +type googleEphemeralServiceAccountIdToken struct { + providerConfig *transport_tpg.Config +} + +func (p *googleEphemeralServiceAccountIdToken) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_id_token" +} + +type ephemeralServiceAccountIdTokenModel struct { + TargetAudience types.String `tfsdk:"target_audience"` + TargetServiceAccount types.String `tfsdk:"target_service_account"` + Delegates types.Set `tfsdk:"delegates"` + IncludeEmail types.Bool `tfsdk:"include_email"` + IdToken types.String `tfsdk:"id_token"` +} + +func (p *googleEphemeralServiceAccountIdToken) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema.Description = "This ephemeral resource provides a Google OpenID Connect (oidc) id_token." + resp.Schema.MarkdownDescription = "This ephemeral resource provides a Google OpenID Connect (oidc) id_token." + + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "target_audience": schema.StringAttribute{ + Description: "The audience claim for the `id_token`.", + Required: true, + }, + "target_service_account": schema.StringAttribute{ + Description: "The email of the service account being impersonated. Used only when using impersonation mode.", + Optional: true, + Validators: []validator.String{ + fwvalidators.ServiceAccountEmailValidator{}, + }, + }, + "delegates": schema.SetAttribute{ + Description: "Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. Used only when using impersonation mode.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(fwvalidators.ServiceAccountEmailValidator{}), + }, + }, + "include_email": schema.BoolAttribute{ + Description: "Include the verified email in the claim. Used only when using impersonation mode.", + Optional: true, // Defaults to false when not set (Null / Unknown) + }, + "id_token": schema.StringAttribute{ + Description: "The `id_token` representing the new generated identity.", + Computed: true, + Sensitive: true, + }, + }, + } +} + +func (p *googleEphemeralServiceAccountIdToken) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + pd, ok := req.ProviderData.(*transport_tpg.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *transport_tpg.Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + p.providerConfig = pd +} + +func (p *googleEphemeralServiceAccountIdToken) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data ephemeralServiceAccountIdTokenModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + targetAudience := data.TargetAudience.ValueString() + + creds, err := p.providerConfig.GetCredentials([]string{userInfoScope}, false) + if err != nil { + resp.Diagnostics.AddError( + "error calling GetCredentials()", + err.Error(), + ) + return + } + + targetServiceAccount := data.TargetServiceAccount + // If a target service account is provided, use the API to generate the idToken + if !targetServiceAccount.IsNull() && !targetServiceAccount.IsUnknown() { + service := p.providerConfig.NewIamCredentialsClient(p.providerConfig.UserAgent) + name := fmt.Sprintf("projects/-/serviceAccounts/%s", targetServiceAccount.ValueString()) + + tokenRequest := &iamcredentials.GenerateIdTokenRequest{ + Audience: targetAudience, + IncludeEmail: data.IncludeEmail.ValueBool(), + Delegates: fwutils.StringSet(data.Delegates), + } + at, err := service.Projects.ServiceAccounts.GenerateIdToken(name, tokenRequest).Do() + if err != nil { + resp.Diagnostics.AddError( + "Error calling iamcredentials.GenerateIdToken", + err.Error(), + ) + return + } + + data.IdToken = types.StringValue(at.Token) + resp.Diagnostics.Append(resp.Result.Set(ctx, data)...) + return + } + + // If no target service account, use the default credentials + ctx = context.Background() + co := []option.ClientOption{} + if creds.JSON != nil { + co = append(co, idtoken.WithCredentialsJSON(creds.JSON)) + } + + idTokenSource, err := idtoken.NewTokenSource(ctx, targetAudience, co...) + if err != nil { + resp.Diagnostics.AddError( + "Unable to retrieve TokenSource", + err.Error(), + ) + return + } + idToken, err := idTokenSource.Token() + if err != nil { + resp.Diagnostics.AddError( + "Unable to retrieve Token", + err.Error(), + ) + return + } + + data.IdToken = types.StringValue(idToken.AccessToken) + resp.Diagnostics.Append(resp.Result.Set(ctx, data)...) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token_test.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token_test.go new file mode 100644 index 0000000000..4dfba12505 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_id_token_test.go @@ -0,0 +1,139 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/envvar" +) + +func TestAccEphemeralServiceAccountIdToken_basic(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "idtoken", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountIdToken_basic(targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountIdToken_withDelegates(t *testing.T) { + t.Parallel() + + initialServiceAccount := envvar.GetTestServiceAccountFromEnv(t) + delegateServiceAccountEmailOne := acctest.BootstrapServiceAccount(t, "id-delegate1", initialServiceAccount) // SA_2 + delegateServiceAccountEmailTwo := acctest.BootstrapServiceAccount(t, "id-delegate2", delegateServiceAccountEmailOne) // SA_3 + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "id-target", delegateServiceAccountEmailTwo) // SA_4 + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountIdToken_withDelegates(delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountIdToken_withEmptyDelegates(t *testing.T) { + t.Parallel() + + initialServiceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "no-del", initialServiceAccount) // SA_4 + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountIdToken_withEmptyDelegates(targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountIdToken_withIncludeEmail(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "idtoken-email", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + ExternalProviders: map[string]resource.ExternalProvider{ + "time": {}, + }, + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountIdToken_withIncludeEmail(targetServiceAccountEmail), + }, + }, + }) +} + +func testAccEphemeralServiceAccountIdToken_basic(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_id_token" "token" { + target_service_account = "%s" + target_audience = "https://example.com" +} +`, serviceAccountEmail) +} + +func testAccEphemeralServiceAccountIdToken_withDelegates(delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_id_token" "token" { + target_service_account = "%s" + delegates = [ + "%s", + "%s", + ] + target_audience = "https://example.com" +} + +# The delegation chain is: +# SA_1 (initialServiceAccountEmail) -> SA_2 (delegateServiceAccountEmailOne) -> SA_3 (delegateServiceAccountEmailTwo) -> SA_4 (targetServiceAccountEmail) +`, targetServiceAccountEmail, delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo) +} + +func testAccEphemeralServiceAccountIdToken_withEmptyDelegates(targetServiceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_id_token" "token" { + target_service_account = "%s" + delegates = [] + target_audience = "https://example.com" +} +`, targetServiceAccountEmail) +} + +func testAccEphemeralServiceAccountIdToken_withIncludeEmail(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_id_token" "token" { + target_service_account = "%s" + target_audience = "https://example.com" + include_email = true +} +`, serviceAccountEmail) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt.go new file mode 100644 index 0000000000..a240447747 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwutils" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwvalidators" + transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountJwt{} + +func GoogleEphemeralServiceAccountJwt() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountJwt{} +} + +type googleEphemeralServiceAccountJwt struct { + providerConfig *transport_tpg.Config +} + +func (p *googleEphemeralServiceAccountJwt) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_jwt" +} + +type ephemeralServiceAccountJwtModel struct { + Payload types.String `tfsdk:"payload"` + ExpiresIn types.Int64 `tfsdk:"expires_in"` + TargetServiceAccount types.String `tfsdk:"target_service_account"` + Delegates types.Set `tfsdk:"delegates"` + Jwt types.String `tfsdk:"jwt"` +} + +func (p *googleEphemeralServiceAccountJwt) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Produces an arbitrary self-signed JWT for service accounts.", + Attributes: map[string]schema.Attribute{ + "payload": schema.StringAttribute{ + Required: true, + Description: `A JSON-encoded JWT claims set that will be included in the signed JWT.`, + }, + "expires_in": schema.Int64Attribute{ + Optional: true, + Description: "Number of seconds until the JWT expires. If set and non-zero an `exp` claim will be added to the payload derived from the current timestamp plus expires_in seconds.", + Validators: []validator.Int64{ + int64validator.AtLeast(1), // Must be greater than 0 + }, + }, + "target_service_account": schema.StringAttribute{ + Description: "The email of the service account that will sign the JWT.", + Required: true, + Validators: []validator.String{ + fwvalidators.ServiceAccountEmailValidator{}, + }, + }, + "delegates": schema.SetAttribute{ + Description: "Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(fwvalidators.ServiceAccountEmailValidator{}), + }, + }, + "jwt": schema.StringAttribute{ + Description: "The signed JWT containing the JWT Claims Set from the `payload`.", + Computed: true, + Sensitive: true, + }, + }, + } +} + +func (p *googleEphemeralServiceAccountJwt) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + pd, ok := req.ProviderData.(*transport_tpg.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *transport_tpg.Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + p.providerConfig = pd +} + +func (p *googleEphemeralServiceAccountJwt) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data ephemeralServiceAccountJwtModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + payload := data.Payload.ValueString() + + if !data.ExpiresIn.IsNull() { + expiresIn := data.ExpiresIn.ValueInt64() + var decoded map[string]interface{} + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + resp.Diagnostics.AddError("Error decoding payload", err.Error()) + return + } + + decoded["exp"] = time.Now().Add(time.Duration(expiresIn) * time.Second).Unix() + + payloadBytesWithExp, err := json.Marshal(decoded) + if err != nil { + resp.Diagnostics.AddError("Error re-encoding payload", err.Error()) + return + } + + payload = string(payloadBytesWithExp) + + } + + name := fmt.Sprintf("projects/-/serviceAccounts/%s", data.TargetServiceAccount.ValueString()) + + service := p.providerConfig.NewIamCredentialsClient(p.providerConfig.UserAgent) + jwtRequest := &iamcredentials.SignJwtRequest{ + Payload: payload, + Delegates: fwutils.StringSet(data.Delegates), + } + + jwtResponse, err := service.Projects.ServiceAccounts.SignJwt(name, jwtRequest).Do() + if err != nil { + resp.Diagnostics.AddError("Error calling iamcredentials.SignJwt", err.Error()) + return + } + + data.Jwt = types.StringValue(jwtResponse.SignedJwt) + + resp.Diagnostics.Append(resp.Result.Set(ctx, data)...) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt_test.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt_test.go new file mode 100644 index 0000000000..8694348430 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_jwt_test.go @@ -0,0 +1,108 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/envvar" +) + +func TestAccEphemeralServiceAccountJwt_basic(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "jwt-basic", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountJwt_basic(targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountJwt_withDelegates(t *testing.T) { + t.Parallel() + + initialServiceAccount := envvar.GetTestServiceAccountFromEnv(t) + delegateServiceAccountEmailOne := acctest.BootstrapServiceAccount(t, "jwt-delegate1", initialServiceAccount) // SA_2 + delegateServiceAccountEmailTwo := acctest.BootstrapServiceAccount(t, "jwt-delegate2", delegateServiceAccountEmailOne) // SA_3 + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "jwt-target", delegateServiceAccountEmailTwo) // SA_4 + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountJwt_withDelegates(delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail), + }, + }, + }) +} + +func TestAccEphemeralServiceAccountJwt_withExpiresIn(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "expiry", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountJwt_withExpiresIn(targetServiceAccountEmail), + }, + }, + }) +} + +func testAccEphemeralServiceAccountJwt_basic(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_jwt" "jwt" { + target_service_account = "%s" + payload = jsonencode({ + "sub": "%[1]s", + "aud": "https://example.com" + }) +} +`, serviceAccountEmail) +} + +func testAccEphemeralServiceAccountJwt_withDelegates(delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo, targetServiceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_jwt" "jwt" { + target_service_account = "%s" + delegates = [ + "%s", + "%s", + ] + payload = jsonencode({ + "sub": "%[1]s", + "aud": "https://example.com" + }) +} +# The delegation chain is: +# SA_1 (initialServiceAccountEmail) -> SA_2 (delegateServiceAccountEmailOne) -> SA_3 (delegateServiceAccountEmailTwo) -> SA_4 (targetServiceAccountEmail) +`, targetServiceAccountEmail, delegateServiceAccountEmailOne, delegateServiceAccountEmailTwo) +} + +func testAccEphemeralServiceAccountJwt_withExpiresIn(serviceAccountEmail string) string { + return fmt.Sprintf(` +ephemeral "google_service_account_jwt" "jwt" { + target_service_account = "%s" + expires_in = 3600 + payload = jsonencode({ + "sub": "%[1]s", + "aud": "https://example.com" + }) +} +`, serviceAccountEmail) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_key.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_key.go new file mode 100644 index 0000000000..0548dd4826 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_key.go @@ -0,0 +1,128 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/verify" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountKey{} + +func GoogleEphemeralServiceAccountKey() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountKey{} +} + +type googleEphemeralServiceAccountKey struct { + providerConfig *transport_tpg.Config +} + +func (p *googleEphemeralServiceAccountKey) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_key" +} + +type ephemeralServiceAccountKeyModel struct { + Name types.String `tfsdk:"name"` + PublicKeyType types.String `tfsdk:"public_key_type"` + KeyAlgorithm types.String `tfsdk:"key_algorithm"` + PublicKey types.String `tfsdk:"public_key"` +} + +func (p *googleEphemeralServiceAccountKey) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get an ephemeral service account public key.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the service account key. This must have format `projects/{PROJECT_ID}/serviceAccounts/{ACCOUNT}/keys/{KEYID}`, where `{ACCOUNT}` is the email address or unique id of the service account.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(verify.ServiceAccountKeyNameRegex), + "must match regex: "+verify.ServiceAccountKeyNameRegex, + ), + }}, + "public_key_type": schema.StringAttribute{ + Description: "The output format of the public key requested. TYPE_X509_PEM_FILE is the default output format.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "TYPE_X509_PEM_FILE", + "TYPE_RAW_PUBLIC_KEY", + ), + }, + }, + "key_algorithm": schema.StringAttribute{ + Description: "The algorithm used to generate the key.", + Computed: true, + }, + "public_key": schema.StringAttribute{ + Description: "The public key, base64 encoded.", + Computed: true, + }, + }, + } +} + +func (p *googleEphemeralServiceAccountKey) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + pd, ok := req.ProviderData.(*transport_tpg.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *transport_tpg.Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + p.providerConfig = pd +} + +func (p *googleEphemeralServiceAccountKey) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data ephemeralServiceAccountKeyModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + keyName := data.Name.ValueString() + + // Validate name + r := regexp.MustCompile(verify.ServiceAccountKeyNameRegex) + if !r.MatchString(keyName) { + resp.Diagnostics.AddError( + "Invalid key name", + fmt.Sprintf("Invalid key name %q does not match regexp %q", keyName, verify.ServiceAccountKeyNameRegex), + ) + return + } + + publicKeyType := data.PublicKeyType.ValueString() + if publicKeyType == "" { + publicKeyType = "TYPE_X509_PEM_FILE" + } + + sak, err := p.providerConfig.NewIamClient(p.providerConfig.UserAgent).Projects.ServiceAccounts.Keys.Get(keyName).PublicKeyType(publicKeyType).Do() + if err != nil { + resp.Diagnostics.AddError( + "Error retrieving Service Account Key", + fmt.Sprintf("Error retrieving Service Account Key %q: %s", keyName, err), + ) + return + } + + data.Name = types.StringValue(sak.Name) + data.KeyAlgorithm = types.StringValue(sak.KeyAlgorithm) + data.PublicKey = types.StringValue(sak.PublicKeyData) + + resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...) +} diff --git a/google-beta/services/resourcemanager/ephemeral_google_service_account_key_test.go b/google-beta/services/resourcemanager/ephemeral_google_service_account_key_test.go new file mode 100644 index 0000000000..53500c4ec5 --- /dev/null +++ b/google-beta/services/resourcemanager/ephemeral_google_service_account_key_test.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/envvar" +) + +func TestAccEphemeralServiceAccountKey_basic(t *testing.T) { + t.Parallel() + + serviceAccount := envvar.GetTestServiceAccountFromEnv(t) + targetServiceAccountEmail := acctest.BootstrapServiceAccount(t, "key-basic", serviceAccount) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccEphemeralServiceAccountKey_setup(targetServiceAccountEmail), + }, + { + Config: testAccEphemeralServiceAccountKey_basic(targetServiceAccountEmail), + }, + }, + }) +} + +func testAccEphemeralServiceAccountKey_setup(serviceAccount string) string { + return fmt.Sprintf(` +resource "google_service_account_key" "key" { + service_account_id = "%s" + public_key_type = "TYPE_X509_PEM_FILE" +} +`, serviceAccount) +} + +func testAccEphemeralServiceAccountKey_basic(serviceAccount string) string { + return fmt.Sprintf(` +resource "google_service_account_key" "key" { + service_account_id = "%s" + public_key_type = "TYPE_X509_PEM_FILE" +} + +ephemeral "google_service_account_key" "key" { + name = google_service_account_key.key.name + public_key_type = "TYPE_X509_PEM_FILE" +} +`, serviceAccount) +} diff --git a/website/docs/ephemeral-resources/service_account_access_token.html.markdown b/website/docs/ephemeral-resources/service_account_access_token.html.markdown new file mode 100644 index 0000000000..c6627448a8 --- /dev/null +++ b/website/docs/ephemeral-resources/service_account_access_token.html.markdown @@ -0,0 +1,74 @@ +--- +subcategory: "Cloud Platform" +description: |- + Produces access_token for impersonated service accounts +--- + +# google_service_account_access_token + +This ephemeral resource provides a google `oauth2` `access_token` for a different service account than the one initially running the script. + +For more information see +[the official documentation](https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials) as well as [iamcredentials.generateAccessToken()](https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/generateAccessToken) + +## Example Usage + +To allow `service_A` to impersonate `service_B`, grant the [Service Account Token Creator](https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role) on B to A. + +In the IAM policy below, `service_A` is given the Token Creator role impersonate `service_B` + +```hcl +resource "google_service_account_iam_binding" "token-creator-iam" { + service_account_id = "projects/-/serviceAccounts/service_B@projectB.iam.gserviceaccount.com" + role = "roles/iam.serviceAccountTokenCreator" + members = [ + "serviceAccount:service_A@projectA.iam.gserviceaccount.com", + ] +} +``` + +Once the IAM permissions are set, you can apply the new token to a provider bootstrapped with it. Any resources that references the aliased provider will run as the new identity. + +In the example below, `google_project` will run as `service_B`. + +```hcl +provider "google" { +} + +ephemeral "google_service_account_access_token" "default" { + provider = google + target_service_account = "service_B@projectB.iam.gserviceaccount.com" + scopes = ["userinfo-email", "cloud-platform"] + lifetime = "300s" +} + +provider "google" { + alias = "impersonated" + access_token = ephemeral.google_service_account_access_token.default.access_token +} + +data "google_client_openid_userinfo" "me" { + provider = google.impersonated +} + +output "target-email" { + value = data.google_client_openid_userinfo.me.email +} +``` + +> *Note*: the generated token is non-refreshable and can have a maximum `lifetime` of `3600` seconds. + +## Argument Reference + +The following arguments are supported: + +* `target_service_account` (Required) - The service account _to_ impersonate (e.g. `service_B@your-project-id.iam.gserviceaccount.com`) +* `scopes` (Required) - The scopes the new credential should have (e.g. `["cloud-platform"]`) +* `delegates` (Optional) - Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. (e.g. `["projects/-/serviceAccounts/delegate-svc-account@project-id.iam.gserviceaccount.com"]`) +* `lifetime` (Optional) Lifetime of the impersonated token (defaults to its max: `3600s`). + +## Attributes Reference + +The following attribute is exported: + +* `access_token` - The `access_token` representing the new generated identity. diff --git a/website/docs/ephemeral-resources/service_account_id_token.html.markdown b/website/docs/ephemeral-resources/service_account_id_token.html.markdown new file mode 100644 index 0000000000..0e9e15b5cf --- /dev/null +++ b/website/docs/ephemeral-resources/service_account_id_token.html.markdown @@ -0,0 +1,72 @@ +--- +subcategory: "Cloud Platform" +description: |- + Produces OpenID Connect token for service accounts +--- + +# google_service_account_id_token + +This ephemeral resource provides a Google OpenID Connect (`oidc`) `id_token`. Tokens issued from this ephemeral resource are typically used to call external services that accept OIDC tokens for authentication (e.g. [Google Cloud Run](https://cloud.google.com/run/docs/authenticating/service-to-service)). + +For more information see +[OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html#IDToken). + +## Example Usage - ServiceAccount JSON credential file. + +-> **Note:** If you run this example configuration you will be able to see ephemeral.google_service_account_id_token.oidc in terraform plan and apply terminal output but you will not see it in state, as ephemeral resources are excluded from state. In future, when write-only attributes are added to resources in the Google provider, ephemeral resources such as google_service_account_id_token could be used to set field values when creating managed resources. + + `google_service_account_id_token` will use the configured [provider credentials](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference#credentials-1) + + ```hcl + ephemeral "google_service_account_id_token" "oidc" { + target_audience = "https://foo.bar/" + } + ``` + +## Example Usage - Service Account Impersonation. + +-> **Note:** If you run this example configuration you will be able to see ephemeral.google_service_account_id_token.oidc in terraform plan and apply terminal output but you will not see it in state, as ephemeral resources are excluded from state. In future, when write-only attributes are added to resources in the Google provider, ephemeral resources such as google_service_account_id_token could be used to set field values when creating managed resources. + + Ephemeral resource `google_service_account_id_token` will use background impersonated credentials provided by [google_service_account_access_token](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/service_account_access_token). + + Note: to use the following, you must grant `target_service_account` the + `roles/iam.serviceAccountTokenCreator` role on itself. + + ```hcl + data "google_service_account_access_token" "impersonated" { + provider = google + target_service_account = "impersonated-account@project.iam.gserviceaccount.com" + delegates = [] + scopes = ["userinfo-email", "cloud-platform"] + lifetime = "300s" + } + + provider "google" { + alias = "impersonated" + access_token = data.google_service_account_access_token.impersonated.access_token + } + + ephemeral "google_service_account_id_token" "oidc" { + provider = google.impersonated + target_service_account = "impersonated-account@project.iam.gserviceaccount.com" + delegates = [] + include_email = true + target_audience = "https://foo.bar/" + } + + ``` + +## Argument Reference + +The following arguments are supported: + +* `target_audience` (Required) - The audience claim for the `id_token`. +* `target_service_account` (Optional) - The email of the service account being impersonated. Used only when using impersonation mode. +* `delegates` (Optional) - Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. Used only when using impersonation mode. +* `include_email` (Optional) Include the verified email in the claim. Used only when using impersonation mode. + +## Attributes Reference + +The following attribute is exported: + +* `id_token` - The `id_token` representing the new generated identity. diff --git a/website/docs/ephemeral-resources/service_account_jwt.html.markdown b/website/docs/ephemeral-resources/service_account_jwt.html.markdown new file mode 100644 index 0000000000..ef0f90a609 --- /dev/null +++ b/website/docs/ephemeral-resources/service_account_jwt.html.markdown @@ -0,0 +1,43 @@ +--- +subcategory: "Cloud Platform" +description: |- + Produces an arbitrary self-signed JWT for service accounts +--- + +# google_service_account_jwt + +This ephemeral resource provides a [self-signed JWT](https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-jwt). Tokens issued from this ephemeral resource are typically used to call external services that accept JWTs for authentication. + +## Example Usage + +-> **Note:** If you run this example configuration you will be able to see ephemeral.google_service_account_jwt.foo in terraform plan and apply terminal output but you will not see it in state, as ephemeral resources are excluded from state. In future, when write-only attributes are added to resources in the Google provider, ephemeral resources such as google_service_account_jwt could be used to set field values when creating managed resources. + +Note: in order to use the following, the caller must have _at least_ `roles/iam.serviceAccountTokenCreator` on the `target_service_account`. + +```hcl +ephemeral "google_service_account_jwt" "foo" { + target_service_account = "impersonated-account@project.iam.gserviceaccount.com" + + payload = jsonencode({ + foo: "bar", + sub: "subject", + }) + + expires_in = 60 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `target_service_account` (Required) - The email of the service account that will sign the JWT. +* `payload` (Required) - The JSON-encoded JWT claims set to include in the self-signed JWT. +* `expires_in` (Optional) - Number of seconds until the JWT expires. If set and non-zero an `exp` claim will be added to the payload derived from the current timestamp plus expires_in seconds. +* `delegates` (Optional) - Delegate chain of approvals needed to perform full impersonation. Specify the fully qualified service account name. + +## Attributes Reference + +The following attribute is exported: + +* `jwt` - The signed JWT containing the JWT Claims Set from the `payload`. diff --git a/website/docs/ephemeral-resources/service_account_key.html.markdown b/website/docs/ephemeral-resources/service_account_key.html.markdown new file mode 100644 index 0000000000..43b2cef159 --- /dev/null +++ b/website/docs/ephemeral-resources/service_account_key.html.markdown @@ -0,0 +1,45 @@ +--- +subcategory: "Cloud Platform" +description: |- + Get a Google Cloud Platform service account Public Key +--- + +# google_service_account_key + +Get an ephemeral service account public key. For more information, see [the official documentation](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) and [API](https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts.keys/get). + +## Example Usage + +-> **Note:** If you run this example configuration you will be able to see ephemeral.google_service_account_key.mykey in terraform plan and apply terminal output but you will not see it in state, as ephemeral resources are excluded from state. In future, when write-only attributes are added to resources in the Google provider, ephemeral resources such as google_service_account_key could be used to set field values when creating managed resources. + + +```hcl +resource "google_service_account" "myaccount" { + account_id = "dev-foo-account" +} + +resource "google_service_account_key" "mykey" { + service_account_id = google_service_account.myaccount.name +} + +ephemeral "google_service_account_key" "mykey" { + name = google_service_account_key.mykey.name + public_key_type = "TYPE_X509_PEM_FILE" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the service account key. This must have format + `projects/{PROJECT_ID}/serviceAccounts/{ACCOUNT}/keys/{KEYID}`, where `{ACCOUNT}` + is the email address or unique id of the service account. + +* `public_key_type` (Optional) The output format of the public key requested. TYPE_X509_PEM_FILE is the default output format. + +## Attributes Reference + +The following attributes are exported in addition to the arguments listed above: + +* `public_key` - The public key, base64 encoded diff --git a/website/docs/guides/using_ephemeral_resources.html.markdown b/website/docs/guides/using_ephemeral_resources.html.markdown new file mode 100644 index 0000000000..dde065e489 --- /dev/null +++ b/website/docs/guides/using_ephemeral_resources.html.markdown @@ -0,0 +1,108 @@ +--- +page_title: "Use ephemeral resources in the Google Cloud provider" +description: |- + How to use ephemeral resources in the Google Cloud provider +--- + +# Ephemeral Resources in the Google Cloud provider + +Ephemeral resources are Terraform resources that are essentially temporary. They allow users to access and use data in their configurations without that data being stored in Terraform state. + +Ephemeral resources are available in Terraform v1.10 and later. For more information, see the [official HashiCorp documentation for Ephemeral Resources](https://developer.hashicorp.com/terraform/language/resources/ephemeral). + +To mark the launch of the ephemeral resources feature, the Google Cloud provider has added four ephemeral resources: +- [`google_service_account_access_token`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_access_token) +- [`google_service_account_id_token`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_id_token) +- [`google_service_account_jwt`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_jwt) +- [`google_service_account_key`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_key) + +These are based on existing data sources already in the provider. In future you may wish to update your configurations to use these ephemeral versions, as they will allow you to avoid storing tokens and credentials values in your Terraform state. + +## Use the Google Cloud provider's new ephemeral resources + +Ephemeral resources are a source of ephemeral data, and they can be referenced in your configuration just like the attributes of resources and data sources. However, a field that references an ephemeral resource must be capable of handling ephemeral data. Due to this, resources in the Google Cloud provider will need to be updated so they include write-only attributes that are capable of using ephemeral data while not storing those values in the resource's state. + +Until then, ephemeral resources can only be used to pass values into the provider block, which is already capable of receiving ephemeral values. + +The following sections show two examples from the new ephemeral resources' documentation pages, which can be used to test out the ephemeral resources in their current form. + +### See how ephemeral resources behave during `terraform plan` and `terraform apply` + +The [documentation](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_key) for the `google_service_account_key` ephemeral resource has a simple example that you can use to view how ephemeral resources behave during plan and apply operations: + +```hcl +resource "google_service_account" "myaccount" { + account_id = "dev-foo-account" +} + +resource "google_service_account_key" "mykey" { + service_account_id = google_service_account.myaccount.name +} + +ephemeral "google_service_account_key" "mykey" { + name = google_service_account_key.mykey.name + public_key_type = "TYPE_X509_PEM_FILE" +} +``` + +During `terraform plan` you will see that the ephemeral resource is deferred, as it depends on other resources for its arguments: + +``` +ephemeral.google_service_account_key.mykey: Configuration unknown, deferring... + +Terraform used the selected providers to generate the +following execution plan. Resource actions are indicated +with the following symbols: + + create + +Terraform will perform the following actions: + + # google_service_account.myaccount will be created + + resource "google_service_account" "myaccount" { + ... +``` + +During `terrform apply` you will see the ephemeral resource is the final resource to be evaluated, because it depends on the two other resources, and the ephemeral resource is not reflected in the statistics about how many resources were created during the apply action: + +``` +ephemeral.google_service_account_key.mykey: Opening... +ephemeral.google_service_account_key.mykey: Opening complete after 1s +ephemeral.google_service_account_key.mykey: Closing... +ephemeral.google_service_account_key.mykey: Closing complete after 0s + +Apply complete! Resources: 2 added, 0 changed, 0 destroyed. +``` + +If you run the example using the local backend you can also inspect the state, where you will see that the ephemeral resource is not represented. + + +### Use ephemeral resources to configure the Google Cloud provider + +The [documentation](https://registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/service_account_access_token) for the `google_service_account_access_token` ephemeral resource demonstrates how it can be used to configure the provider. Check that ephemeral resource's documentation for details about the IAM permissions required for this example to work: + +```hcl +provider "google" { +} + + +ephemeral "google_service_account_access_token" "default" { + provider = google + target_service_account = "service_B@projectB.iam.gserviceaccount.com" + scopes = ["userinfo-email", "cloud-platform"] + lifetime = "300s" +} + +provider "google" { + alias = "impersonated" + access_token = ephemeral.google_service_account_access_token.default.access_token +} + +data "google_client_openid_userinfo" "me" { + provider = google.impersonated +} + +output "target-email" { + value = data.google_client_openid_userinfo.me.email +} +``` +