Skip to content

Commit a7bb8b2

Browse files
BBBmaurileykarson
authored andcommitted
add external_credentials support in provider block (GoogleCloudPlatform#12714)
Co-authored-by: Riley Karson <[email protected]>
1 parent 8d39fa9 commit a7bb8b2

10 files changed

+652
-18
lines changed

mmv1/third_party/terraform/fwmodels/provider_model.go.tmpl

+8
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ import (
55
"github.com/hashicorp/terraform-plugin-framework/types"
66
)
77

8+
// ExternalCredentialsModel contains the information necessary to retrieve external credentials (like Workload Identity Federation credentials) using the user-defined function retrieval method (https://pkg.go.dev/golang.org/x/oauth2/google/externalaccount)
9+
type ExternalCredentialsModel struct {
10+
Audience types.String `tfsdk:"audience"`
11+
ServiceAccountEmail types.String `tfsdk:"service_account_email"`
12+
IdentityToken types.String `tfsdk:"identity_token"`
13+
}
14+
815
// ProviderModel maps provider schema data to a Go type.
916
// When the plugin-framework provider is configured, the Configure function receives data about
1017
// the provider block in the configuration. That data is used to populate this struct.
1118
type ProviderModel struct {
19+
ExternalCredentials []ExternalCredentialsModel `tfsdk:"external_credentials"`
1220
Credentials types.String `tfsdk:"credentials"`
1321
AccessToken types.String `tfsdk:"access_token"`
1422
ImpersonateServiceAccount types.String `tfsdk:"impersonate_service_account"`

mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl

+25-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
"github.com/hashicorp/terraform-provider-google/google/functions"
2222
"github.com/hashicorp/terraform-provider-google/google/fwmodels"
2323
"github.com/hashicorp/terraform-provider-google/google/services/resourcemanager"
24-
"github.com/hashicorp/terraform-provider-google/version"
24+
"github.com/hashicorp/terraform-provider-google/version"
2525
{{- if ne $.TargetVersionName "ga" }}
2626
"github.com/hashicorp/terraform-provider-google/google/services/firebase"
2727
{{- end }}
@@ -252,6 +252,30 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest,
252252
},
253253
},
254254
},
255+
"external_credentials": schema.ListNestedBlock{
256+
NestedObject: schema.NestedBlockObject{
257+
Attributes: map[string]schema.Attribute{
258+
"audience": schema.StringAttribute{
259+
Required: true,
260+
Validators: []validator.String{
261+
fwvalidators.NonEmptyStringValidator(),
262+
},
263+
},
264+
"service_account_email": schema.StringAttribute{
265+
Required: true,
266+
Validators: []validator.String{
267+
fwvalidators.ServiceAccountEmailValidator{},
268+
},
269+
},
270+
"identity_token": schema.StringAttribute{
271+
Required: true,
272+
Validators: []validator.String{
273+
fwvalidators.JWTValidator(),
274+
},
275+
},
276+
},
277+
},
278+
},
255279
},
256280
}
257281

mmv1/third_party/terraform/fwvalidators/framework_validators.go

+56
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package fwvalidators
22

33
import (
44
"context"
5+
"encoding/base64"
56
"fmt"
67
"os"
78
"regexp"
9+
"strings"
810
"time"
911

1012
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
@@ -206,3 +208,57 @@ func (v BoundedDuration) ValidateString(ctx context.Context, req validator.Strin
206208
)
207209
}
208210
}
211+
212+
type jwtValidator struct {
213+
}
214+
215+
func (v jwtValidator) Description(_ context.Context) string {
216+
return "value must be a valid JWT token"
217+
}
218+
219+
func (v jwtValidator) MarkdownDescription(ctx context.Context) string {
220+
return v.Description(ctx)
221+
}
222+
223+
func (v jwtValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) {
224+
if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
225+
return
226+
}
227+
228+
value := request.ConfigValue.ValueString()
229+
230+
if value == "" {
231+
response.Diagnostics.AddAttributeError(
232+
request.Path,
233+
"Invalid JWT Token",
234+
"JWT token must not be empty",
235+
)
236+
return
237+
}
238+
239+
// JWT consists of 3 parts separated by dots: header.payload.signature
240+
parts := strings.Split(value, ".")
241+
if len(parts) != 3 {
242+
response.Diagnostics.AddAttributeError(
243+
request.Path,
244+
"Invalid JWT Format",
245+
"JWT token must have 3 parts separated by dots (header.payload.signature)",
246+
)
247+
return
248+
}
249+
250+
// Check that each part is base64 encoded
251+
for i, part := range parts {
252+
if _, err := base64.RawURLEncoding.DecodeString(part); err != nil {
253+
response.Diagnostics.AddAttributeError(
254+
request.Path,
255+
"Invalid JWT Encoding",
256+
fmt.Sprintf("Part %d of JWT is not valid base64: %v", i+1, err),
257+
)
258+
}
259+
}
260+
}
261+
262+
func JWTValidator() validator.String {
263+
return jwtValidator{}
264+
}

mmv1/third_party/terraform/provider/provider.go.tmpl

+44-10
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,40 @@ func Provider() *schema.Provider {
3737
Type: schema.TypeString,
3838
Optional: true,
3939
ValidateFunc: ValidateCredentials,
40-
ConflictsWith: []string{"access_token"},
40+
ConflictsWith: []string{"access_token", "external_credentials"},
4141
},
4242

