diff --git a/mmv1/provider/terraform.go b/mmv1/provider/terraform.go index 0820c16caac2..056a1ffeb893 100644 --- a/mmv1/provider/terraform.go +++ b/mmv1/provider/terraform.go @@ -313,7 +313,7 @@ func (t Terraform) getCommonCopyFiles(versionName string, generateCode, generate // save the folder name to foldersCopiedToGoogleDir var foldersCopiedToGoogleDir []string if generateCode { - foldersCopiedToGoogleDir = []string{"third_party/terraform/services", "third_party/terraform/acctest", "third_party/terraform/sweeper", "third_party/terraform/provider", "third_party/terraform/tpgdclresource", "third_party/terraform/tpgiamresource", "third_party/terraform/tpgresource", "third_party/terraform/transport", "third_party/terraform/fwmodels", "third_party/terraform/fwprovider", "third_party/terraform/fwtransport", "third_party/terraform/fwresource", "third_party/terraform/verify", "third_party/terraform/envvar", "third_party/terraform/functions", "third_party/terraform/test-fixtures"} + foldersCopiedToGoogleDir = []string{"third_party/terraform/services", "third_party/terraform/acctest", "third_party/terraform/sweeper", "third_party/terraform/provider", "third_party/terraform/tpgdclresource", "third_party/terraform/tpgiamresource", "third_party/terraform/tpgresource", "third_party/terraform/transport", "third_party/terraform/fwmodels", "third_party/terraform/fwprovider", "third_party/terraform/fwtransport", "third_party/terraform/fwresource", "third_party/terraform/fwutils", "third_party/terraform/fwvalidators", "third_party/terraform/verify", "third_party/terraform/envvar", "third_party/terraform/functions", "third_party/terraform/test-fixtures"} } googleDir := "google" if versionName != "ga" { diff --git a/mmv1/provider/terraform/common~copy.yaml b/mmv1/provider/terraform/common~copy.yaml index b9ad0c850979..1784bffb4170 100644 --- a/mmv1/provider/terraform/common~copy.yaml +++ b/mmv1/provider/terraform/common~copy.yaml @@ -101,6 +101,20 @@ '<%= dir -%>/fwprovider/<%= fname -%>': 'third_party/terraform/fwprovider/<%= fname -%>' <% end -%> +<% + Dir["third_party/terraform/fwutils/*.go"].each do |file_path| + fname = file_path.split('/')[-1] +-%> +'<%= dir -%>/fwutils/<%= fname -%>': 'third_party/terraform/fwutils/<%= fname -%>' +<% end -%> + +<% + Dir["third_party/terraform/fwvalidators/*.go"].each do |file_path| + fname = file_path.split('/')[-1] +-%> +'<%= dir -%>/fwvalidators/<%= fname -%>': 'third_party/terraform/fwvalidators/<%= fname -%>' +<% end -%> + <% Dir["third_party/terraform/fwtransport/*.go"].each do |file_path| fname = file_path.split('/')[-1] diff --git a/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl b/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl index 615feac27771..1049fe1638ce 100644 --- a/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl +++ b/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-google/google/fwvalidators" "github.com/hashicorp/terraform-provider-google/google/functions" "github.com/hashicorp/terraform-provider-google/google/fwmodels" "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" @@ -82,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{ @@ -92,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{ @@ -108,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{ @@ -242,7 +243,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{ @@ -320,6 +321,8 @@ func (p *FrameworkProvider) Functions(_ context.Context) []func() function.Funct // 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{ - // TODO! + resourcemanager.GoogleEphemeralServiceAccountAccessToken, + resourcemanager.GoogleEphemeralServiceAccountIdToken, + resourcemanager.GoogleEphemeralServiceAccountJwt, } -} \ No newline at end of file +} diff --git a/mmv1/third_party/terraform/fwutils/utils.go b/mmv1/third_party/terraform/fwutils/utils.go new file mode 100644 index 000000000000..eee57edde3c8 --- /dev/null +++ b/mmv1/third_party/terraform/fwutils/utils.go @@ -0,0 +1,12 @@ +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/mmv1/third_party/terraform/fwprovider/framework_validators.go b/mmv1/third_party/terraform/fwvalidators/framework_validators.go similarity index 55% rename from mmv1/third_party/terraform/fwprovider/framework_validators.go rename to mmv1/third_party/terraform/fwvalidators/framework_validators.go index 3af98fd9db92..f0c273df7e96 100644 --- a/mmv1/third_party/terraform/fwprovider/framework_validators.go +++ b/mmv1/third_party/terraform/fwvalidators/framework_validators.go @@ -1,9 +1,10 @@ -package fwprovider +package fwvalidators import ( "context" "fmt" "os" + "regexp" "time" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -115,3 +116,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/mmv1/third_party/terraform/fwvalidators/framework_validators_test.go b/mmv1/third_party/terraform/fwvalidators/framework_validators_test.go new file mode 100644 index 000000000000..6aa1a0c558a3 --- /dev/null +++ b/mmv1/third_party/terraform/fwvalidators/framework_validators_test.go @@ -0,0 +1,309 @@ +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/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/fwvalidators" + + transport_tpg "github.com/hashicorp/terraform-provider-google/google/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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token.go new file mode 100644 index 000000000000..8b7b5e7c3290 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token.go @@ -0,0 +1,152 @@ +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/google/fwtransport" + "github.com/hashicorp/terraform-provider-google/google/fwutils" + "github.com/hashicorp/terraform-provider-google/google/fwvalidators" + "github.com/hashicorp/terraform-provider-google/google/tpgresource" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountAccessToken{} + +func GoogleEphemeralServiceAccountAccessToken() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountAccessToken{} +} + +type googleEphemeralServiceAccountAccessToken struct { + providerConfig *fwtransport.FrameworkProviderConfig +} + +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.(*fwtransport.FrameworkProviderConfig) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *fwtransport.FrameworkProviderConfig, 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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token_test.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token_test.go new file mode 100644 index 000000000000..27ffe2af58b8 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_access_token_test.go @@ -0,0 +1,109 @@ +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token.go new file mode 100644 index 000000000000..312856c4d14d --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token.go @@ -0,0 +1,171 @@ +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/google/fwmodels" + "github.com/hashicorp/terraform-provider-google/google/fwtransport" + "github.com/hashicorp/terraform-provider-google/google/fwutils" + "github.com/hashicorp/terraform-provider-google/google/fwvalidators" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountIdToken{} + +func GoogleEphemeralServiceAccountIdToken() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountIdToken{} +} + +type googleEphemeralServiceAccountIdToken struct { + providerConfig *fwtransport.FrameworkProviderConfig +} + +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.(*fwtransport.FrameworkProviderConfig) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *fwtransport.FrameworkProviderConfig, 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() + + // TODO: This is a temporary solution to get the credentials from the provider config. + // we'll address this once muxing issues are resolved. + model := fwmodels.ProviderModel{ + Credentials: p.providerConfig.Credentials, + AccessToken: p.providerConfig.AccessToken, + ImpersonateServiceAccount: p.providerConfig.ImpersonateServiceAccount, + ImpersonateServiceAccountDelegates: p.providerConfig.ImpersonateServiceAccountDelegates, + Project: p.providerConfig.Project, + BillingProject: p.providerConfig.BillingProject, + Scopes: p.providerConfig.Scopes, + UniverseDomain: p.providerConfig.UniverseDomain, + } + creds := fwtransport.GetCredentials(ctx, model, false, &resp.Diagnostics) + 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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token_test.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token_test.go new file mode 100644 index 000000000000..b35af47cf56e --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_id_token_test.go @@ -0,0 +1,107 @@ +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/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_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_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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt.go new file mode 100644 index 000000000000..7d1117f2b1d4 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt.go @@ -0,0 +1,143 @@ +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/google/fwtransport" + "github.com/hashicorp/terraform-provider-google/google/fwutils" + "github.com/hashicorp/terraform-provider-google/google/fwvalidators" + "google.golang.org/api/iamcredentials/v1" +) + +var _ ephemeral.EphemeralResource = &googleEphemeralServiceAccountJwt{} + +func GoogleEphemeralServiceAccountJwt() ephemeral.EphemeralResource { + return &googleEphemeralServiceAccountJwt{} +} + +type googleEphemeralServiceAccountJwt struct { + providerConfig *fwtransport.FrameworkProviderConfig +} + +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.(*fwtransport.FrameworkProviderConfig) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *fwtransport.FrameworkProviderConfig, 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/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt_test.go b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt_test.go new file mode 100644 index 000000000000..89091c887bfd --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/ephemeral_google_service_account_jwt_test.go @@ -0,0 +1,106 @@ +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/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/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_access_token.html.markdown b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_access_token.html.markdown new file mode 100644 index 000000000000..c51a08df48e0 --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_access_token.html.markdown @@ -0,0 +1,78 @@ +--- +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" { +} + +data "google_client_config" "default" { + 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/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_id_token.html.markdown b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_id_token.html.markdown new file mode 100644 index 000000000000..00cd9c30b0e4 --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_id_token.html.markdown @@ -0,0 +1,89 @@ +--- +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. + `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. + `google_service_account_access_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/" + } + + ``` + +## Example Usage - Invoking Cloud Run Endpoint + + The following configuration will invoke [Cloud Run](https://cloud.google.com/run/docs/authenticating/service-to-service) endpoint where the service account for Terraform has been granted `roles/run.invoker` role previously. + +```hcl + +ephemeral "google_service_account_id_token" "oidc" { + target_audience = "https://your.cloud.run.app/" +} + +data "http" "cloudrun" { + url = "https://your.cloud.run.app/" + request_headers = { + Authorization = "Bearer ${ephemeral.google_service_account_id_token.oidc.id_token}" + } +} + + +output "cloud_run_response" { + value = data.http.cloudrun.body +} +``` + +## 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/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_jwt.html.markdown b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_jwt.html.markdown new file mode 100644 index 000000000000..ea9d1413cf3d --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/ephemeral-resources/service_account_jwt.html.markdown @@ -0,0 +1,41 @@ +--- +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: 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`.