diff --git a/CHANGELOG.md b/CHANGELOG.md index 80134067..023c5f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Changed + +- Replaced `api_oidc_config` with `jwt_issuer` resource + ## [1.8.0] - 2024-09-18 ### Added diff --git a/docs/resources/api_oidc_config.md b/docs/resources/api_oidc_config.md deleted file mode 100644 index 8b5f5e34..00000000 --- a/docs/resources/api_oidc_config.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -# generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "cockroach_api_oidc_config Resource - terraform-provider-cockroach" -subcategory: "" -description: |- - Configuration to allow external OIDC providers to issue tokens for use with CC API. ---- - -# cockroach_api_oidc_config (Resource) - -Configuration to allow external OIDC providers to issue tokens for use with CC API. - -## Example Usage - -```terraform -resource "cockroach_api_oidc_config" "example" { - issuer = "https://accounts.google.com" - audience = "test_audience" - jwks = "{\"keys\":[{\"alg\":\"RS256\",\"e\":\"AQAB\",\"kid\":\"test_kid1\",\"kty\":\"RSA\",\"n\":\"09lq1lCEuteonwDJOhGTDak11ThplZuC9JEWQNdBnBSQwlkJQIE7A7nTBO0xTibcsh2HwYkC-N_Gs1jP4iwN3dRqnu5FwG2ct5mY8KLwJiHzToFC0MKenSFQCy0FviNtOnpiObcUlDvR2NDeNtMl_6SPzcQEt7GUTBBYZgoAxPmOgevki6ZNO6Y86xFqx3y6v8EPwW010AiC60r4AHGCTBhYF4uqmq5JH2UU4dDh9Udc-9LZxlSqPwJvnKDG2GjcnD8TsU3wjfEM_nRmx3dnXsrZUXYfNGtdv5dlHywf5AhkJmTavqcsJkgrNA-PNBghFMcCR816_kCIkCYWLWC5vQ\"}]}" - claim = "sub" - identity_map = [ - { - token_identity = "token_identity" - cc_identity = "cc_identity" - is_regex = false - }, - { - token_identity = "(.*)" - cc_identity = "\\1@example.com" - is_regex = true - }, - ] -} -``` - - -## Schema - -### Required - -- `audience` (String) The audience that CC API should accept for this API OIDC Configuration. -- `issuer` (String) The issuer of tokens for the API OIDC Configuration. Usually this is a url. -- `jwks` (String) The JSON Web Key Set used to check the signature of the JWTs. - -### Optional - -- `claim` (String) The JWT claim that should be used as the user identifier. Defaults to the subject. -- `identity_map` (Attributes List) The mapping rules to convert token user identifiers into a new form. (see [below for nested schema](#nestedatt--identity_map)) - -### Read-Only - -- `id` (String) ID of the API OIDC Configuration. - - -### Nested Schema for `identity_map` - -Required: - -- `cc_identity` (String) The username (email or service account id) of the CC user that the token should map to. -- `token_identity` (String) The token value that needs to be mapped. - -Optional: - -- `is_regex` (Boolean) Indicates that the token_principal field is a regex value. diff --git a/docs/resources/jwt_issuer.md b/docs/resources/jwt_issuer.md new file mode 100644 index 00000000..7f64412f --- /dev/null +++ b/docs/resources/jwt_issuer.md @@ -0,0 +1,58 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "cockroach_jwt_issuer Resource - terraform-provider-cockroach" +subcategory: "" +description: |- + Configuration to manage external JWT Issuers to access CockroachDB Cloud API via JWT. +--- + +# cockroach_jwt_issuer (Resource) + +Configuration to manage external JWT Issuers to access CockroachDB Cloud API via JWT. + +## Example Usage + +```terraform +resource "cockroach_jwt_issuer" "example" { + issuer_url = "https://accounts.google.com" + audience = "test_audience" + jwks = "{\"keys\":[{\"alg\":\"RS256\",\"e\":\"AQAB\",\"kid\":\"test_kid1\",\"kty\":\"RSA\",\"n\":\"09lq1lCEuteonwDJOhGTDak11ThplZuC9JEWQNdBnBSQwlkJQIE7A7nTBO0xTibcsh2HwYkC-N_Gs1jP4iwN3dRqnu5FwG2ct5mY8KLwJiHzToFC0MKenSFQCy0FviNtOnpiObcUlDvR2NDeNtMl_6SPzcQEt7GUTBBYZgoAxPmOgevki6ZNO6Y86xFqx3y6v8EPwW010AiC60r4AHGCTBhYF4uqmq5JH2UU4dDh9Udc-9LZxlSqPwJvnKDG2GjcnD8TsU3wjfEM_nRmx3dnXsrZUXYfNGtdv5dlHywf5AhkJmTavqcsJkgrNA-PNBghFMcCR816_kCIkCYWLWC5vQ\"}]}" + claim = "email" + identity_map = [ + { + token_identity = "token_identity" + cc_identity = "cc_identity" + }, + { + token_identity = "(.*)" + cc_identity = "\\1@example.com" + }, + ] +} +``` + + +## Schema + +### Required + +- `audience` (String) The intended audience for consuming the JWT. +- `issuer_url` (String) The URL of the server issuing JWTs. + +### Optional + +- `claim` (String) Used to identify the user from the external Identity Provider. Defaults to "sub". +- `identity_map` (Attributes List) A list of mappings to map the external token identity into CockroachDB Cloud. (see [below for nested schema](#nestedatt--identity_map)) +- `jwks` (String) A set of public keys (JWKS) used to verify the JWT. + +### Read-Only + +- `id` (String) The unique identifier of the JWT Issuer resource. + + +### Nested Schema for `identity_map` + +Required: + +- `cc_identity` (String) Specifies how to map the fetched token identity to an identity in CockroachDB Cloud. In case of a regular expression for token_identity, this must contain a \1 placeholder for the matched content. +- `token_identity` (String) Specifies how to fetch external identity from the token claim. A regular expression must start with a forward slash. The regular expression must be in RE2 compatible syntax. For further details, please see https://github.com/google/re2/wiki/Syntax. diff --git a/examples/resources/cockroach_api_oidc_config/resource.tf b/examples/resources/cockroach_api_oidc_config/resource.tf deleted file mode 100644 index a7de0a18..00000000 --- a/examples/resources/cockroach_api_oidc_config/resource.tf +++ /dev/null @@ -1,18 +0,0 @@ -resource "cockroach_api_oidc_config" "example" { - issuer = "https://accounts.google.com" - audience = "test_audience" - jwks = "{\"keys\":[{\"alg\":\"RS256\",\"e\":\"AQAB\",\"kid\":\"test_kid1\",\"kty\":\"RSA\",\"n\":\"09lq1lCEuteonwDJOhGTDak11ThplZuC9JEWQNdBnBSQwlkJQIE7A7nTBO0xTibcsh2HwYkC-N_Gs1jP4iwN3dRqnu5FwG2ct5mY8KLwJiHzToFC0MKenSFQCy0FviNtOnpiObcUlDvR2NDeNtMl_6SPzcQEt7GUTBBYZgoAxPmOgevki6ZNO6Y86xFqx3y6v8EPwW010AiC60r4AHGCTBhYF4uqmq5JH2UU4dDh9Udc-9LZxlSqPwJvnKDG2GjcnD8TsU3wjfEM_nRmx3dnXsrZUXYfNGtdv5dlHywf5AhkJmTavqcsJkgrNA-PNBghFMcCR816_kCIkCYWLWC5vQ\"}]}" - claim = "sub" - identity_map = [ - { - token_identity = "token_identity" - cc_identity = "cc_identity" - is_regex = false - }, - { - token_identity = "(.*)" - cc_identity = "\\1@example.com" - is_regex = true - }, - ] -} diff --git a/examples/resources/cockroach_jwt_issuer/resource.tf b/examples/resources/cockroach_jwt_issuer/resource.tf new file mode 100644 index 00000000..2e58a7a9 --- /dev/null +++ b/examples/resources/cockroach_jwt_issuer/resource.tf @@ -0,0 +1,16 @@ +resource "cockroach_jwt_issuer" "example" { + issuer_url = "https://accounts.google.com" + audience = "test_audience" + jwks = "{\"keys\":[{\"alg\":\"RS256\",\"e\":\"AQAB\",\"kid\":\"test_kid1\",\"kty\":\"RSA\",\"n\":\"09lq1lCEuteonwDJOhGTDak11ThplZuC9JEWQNdBnBSQwlkJQIE7A7nTBO0xTibcsh2HwYkC-N_Gs1jP4iwN3dRqnu5FwG2ct5mY8KLwJiHzToFC0MKenSFQCy0FviNtOnpiObcUlDvR2NDeNtMl_6SPzcQEt7GUTBBYZgoAxPmOgevki6ZNO6Y86xFqx3y6v8EPwW010AiC60r4AHGCTBhYF4uqmq5JH2UU4dDh9Udc-9LZxlSqPwJvnKDG2GjcnD8TsU3wjfEM_nRmx3dnXsrZUXYfNGtdv5dlHywf5AhkJmTavqcsJkgrNA-PNBghFMcCR816_kCIkCYWLWC5vQ\"}]}" + claim = "email" + identity_map = [ + { + token_identity = "token_identity" + cc_identity = "cc_identity" + }, + { + token_identity = "(.*)" + cc_identity = "\\1@example.com" + }, + ] +} diff --git a/internal/provider/api_oidc_config_test.go b/internal/provider/api_oidc_config_test.go deleted file mode 100644 index b8955820..00000000 --- a/internal/provider/api_oidc_config_test.go +++ /dev/null @@ -1,185 +0,0 @@ -/* - Copyright 2023 The Cockroach Authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package provider - -import ( - "context" - "fmt" - "os" - "testing" - - "github.com/cockroachdb/cockroach-cloud-sdk-go/v3/pkg/client" - mock_client "github.com/cockroachdb/terraform-provider-cockroach/mock" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" -) - -// TestAccApiOidcConfigResource attempts to create, check, and destroy -// a real API OIDC Config. It will be skipped if TF_ACC isn't set. -// In order to work the ApiOidcEnabled Feature Flag must be enabled and -// the test org must have Org SSO enabled (no need for SAML/OIDC). -func TestAccApiOidcConfigResource(t *testing.T) { - t.Parallel() - issuer := fmt.Sprintf("issuer-%s", GenerateRandomString(4)) - audience := "audience" - jwks := "{}" - claim := "subject" - identityMap := []IdentityMapEntry{ - { - CcIdentity: types.StringValue("cc_id1"), - TokenIdentity: types.StringValue("token_id1"), - IsRegex: types.BoolValue(false), - }, - { - CcIdentity: types.StringValue("cc_id2"), - TokenIdentity: types.StringValue("token_id2"), - IsRegex: types.BoolValue(true), - }, - } - - testApiOidcConfigResource(t, issuer, audience, jwks, claim, identityMap, false) -} - -// TestIntegrationApiOidcConfigResource attempts to create, check, and destroy -// an API OIDC Config, but uses a mocked API service. -func TestIntegrationApiOidcConfigResource(t *testing.T) { - id := uuid.Must(uuid.NewUUID()) - if os.Getenv(CockroachAPIKey) == "" { - os.Setenv(CockroachAPIKey, "fake") - } - - ctrl := gomock.NewController(t) - s := mock_client.NewMockService(ctrl) - defer HookGlobal(&NewService, func(c *client.Client) client.Service { - return s - })() - issuer := "issuer" - audience := "audience" - claim := "claim" - jwks := "{}" - identityMap := []IdentityMapEntry{ - { - CcIdentity: types.StringValue("cc_id1"), - TokenIdentity: types.StringValue("token_id1"), - IsRegex: types.BoolValue(false), - }, - { - CcIdentity: types.StringValue("cc_id2"), - TokenIdentity: types.StringValue("token_id2"), - IsRegex: types.BoolValue(true), - }, - } - response := client.ApiOidcConfig{ - Id: id.String(), - Issuer: issuer, - Audience: audience, - Jwks: jwks, - Claim: &claim, - IdentityMap: identityMapFromTerraformState(&identityMap), - } - - s.EXPECT().GetApiOidcConfig(gomock.Any(), id.String()). - Return(&response, nil, nil).AnyTimes() - s.EXPECT().CreateApiOidcConfig(gomock.Any(), gomock.Any()). - Return(&response, nil, nil) - s.EXPECT().DeleteApiOidcConfig(gomock.Any(), id.String()). - Return(&response, nil, nil) - - testApiOidcConfigResource(t, issuer, audience, jwks, claim, identityMap, true) -} - -func testApiOidcConfigResource(t *testing.T, issuer, audience, jwks, claim string, identityMap []IdentityMapEntry, useMock bool) { - var ( - resourceNameTest = "cockroach_api_oidc_config.test" - ) - resource.Test(t, resource.TestCase{ - IsUnitTest: useMock, - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: getTestApiOidcConfig(issuer, audience, jwks, claim, identityMap), - Check: resource.ComposeTestCheckFunc( - testApiOidcConfig(resourceNameTest, issuer, audience, jwks, claim, identityMap), - resource.TestCheckResourceAttr(resourceNameTest, "issuer", issuer), - resource.TestCheckResourceAttr(resourceNameTest, "audience", audience), - resource.TestCheckResourceAttr(resourceNameTest, "jwks", jwks), - resource.TestCheckResourceAttr(resourceNameTest, "claim", claim), - ), - }, - { - ResourceName: resourceNameTest, - ImportState: true, - ImportStateVerify: true, - }, - }, - }) -} - -func testApiOidcConfig( - resourceName, issuer, audience, jwks, claim string, identityMap []IdentityMapEntry, -) resource.TestCheckFunc { - return func(s *terraform.State) error { - p := testAccProvider.(*provider) - p.service = NewService(cl) - - rs, ok := s.RootModule().Resources[resourceName] - if !ok { - return fmt.Errorf("not found: %s", resourceName) - } - - if rs.Primary.ID == "" { - return fmt.Errorf("no ID is set") - } - - traceAPICall("GetApiOidcConfig") - //nolint:staticcheck - roleResp, _, err := p.service.GetApiOidcConfig(context.TODO(), rs.Primary.ID) - if err == nil { - respIdentityMap := *roleResp.IdentityMap - if roleResp.Issuer == issuer && roleResp.Audience == audience && roleResp.Jwks == jwks && *roleResp.Claim == claim && *respIdentityMap[0].CcIdentity == identityMap[0].CcIdentity.ValueString() { - return nil - } - } - - return fmt.Errorf("API OIDC Config does not have correct values") - } -} - -func getTestApiOidcConfig(issuer, audience, jwks, claim string, identityMap []IdentityMapEntry) string { - identityMapString := "[\n" - for _, identityMapEntry := range identityMap { - identityMapString += "{\n" - identityMapString += fmt.Sprintf("token_identity = %s\n", identityMapEntry.TokenIdentity) - identityMapString += fmt.Sprintf("cc_identity = %s\n", identityMapEntry.CcIdentity) - identityMapString += fmt.Sprintf("is_regex = %s\n", identityMapEntry.IsRegex) - identityMapString += "},\n" - } - identityMapString += "]" - return fmt.Sprintf(` -resource "cockroach_api_oidc_config" "test" { - issuer = "%s" - audience = "%s" - jwks = "%s" - claim = "%s" - identity_map = %s -} -`, issuer, audience, jwks, claim, identityMapString) -} diff --git a/internal/provider/api_oidc_config.go b/internal/provider/jwt_issuers.go similarity index 51% rename from internal/provider/api_oidc_config.go rename to internal/provider/jwt_issuers.go index a4146b2d..a3ba653c 100644 --- a/internal/provider/api_oidc_config.go +++ b/internal/provider/jwt_issuers.go @@ -1,5 +1,5 @@ /* -Copyright 2023 The Cockroach Authors +Copyright 2024 The Cockroach Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -30,73 +30,70 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -type apiOidcConfigResource struct { +type jwtIssuerResource struct { provider *provider } -func (r *apiOidcConfigResource) Schema( +func NewJWTIssuerResource() resource.Resource { + return &jwtIssuerResource{} +} + +func (r *jwtIssuerResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, ) { resp.Schema = schema.Schema{ - MarkdownDescription: "Configuration to allow external OIDC providers to issue tokens for use with CC API.", + MarkdownDescription: "Configuration to manage external JWT Issuers to access CockroachDB Cloud API via JWT.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, - MarkdownDescription: "ID of the API OIDC Configuration.", + Description: "The unique identifier of the JWT Issuer resource.", }, - "issuer": schema.StringAttribute{ + "issuer_url": schema.StringAttribute{ Required: true, - Description: "The issuer of tokens for the API OIDC Configuration. Usually this is a url.", + Description: "The URL of the server issuing JWTs.", }, "audience": schema.StringAttribute{ Required: true, - Description: "The audience that CC API should accept for this API OIDC Configuration.", + Description: "The intended audience for consuming the JWT.", }, "jwks": schema.StringAttribute{ - Required: true, - Description: "The JSON Web Key Set used to check the signature of the JWTs.", + Optional: true, + Description: "A set of public keys (JWKS) used to verify the JWT.", }, "claim": schema.StringAttribute{ Optional: true, - Computed: true, - Description: "The JWT claim that should be used as the user identifier. Defaults to the subject.", + Description: "Used to identify the user from the external Identity Provider. Defaults to \"sub\".", }, "identity_map": schema.ListNestedAttribute{ + Optional: true, + Description: "A list of mappings to map the external token identity into CockroachDB Cloud.", NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "token_identity": schema.StringAttribute{ Required: true, - Description: "The token value that needs to be mapped.", + Description: "Specifies how to fetch external identity from the token claim. A regular expression must start with a forward slash. The regular expression must be in RE2 compatible syntax. For further details, please see https://github.com/google/re2/wiki/Syntax.", }, "cc_identity": schema.StringAttribute{ Required: true, - Description: "The username (email or service account id) of the CC user that the token should map to.", - }, - "is_regex": schema.BoolAttribute{ - Optional: true, - Computed: true, - Description: "Indicates that the token_principal field is a regex value.", + Description: "Specifies how to map the fetched token identity to an identity in CockroachDB Cloud. In case of a regular expression for token_identity, this must contain a \\1 placeholder for the matched content.", }, }, }, - Optional: true, - Computed: true, - Description: "The mapping rules to convert token user identifiers into a new form.", }, }, } } -func (r *apiOidcConfigResource) Metadata( +func (r *jwtIssuerResource) Metadata( _ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse, ) { - resp.TypeName = req.ProviderTypeName + "_api_oidc_config" + resp.TypeName = req.ProviderTypeName + "_jwt_issuer" } -func (r *apiOidcConfigResource) Configure( +func (r *jwtIssuerResource) Configure( _ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse, ) { if req.ProviderData == nil { @@ -109,7 +106,13 @@ func (r *apiOidcConfigResource) Configure( } } -func (r *apiOidcConfigResource) Create( +func (r *jwtIssuerResource) ImportState( + ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse, +) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *jwtIssuerResource) Create( ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse, ) { if r.provider == nil || !r.provider.configured { @@ -117,39 +120,38 @@ func (r *apiOidcConfigResource) Create( return } - var apiOIdcConfigSpec ApiOidcConfig - diags := req.Plan.Get(ctx, &apiOIdcConfigSpec) + var jwtIssuerSpec JWTIssuer + diags := req.Plan.Get(ctx, &jwtIssuerSpec) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - createRequest := &client.CreateApiOidcConfigRequest{ - Audience: apiOIdcConfigSpec.Audience.ValueString(), - Issuer: apiOIdcConfigSpec.Issuer.ValueString(), - Jwks: apiOIdcConfigSpec.Jwks.ValueString(), - Claim: apiOIdcConfigSpec.Claim.ValueStringPointer(), - IdentityMap: identityMapFromTerraformState(apiOIdcConfigSpec.IdentityMap), + createRequest := &client.AddJWTIssuerRequest{ + Audience: jwtIssuerSpec.Audience.ValueString(), + IssuerUrl: jwtIssuerSpec.IssuerURL.ValueString(), + Jwks: jwtIssuerSpec.Jwks.ValueStringPointer(), + Claim: jwtIssuerSpec.Claim.ValueStringPointer(), + IdentityMap: identityMapFromTerraformState(jwtIssuerSpec.IdentityMap), } - traceAPICall("CreateApiOidcConfig") - //nolint:staticcheck - apiResp, _, err := r.provider.service.CreateApiOidcConfig(ctx, createRequest) + traceAPICall("AddJWTIssuer") + apiResp, _, err := r.provider.service.AddJWTIssuer(ctx, createRequest) if err != nil { resp.Diagnostics.AddError( - "Error creating API OIDC Config", - fmt.Sprintf("Could not create API OIDC Config: %s", formatAPIErrorMessage(err)), + "Error creating JWT Issuer", + fmt.Sprintf("Could not create JWT Issuer: %s", formatAPIErrorMessage(err)), ) return } - loadApiOidcConfigToTerraformState(apiResp, &apiOIdcConfigSpec) - diags = resp.State.Set(ctx, apiOIdcConfigSpec) + loadJWTIssuerToTerraformState(apiResp, &jwtIssuerSpec) + diags = resp.State.Set(ctx, jwtIssuerSpec) resp.Diagnostics.Append(diags...) } -func (r *apiOidcConfigResource) Read( +func (r *jwtIssuerResource) Read( ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse, ) { if r.provider == nil || !r.provider.configured { @@ -157,40 +159,39 @@ func (r *apiOidcConfigResource) Read( return } - var state ApiOidcConfig + var state JWTIssuer diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - traceAPICall("GetApiOidcConfig") - //nolint:staticcheck - apiResp, httpResp, err := r.provider.service.GetApiOidcConfig(ctx, state.ID.ValueString()) + traceAPICall("GetJWTIssuer") + apiResp, httpResp, err := r.provider.service.GetJWTIssuer(ctx, state.ID.ValueString()) if err != nil { if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { resp.Diagnostics.AddWarning( - "API OIDC Config not found", - "API OIDC Config not found. API OIDC Config will be removed from state.") + "JWT Issuer not found", + "JWT Issuer not found. JWT Issuer will be removed from state.") resp.State.RemoveResource(ctx) } else { resp.Diagnostics.AddError( - "Error getting API OIDC Config", - fmt.Sprintf("Unexpected error retrieving API OIDC Config: %s", formatAPIErrorMessage(err))) + "Error getting JWT Issuer", + fmt.Sprintf("Unexpected error retrieving JWT Issuer: %s", formatAPIErrorMessage(err))) } return } - loadApiOidcConfigToTerraformState(apiResp, &state) + loadJWTIssuerToTerraformState(apiResp, &state) diags = resp.State.Set(ctx, state) resp.Diagnostics.Append(diags...) } -func (r *apiOidcConfigResource) Update( +func (r *jwtIssuerResource) Update( ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse, ) { - var plan ApiOidcConfig + var plan JWTIssuer diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -198,106 +199,97 @@ func (r *apiOidcConfigResource) Update( } // Get current state - var state ApiOidcConfig + var state JWTIssuer diags = req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - traceAPICall("UpdateApiOidcConfig") - //nolint:staticcheck - apiResp, _, err := r.provider.service.UpdateApiOidcConfig(ctx, plan.ID.ValueString(), &client.ApiOidcConfig1{ - Audience: plan.Audience.ValueString(), - Claim: plan.Claim.ValueStringPointer(), - IdentityMap: identityMapFromTerraformState(plan.IdentityMap), - Issuer: plan.Issuer.ValueString(), - Jwks: plan.Jwks.ValueString(), - }) + traceAPICall("UpdateJWTIssuer") + apiResp, _, err := r.provider.service.UpdateJWTIssuer( + ctx, + plan.ID.ValueString(), + &client.UpdateJWTIssuerRequest{ + Audience: plan.Audience.ValueStringPointer(), + Claim: plan.Claim.ValueStringPointer(), + IdentityMap: identityMapFromTerraformState(plan.IdentityMap), + IssuerUrl: plan.IssuerURL.ValueStringPointer(), + Jwks: plan.Jwks.ValueStringPointer(), + }, + ) if err != nil { resp.Diagnostics.AddError( - "Error update API OIDC Config", - fmt.Sprintf("Could not update API OIDC Config: %s", formatAPIErrorMessage(err)), + "Error updating JWT Issuer", + fmt.Sprintf("Could not update JWT Issuer: %s", formatAPIErrorMessage(err)), ) return } - loadApiOidcConfigToTerraformState(apiResp, &state) + loadJWTIssuerToTerraformState(apiResp, &state) diags = resp.State.Set(ctx, state) resp.Diagnostics.Append(diags...) } -func (r *apiOidcConfigResource) Delete( +func (r *jwtIssuerResource) Delete( ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse, ) { - var state ApiOidcConfig + var state JWTIssuer diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - traceAPICall("DeleteApiOidcConfig") - //nolint:staticcheck - _, _, err := r.provider.service.DeleteApiOidcConfig(ctx, state.ID.ValueString()) + traceAPICall("DeleteJWTIssuer") + _, _, err := r.provider.service.DeleteJWTIssuer(ctx, state.ID.ValueString()) if err != nil { resp.Diagnostics.AddError( - "Error deleting API OIDC Config", - fmt.Sprintf("Could not delete API OIDC Config: %s", formatAPIErrorMessage(err)), + "Error deleting JWT Issuer", + fmt.Sprintf("Could not delete JWT Issuer: %s", formatAPIErrorMessage(err)), ) return } - // Remove resource from state resp.State.RemoveResource(ctx) } -func (r *apiOidcConfigResource) ImportState( - ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse, -) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func NewApiOidcConfigResource() resource.Resource { - return &apiOidcConfigResource{} -} - -func loadApiOidcConfigToTerraformState( - apiOidcConfig *client.ApiOidcConfig, state *ApiOidcConfig, +func loadJWTIssuerToTerraformState( + jwtIssuer *client.JWTIssuer, state *JWTIssuer, ) { - state.ID = types.StringValue(apiOidcConfig.Id) - state.Audience = types.StringValue(apiOidcConfig.Audience) - state.Issuer = types.StringValue(apiOidcConfig.Issuer) - state.Jwks = types.StringValue(apiOidcConfig.Jwks) - state.Claim = types.StringPointerValue(apiOidcConfig.Claim) - state.IdentityMap = identityMapToTerraformState(apiOidcConfig.IdentityMap) + state.ID = types.StringValue(jwtIssuer.Id) + state.Audience = types.StringValue(jwtIssuer.Audience) + state.IssuerURL = types.StringValue(jwtIssuer.IssuerUrl) + state.Jwks = types.StringPointerValue(jwtIssuer.Jwks) + state.Claim = types.StringPointerValue(jwtIssuer.Claim) + state.IdentityMap = identityMapToTerraformState(jwtIssuer.IdentityMap) } -func identityMapFromTerraformState(identityMap *[]IdentityMapEntry) *[]client.ApiOidcIdentityMapEntry { +func identityMapFromTerraformState(identityMap *[]IdentityMapEntry) *[]client.JWTIssuerIdentityMapEntry { if identityMap == nil { return nil } - var out []client.ApiOidcIdentityMapEntry + var out []client.JWTIssuerIdentityMapEntry for _, mapEntry := range *identityMap { - out = append(out, client.ApiOidcIdentityMapEntry{ - CcIdentity: mapEntry.CcIdentity.ValueStringPointer(), - IsRegex: mapEntry.IsRegex.ValueBoolPointer(), + out = append(out, client.JWTIssuerIdentityMapEntry{ TokenIdentity: mapEntry.TokenIdentity.ValueStringPointer(), + CcIdentity: mapEntry.CcIdentity.ValueStringPointer(), + // TODO: Need to remove once the field has been removed from the API. + IsRegex: types.BoolValue(false).ValueBoolPointer(), }) } return &out } -func identityMapToTerraformState(identityMap *[]client.ApiOidcIdentityMapEntry) *[]IdentityMapEntry { +func identityMapToTerraformState(identityMap *[]client.JWTIssuerIdentityMapEntry) *[]IdentityMapEntry { if identityMap == nil { return nil } var out []IdentityMapEntry for _, mapEntry := range *identityMap { out = append(out, IdentityMapEntry{ - CcIdentity: types.StringPointerValue(mapEntry.CcIdentity), - IsRegex: types.BoolPointerValue(mapEntry.IsRegex), TokenIdentity: types.StringPointerValue(mapEntry.TokenIdentity), + CcIdentity: types.StringPointerValue(mapEntry.CcIdentity), }) } return &out diff --git a/internal/provider/jwt_issuers_test.go b/internal/provider/jwt_issuers_test.go new file mode 100644 index 00000000..0e8a6ae2 --- /dev/null +++ b/internal/provider/jwt_issuers_test.go @@ -0,0 +1,385 @@ +/* + Copyright 2024 The Cockroach Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "testing" + + "github.com/cockroachdb/cockroach-cloud-sdk-go/v3/pkg/client" + mock_client "github.com/cockroachdb/terraform-provider-cockroach/mock" + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + testIssuerURL = "https://issuer-%s.com" + + testAudience = "audience" + testAudienceUpdated = "audience_updated" + + testJWKS = "{}" + testJWKSUpdated = "{updated: 1}" + + testClaim = "sub" + testClaimUpdated = "email" + + testTokenID1 = "token_id1" + testCCID1 = "cc_id1" + + testTokenID2 = "token_id2" + testCCID2 = "cc_id2" + + testIdentityMap = []IdentityMapEntry{ + { + TokenIdentity: types.StringValue(testTokenID1), + CcIdentity: types.StringValue(testCCID1), + }, + { + TokenIdentity: types.StringValue(testTokenID2), + CcIdentity: types.StringValue(testCCID2), + }, + } + testIdentityMapUpdated = []IdentityMapEntry{ + { + TokenIdentity: types.StringValue(testTokenID1), + CcIdentity: types.StringValue(testCCID1), + }, + } +) + +// TestAccJWTIssuerResource attempts to create, check, and destroy +// a real JWT Issuer. It will be skipped if TF_ACC isn't set. +// In order to work, the `JwtIssuerEnabled` Feature Flag must be enabled and +// the test org must have Org SSO enabled (no need for SAML/OIDC). +func TestAccJWTIssuerResource(t *testing.T) { + t.Parallel() + + issuerURL := fmt.Sprintf(testIssuerURL, GenerateRandomString(4)) + testJWTIssuerResource(t, issuerURL, false) +} + +// TestIntegrationJWTIssuerResource attempts to create, check, and destroy +// a JWT issuer resource, but uses a mocked API service. +func TestIntegrationJWTIssuerResource(t *testing.T) { + if os.Getenv(CockroachAPIKey) == "" { + require.NoError(t, os.Setenv(CockroachAPIKey, "fake")) + } + + ctrl := gomock.NewController(t) + s := mock_client.NewMockService(ctrl) + defer HookGlobal(&NewService, func(c *client.Client) client.Service { + return s + })() + + id := uuid.Must(uuid.NewUUID()) + issuerURL := fmt.Sprintf(testIssuerURL, GenerateRandomString(4)) + jwtIssuer := &client.JWTIssuer{ + Id: id.String(), + IssuerUrl: issuerURL, + Audience: testAudience, + Jwks: &testJWKS, + Claim: &testClaim, + IdentityMap: identityMapFromTerraformState(&testIdentityMap), + } + jwtIssuerUpdated := &client.JWTIssuer{ + Id: id.String(), + IssuerUrl: issuerURL, + Audience: testAudienceUpdated, + Jwks: &testJWKSUpdated, + Claim: &testClaimUpdated, + IdentityMap: identityMapFromTerraformState(&testIdentityMapUpdated), + } + + // Create + s.EXPECT().AddJWTIssuer(gomock.Any(), gomock.Any()). + Return(jwtIssuer, nil, nil) + s.EXPECT().GetJWTIssuer(gomock.Any(), id.String()). + Return(jwtIssuer, nil, nil).Times(3) + + // Update + s.EXPECT().UpdateJWTIssuer(gomock.Any(), id.String(), gomock.Any()). + Return(jwtIssuerUpdated, nil, nil) + s.EXPECT().GetJWTIssuer(gomock.Any(), id.String()). + Return(jwtIssuerUpdated, nil, nil).Times(3) + + // Delete + s.EXPECT().DeleteJWTIssuer(gomock.Any(), id.String()). + Return(jwtIssuerUpdated, nil, nil) + s.EXPECT().GetJWTIssuer(gomock.Any(), id.String()). + Return(nil, &http.Response{StatusCode: http.StatusNotFound}, errors.New("not found")) + + testJWTIssuerResource(t, issuerURL, true) +} + +func testJWTIssuerResource( + t *testing.T, issuerURL string, useMock bool, +) { + resourceNameTest := "cockroach_jwt_issuer.test" + resource.Test(t, resource.TestCase{ + IsUnitTest: useMock, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: getTestJWTIssuerCreateConfig(issuerURL), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resourceNameTest, + tfjsonpath.New("id"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + resourceNameTest, + tfjsonpath.New("id"), + knownvalue.StringRegexp( + regexp.MustCompile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + ), + ), + ), + }, + Check: resource.ComposeTestCheckFunc( + testJWTIssuer(resourceNameTest, issuerURL), + resource.TestCheckResourceAttr(resourceNameTest, "issuer_url", issuerURL), + resource.TestCheckResourceAttr(resourceNameTest, "audience", testAudience), + resource.TestCheckResourceAttr(resourceNameTest, "jwks", testJWKS), + resource.TestCheckResourceAttr(resourceNameTest, "claim", testClaim), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.#", "2"), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.0.token_identity", testIdentityMap[0].TokenIdentity.ValueString()), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.0.cc_identity", testIdentityMap[0].CcIdentity.ValueString()), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.1.token_identity", testIdentityMap[1].TokenIdentity.ValueString()), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.1.cc_identity", testIdentityMap[1].CcIdentity.ValueString()), + ), + }, + { + Config: getTestJWTIssuerUpdateConfig(issuerURL), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resourceNameTest, + tfjsonpath.New("id"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + resourceNameTest, + tfjsonpath.New("id"), + knownvalue.StringRegexp( + regexp.MustCompile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + ), + ), + ), + }, + Check: resource.ComposeTestCheckFunc( + testJWTIssuerUpdated(resourceNameTest, issuerURL), + resource.TestCheckResourceAttr(resourceNameTest, "issuer_url", issuerURL), + resource.TestCheckResourceAttr(resourceNameTest, "audience", testAudienceUpdated), + resource.TestCheckResourceAttr(resourceNameTest, "jwks", testJWKSUpdated), + resource.TestCheckResourceAttr(resourceNameTest, "claim", testClaimUpdated), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.#", "1"), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.0.token_identity", testIdentityMap[0].TokenIdentity.ValueString()), + resource.TestCheckResourceAttr(resourceNameTest, "identity_map.0.cc_identity", testIdentityMap[0].CcIdentity.ValueString()), + ), + }, + { + ResourceName: resourceNameTest, + ImportState: true, + ImportStateVerify: true, + Destroy: true, + }, + }, + CheckDestroy: testJWTIssuerDestroy(resourceNameTest), + }) +} + +func getTestJWTIssuerCreateConfig(issuerURL string) string { + identityMapString := "[\n" + for _, identityMapEntry := range testIdentityMap { + identityMapString += "{\n" + identityMapString += fmt.Sprintf("token_identity = %s\n", identityMapEntry.TokenIdentity) + identityMapString += fmt.Sprintf("cc_identity = %s\n", identityMapEntry.CcIdentity) + identityMapString += "},\n" + } + identityMapString += "]" + + return fmt.Sprintf(` +resource "cockroach_jwt_issuer" "test" { + issuer_url = "%s" + audience = "%s" + jwks = "%s" + claim = "%s" + identity_map = %s +} +`, issuerURL, testAudience, testJWKS, testClaim, identityMapString) +} + +func getTestJWTIssuerUpdateConfig(issuerURL string) string { + identityMapString := "[\n" + for _, identityMapEntry := range testIdentityMapUpdated { + identityMapString += "{\n" + identityMapString += fmt.Sprintf("token_identity = %s\n", identityMapEntry.TokenIdentity) + identityMapString += fmt.Sprintf("cc_identity = %s\n", identityMapEntry.CcIdentity) + identityMapString += "},\n" + } + identityMapString += "]" + + return fmt.Sprintf(` +resource "cockroach_jwt_issuer" "test" { + issuer_url = "%s" + audience = "%s" + jwks = "%s" + claim = "%s" + identity_map = %s +} +`, issuerURL, testAudienceUpdated, testJWKSUpdated, testClaimUpdated, identityMapString) +} + +func testJWTIssuer(resourceName, issuerURL string) resource.TestCheckFunc { + return func(s *terraform.State) error { + p := testAccProvider.(*provider) + p.service = NewService(cl) + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + expectedResp := &client.JWTIssuer{ + IssuerUrl: issuerURL, + Audience: testAudience, + Jwks: &testJWKS, + Claim: &testClaim, + IdentityMap: &[]client.JWTIssuerIdentityMapEntry{ + { + TokenIdentity: testIdentityMap[0].TokenIdentity.ValueStringPointer(), + CcIdentity: testIdentityMap[0].CcIdentity.ValueStringPointer(), + // TODO: Need to remove once the field has been removed from the API. + IsRegex: basetypes.NewBoolValue(false).ValueBoolPointer(), + }, + { + TokenIdentity: testIdentityMap[1].TokenIdentity.ValueStringPointer(), + CcIdentity: testIdentityMap[1].CcIdentity.ValueStringPointer(), + // TODO: Need to remove once the field has been removed from the API. + IsRegex: basetypes.NewBoolValue(false).ValueBoolPointer(), + }, + }, + } + + traceAPICall("GetJWTIssuer") + jwtIssuerResp, _, err := p.service.GetJWTIssuer(context.TODO(), rs.Primary.ID) + if err == nil { + // Copy over the resource ID as it is dynamic in nature. + expectedResp.Id = jwtIssuerResp.Id + if assert.ObjectsAreEqual(expectedResp, jwtIssuerResp) { + return nil + } + } + + return fmt.Errorf("JWT Issuer configuration mismatch, expected %+v, got %+v", + expectedResp, jwtIssuerResp) + } +} + +func testJWTIssuerUpdated(resourceName, issuerURL string) resource.TestCheckFunc { + return func(s *terraform.State) error { + p := testAccProvider.(*provider) + p.service = NewService(cl) + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + expectedResp := &client.JWTIssuer{ + IssuerUrl: issuerURL, + Audience: testAudienceUpdated, + Jwks: &testJWKSUpdated, + Claim: &testClaimUpdated, + IdentityMap: &[]client.JWTIssuerIdentityMapEntry{ + { + TokenIdentity: testIdentityMapUpdated[0].TokenIdentity.ValueStringPointer(), + CcIdentity: testIdentityMapUpdated[0].CcIdentity.ValueStringPointer(), + // TODO: Need to remove once the field has been removed from the API. + IsRegex: basetypes.NewBoolValue(false).ValueBoolPointer(), + }, + }, + } + + traceAPICall("GetJWTIssuer") + jwtIssuerResp, _, err := p.service.GetJWTIssuer(context.TODO(), rs.Primary.ID) + if err == nil { + // Copy over the resource ID as it is dynamic in nature. + expectedResp.Id = jwtIssuerResp.Id + if assert.ObjectsAreEqual(expectedResp, jwtIssuerResp) { + return nil + } + } + + return fmt.Errorf("JWT Issuer configuration mismatch, expected %+v, got %+v", + expectedResp, jwtIssuerResp) + } +} + +func testJWTIssuerDestroy(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) (err error) { + p := testAccProvider.(*provider) + p.service = NewService(cl) + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + traceAPICall("GetJWTIssuer") + _, httpResponse, err := p.service.GetJWTIssuer(context.TODO(), rs.Primary.ID) + if err != nil && httpResponse != nil && httpResponse.StatusCode == http.StatusNotFound { + return nil + } + + var statusCode int + if httpResponse != nil { + statusCode = httpResponse.StatusCode + } + return fmt.Errorf("JWT Issuer not destroyed, HTTP response code: %d, error: %v", statusCode, err) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index e8577dec..49d88762 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -307,9 +307,9 @@ type FolderDataSourceModel struct { ParentId types.String `tfsdk:"parent_id"` } -type ApiOidcConfig struct { +type JWTIssuer struct { ID types.String `tfsdk:"id"` - Issuer types.String `tfsdk:"issuer"` + IssuerURL types.String `tfsdk:"issuer_url"` Audience types.String `tfsdk:"audience"` Jwks types.String `tfsdk:"jwks"` Claim types.String `tfsdk:"claim"` @@ -319,7 +319,6 @@ type ApiOidcConfig struct { type IdentityMapEntry struct { TokenIdentity types.String `tfsdk:"token_identity"` CcIdentity types.String `tfsdk:"cc_identity"` - IsRegex types.Bool `tfsdk:"is_regex"` } type ServiceAccount struct { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 589883d8..e3042322 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -144,7 +144,7 @@ func (p *provider) Resources(_ context.Context) []func() resource.Resource { NewMaintenanceWindowResource, NewVersionDeferralResource, NewFolderResource, - NewApiOidcConfigResource, + NewJWTIssuerResource, NewServiceAccountResource, NewAPIKeyResource, }