4343
"access_token": {
4444
Type: schema.TypeString,
4545
Optional: true,
4646
ValidateFunc: ValidateEmptyStrings,
47-
ConflictsWith: []string{"credentials"},
47+
ConflictsWith: []string{"credentials", "external_credentials"},
48+
},
49+
50+
"external_credentials": {
51+
Type: schema.TypeList,
52+
MaxItems: 1,
53+
Optional: true,
54+
ConflictsWith: []string{"credentials", "access_token"},
55+
Elem: &schema.Resource{
56+
Schema: map[string]*schema.Schema{
57+
"audience": {
58+
Type: schema.TypeString,
59+
Required: true,
60+
ValidateFunc: ValidateEmptyStrings,
61+
},
62+
"service_account_email": {
63+
Type: schema.TypeString,
64+
Required: true,
65+
ValidateFunc: ValidateServiceAccountEmail,
66+
},
67+
"identity_token": {
68+
Type: schema.TypeString,
69+
Required: true,
70+
ValidateFunc: ValidateJWT,
71+
},
72+
},
73+
},
4874
},
4975

5076
"impersonate_service_account": {
@@ -257,19 +283,27 @@ func ProviderConfigure(ctx context.Context, d *schema.ResourceData, p *schema.Pr
257283
config.RequestReason = v.(string)
258284
}
259285

260-
// Check for primary credentials in config. Note that if neither is set, ADCs
286+
// Check for primary credentials in config. Note that if none of these values are set, ADCs
261287
// will be used if available.
262-
if v, ok := d.GetOk("access_token"); ok {
263-
config.AccessToken = v.(string)
264-
}
288+
if v, ok := d.GetOk("external_credentials"); ok {
289+
external, err := transport_tpg.ExpandExternalCredentialsConfig(v)
290+
if err != nil {
291+
return nil, diag.FromErr(err)
292+
}
293+
config.ExternalCredentials = external
294+
} else {
295+
if v, ok := d.GetOk("access_token"); ok {
296+
config.AccessToken = v.(string)
297+
}
265298

266-
if v, ok := d.GetOk("credentials"); ok {
267-
config.Credentials = v.(string)
299+
if v, ok := d.GetOk("credentials"); ok {
300+
config.Credentials = v.(string)
301+
}
268302
}
269303

270-
// only check environment variables if neither value was set in config- this
304+
// only check environment variables if none of these values are set in config- this
271305
// means config beats env var in all cases.
272-
if config.AccessToken == "" && config.Credentials == "" {
306+
if config.ExternalCredentials == nil && config.AccessToken == "" && config.Credentials == "" {
273307
config.Credentials = transport_tpg.MultiEnvSearch([]string{
274308
"GOOGLE_CREDENTIALS",
275309
"GOOGLE_CLOUD_KEYFILE_JSON",

mmv1/third_party/terraform/provider/provider_internal_test.go

+65
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,71 @@ func TestProvider_ValidateCredentials(t *testing.T) {
8585
}
8686
}
8787

88+
func TestProvider_ValidateJWT(t *testing.T) {
89+
cases := map[string]struct {
90+
ConfigValue interface{}
91+
ValueNotProvided bool
92+
ExpectedWarnings []string
93+
ExpectedErrors []error
94+
}{
95+
"a valid JWT is accepted": {
96+
ConfigValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
97+
},
98+
"an empty JWT is rejected": {
99+
ConfigValue: "",
100+
ExpectedErrors: []error{
101+
errors.New("\"\" cannot be empty"),
102+
},
103+
},
104+
"a JWT with invalid base64 parts is rejected": {
105+
ConfigValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid-signature",
106+
ExpectedErrors: []error{
107+
errors.New("part 3 of JWT is not valid base64: illegal base64 data at input byte 16"),
108+
},
109+
},
110+
"a JWT with incorrect format (not 3 parts) is rejected": {
111+
ConfigValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ",
112+
ExpectedErrors: []error{
113+
errors.New("\"\" is not a valid JWT format"),
114+
},
115+
},
116+
"unconfigured value is not valid": {
117+
ValueNotProvided: true,
118+
ExpectedErrors: []error{
119+
errors.New("\"\" cannot be empty"),
120+
},
121+
},
122+
}
123+
124+
for tn, tc := range cases {
125+
t.Run(tn, func(t *testing.T) {
126+
127+
// Arrange
128+
var configValue interface{}
129+
if !tc.ValueNotProvided {
130+
configValue = tc.ConfigValue
131+
}
132+
133+
// Act
134+
ws, es := provider.ValidateJWT(configValue, "")
135+
136+
// Assert
137+
if len(ws) != len(tc.ExpectedWarnings) {
138+
t.Fatalf("Expected %d warnings, got %d: %v", len(tc.ExpectedWarnings), len(ws), ws)
139+
}
140+
if len(es) != len(tc.ExpectedErrors) {
141+
t.Fatalf("Expected %d errors, got %d: %v", len(tc.ExpectedErrors), len(es), es)
142+
}
143+
144+
for i := 0; i < len(tc.ExpectedErrors) && i < len(es); i++ {
145+
if es[i].Error() != tc.ExpectedErrors[i].Error() {
146+
t.Fatalf("Expected error %d to be \"%s\", got \"%s\"", i+1, tc.ExpectedErrors[i], es[i])
147+
}
148+
}
149+
})
150+
}
151+
}
152+
88153
func TestProvider_ValidateEmptyStrings(t *testing.T) {
89154
cases := map[string]struct {
90155
ConfigValue interface{}

0 commit comments

Comments
 (0)