diff --git a/CHANGELOG.md b/CHANGELOG.md index b888ed7..4222ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.5.0 (Mar 26, 2024) + +FEATURES: + +* **New Resource:** `platform_oidc_configuration` and `platform_oidc_identity_mapping`: PR: [#47](https://github.com/jfrog/terraform-provider-platform/pull/47) Issue: [#26](https://github.com/jfrog/terraform-provider-platform/issues/26), [#29](https://github.com/jfrog/terraform-provider-platform/issues/29), [#31](https://github.com/jfrog/terraform-provider-platform/issues/31), [#38](https://github.com/jfrog/terraform-provider-platform/issues/38) + ## 1.4.1 (Mar 18, 2024) BUG FIXES: diff --git a/docs/resources/oidc_configuration.md b/docs/resources/oidc_configuration.md new file mode 100644 index 0000000..8eaf05c --- /dev/null +++ b/docs/resources/oidc_configuration.md @@ -0,0 +1,53 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "platform_oidc_configuration Resource - terraform-provider-platform" +subcategory: "" +description: |- + Manage OIDC configuration in JFrog platform. See the JFrog OIDC configuration documentation https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-an-oidc-integration for more information. +--- + +# platform_oidc_configuration (Resource) + +Manage OIDC configuration in JFrog platform. See the JFrog [OIDC configuration documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-an-oidc-integration) for more information. + +## Example Usage + +```terraform +resource "platform_oidc_configuration" "my-github-oidc-configuration" { + name = "my-github-oidc-configuration" + description = "My GitHub OIDC configuration" + issuer_url = "https://token.actions.githubusercontent.com/" + provider_type = "GitHub" + audience = "jfrog-github" +} + +resource "platform_oidc_configuration" "my-generic-oidc-configuration" { + name = "my-generic-oidc-configuration" + description = "My generic OIDC configuration" + issuer_url = "https://tempurl.org/" + provider_type = "generic" + audience = "jfrog-generic" +} +``` + + +## Schema + +### Required + +- `issuer_url` (String) OIDC issuer URL. For GitHub actions, the URL must be https://token.actions.githubusercontent.com/. +- `name` (String) Name of the OIDC provider +- `provider_type` (String) Type of OIDC provider. Can be `generic` or `GitHub`. + +### Optional + +- `audience` (String) Informational field that you can use to include details of the audience that uses the OIDC configuration. +- `description` (String) Description of the OIDC provider + +## Import + +Import is supported using the following syntax: + +```shell +terraform import platform_oidc_configuration.my-oidc-configuration my-oidc-configuration +``` diff --git a/docs/resources/oidc_identity_mapping.md b/docs/resources/oidc_identity_mapping.md new file mode 100644 index 0000000..19971e2 --- /dev/null +++ b/docs/resources/oidc_identity_mapping.md @@ -0,0 +1,88 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "platform_oidc_identity_mapping Resource - terraform-provider-platform" +subcategory: "" +description: |- + Manage OIDC identity mapping for an OIDC configuration in JFrog platform. See the JFrog OIDC identity mappings documentation https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-identity-mappings for more information. +--- + +# platform_oidc_identity_mapping (Resource) + +Manage OIDC identity mapping for an OIDC configuration in JFrog platform. See the JFrog [OIDC identity mappings documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-identity-mappings) for more information. + +## Example Usage + +```terraform +resource "platform_oidc_identity_mapping" "my-github-oidc-user-identity-mapping" { + name = "my-github-oidc-user-identity-mapping" + description = "My GitHub OIDC user identity mapping" + provider_name = "my-github-oidc-configuration" + priority = 1 + + claims_json = jsonencode({ + "sub" = "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflow_ref" = "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main" + }) + + token_spec = { + username = "my-user" + scope = "applied-permissions/user" + audience = "*@*" + expires_in = 7200 + } +} + +resource "platform_oidc_identity_mapping" "my-github-oidc-group-identity-mapping" { + name = "my-github-oidc-group-identity-mapping" + description = "My GitHub OIDC group identity mapping" + provider_name = "my-github-oidc-configuration" + priority = 1 + + claims_json = jsonencode({ + "sub" = "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflow_ref" = "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main" + }) + + token_spec = { + scope = "applied-permissions/groups:\"readers\",\"my-group\"" + audience = "jfrt@* jfac@* jfmc@* jfmd@* jfevt@* jfxfer@* jflnk@* jfint@* jfwks@*" + expires_in = 7200 + } +} +``` + + +## Schema + +### Required + +- `claims_json` (String) Claims JSON from the OIDC provider. Use [Terraform jsonencode function](https://developer.hashicorp.com/terraform/language/functions/jsonencode) to encode the JSON string. Claims constitute the payload part of a JSON web token and represent a set of information exchanged between two parties. The JWT standard distinguishes between reserved claims, public claims, and private claims. In API Gateway context, both public claims and private claims are considered custom claims. For example, an ID token (which is always a JWT) can contain a claim called that asserts that the name of the user authenticating is "John Doe". In a JWT, a claim appears as a name/value pair where the name is always a string and the value can be any JSON value. +- `name` (String) Name of the OIDC identity mapping +- `priority` (Number) Priority of the identity mapping. The priority should be a number. The higher priority is set for the lower number. If you do not enter a value, the identity mapping is assigned the lowest priority. We recommend that you assign the highest priority (1) to the strongest permission gate. Set the lowest priority to the weakest permission for a logical and effective access control setup. +- `provider_name` (String) Name of the OIDC configuration +- `token_spec` (Attributes) Specifications of the token. In case of success, a token with the following details will be generated and passed to OIDC Provider. (see [below for nested schema](#nestedatt--token_spec)) + +### Optional + +- `description` (String) Description of the OIDC mapping + + +### Nested Schema for `token_spec` + +Required: + +- `scope` (String) Scope of the token. Must start with `applied-permissions/user`, `applied-permissions/admin`, or `applied-permissions/groups:`. Group names must be comma-separated, double quotes wrapped, e.g. `applied-permissions/groups:\"readers\",\"my-group\",` + +Optional: + +- `audience` (String) Sets of (space separated) the JFrog services to which the mapping applies. Default value is `*@*`, which applies to all services. +- `expires_in` (Number) Token expiry time in seconds. Default value is 60. +- `username` (String) User name of the OIDC user. Not applicable when `scope` is set to `applied-permissions/groups` + +## Import + +Import is supported using the following syntax: + +```shell +terraform import platform_oidc_identity_mapping.my-oidc-identity-mapping my-oidc-identity-mapping:my-oidc-configuration +``` diff --git a/examples/resources/platform_oidc_configuration/import.sh b/examples/resources/platform_oidc_configuration/import.sh new file mode 100644 index 0000000..ff90e56 --- /dev/null +++ b/examples/resources/platform_oidc_configuration/import.sh @@ -0,0 +1 @@ +terraform import platform_oidc_configuration.my-oidc-configuration my-oidc-configuration \ No newline at end of file diff --git a/examples/resources/platform_oidc_configuration/resource.tf b/examples/resources/platform_oidc_configuration/resource.tf new file mode 100644 index 0000000..675da8c --- /dev/null +++ b/examples/resources/platform_oidc_configuration/resource.tf @@ -0,0 +1,15 @@ +resource "platform_oidc_configuration" "my-github-oidc-configuration" { + name = "my-github-oidc-configuration" + description = "My GitHub OIDC configuration" + issuer_url = "https://token.actions.githubusercontent.com/" + provider_type = "GitHub" + audience = "jfrog-github" +} + +resource "platform_oidc_configuration" "my-generic-oidc-configuration" { + name = "my-generic-oidc-configuration" + description = "My generic OIDC configuration" + issuer_url = "https://tempurl.org/" + provider_type = "generic" + audience = "jfrog-generic" +} \ No newline at end of file diff --git a/examples/resources/platform_oidc_identity_mapping/import.sh b/examples/resources/platform_oidc_identity_mapping/import.sh new file mode 100644 index 0000000..c216f7c --- /dev/null +++ b/examples/resources/platform_oidc_identity_mapping/import.sh @@ -0,0 +1 @@ +terraform import platform_oidc_identity_mapping.my-oidc-identity-mapping my-oidc-identity-mapping:my-oidc-configuration \ No newline at end of file diff --git a/examples/resources/platform_oidc_identity_mapping/resource.tf b/examples/resources/platform_oidc_identity_mapping/resource.tf new file mode 100644 index 0000000..feba036 --- /dev/null +++ b/examples/resources/platform_oidc_identity_mapping/resource.tf @@ -0,0 +1,36 @@ +resource "platform_oidc_identity_mapping" "my-github-oidc-user-identity-mapping" { + name = "my-github-oidc-user-identity-mapping" + description = "My GitHub OIDC user identity mapping" + provider_name = "my-github-oidc-configuration" + priority = 1 + + claims_json = jsonencode({ + "sub" = "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflow_ref" = "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main" + }) + + token_spec = { + username = "my-user" + scope = "applied-permissions/user" + audience = "*@*" + expires_in = 7200 + } +} + +resource "platform_oidc_identity_mapping" "my-github-oidc-group-identity-mapping" { + name = "my-github-oidc-group-identity-mapping" + description = "My GitHub OIDC group identity mapping" + provider_name = "my-github-oidc-configuration" + priority = 1 + + claims_json = jsonencode({ + "sub" = "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflow_ref" = "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main" + }) + + token_spec = { + scope = "applied-permissions/groups:\"readers\",\"my-group\"" + audience = "jfrt@* jfac@* jfmc@* jfmd@* jfevt@* jfxfer@* jflnk@* jfint@* jfwks@*" + expires_in = 7200 + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 53791e8..d4e32dd 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/hashicorp/terraform-plugin-framework v1.7.0 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.22.1 - github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.7.0 github.com/jfrog/terraform-provider-shared v1.22.1 github.com/samber/lo v1.39.0 @@ -48,6 +47,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.20.0 // indirect github.com/hashicorp/terraform-json v0.21.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/pkg/platform/provider.go b/pkg/platform/provider.go index 9da2e9f..3b1f6c9 100644 --- a/pkg/platform/provider.go +++ b/pkg/platform/provider.go @@ -143,6 +143,8 @@ func (p *PlatformProvider) Resources(ctx context.Context) []func() resource.Reso return []func() resource.Resource{ NewLicenseResource, NewGlobalRoleResource, + NewOIDCConfigurationResource, + NewOIDCIdentityMappingResource, NewPermissionResource, NewReverseProxyResource, NewWorkerServiceResource, diff --git a/pkg/platform/resource_oidc_configuration.go b/pkg/platform/resource_oidc_configuration.go new file mode 100644 index 0000000..e928cbb --- /dev/null +++ b/pkg/platform/resource_oidc_configuration.go @@ -0,0 +1,278 @@ +package platform + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/jfrog/terraform-provider-shared/util" + utilfw "github.com/jfrog/terraform-provider-shared/util/fw" +) + +const ( + gitHubProviderType = "GitHub" + gitHubProviderURL = "https://token.actions.githubusercontent.com/" + odicConfigurationEndpoint = "/access/api/v1/oidc" +) + +var OIDCConfigurationNameValidators = []validator.String{ + stringvalidator.LengthBetween(1, 255), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-z]{1}[a-z0-9\-]+$`), + "must start with a lowercase letter and only contain lowercase letters, digits and `-` character.", + ), +} + +var _ resource.Resource = (*odicConfigurationResource)(nil) + +type odicConfigurationResource struct { + ProviderData util.ProvderMetadata +} + +func NewOIDCConfigurationResource() resource.Resource { + return &odicConfigurationResource{} +} + +func (r *odicConfigurationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_oidc_configuration" +} + +func (r *odicConfigurationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + Validators: OIDCConfigurationNameValidators, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Description: "Name of the OIDC provider", + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Description of the OIDC provider", + }, + "issuer_url": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^https:\/\/`), + "must use https protocol.", + ), + }, + Description: fmt.Sprintf("OIDC issuer URL. For GitHub actions, the URL must be %s.", gitHubProviderURL), + }, + "provider_type": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf([]string{"generic", gitHubProviderType}...), + }, + MarkdownDescription: fmt.Sprintf("Type of OIDC provider. Can be `generic` or `%s`.", gitHubProviderType), + }, + "audience": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + Description: "Informational field that you can use to include details of the audience that uses the OIDC configuration.", + }, + }, + MarkdownDescription: "Manage OIDC configuration in JFrog platform. See the JFrog [OIDC configuration documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-an-oidc-integration) for more information.", + } +} + +func (r odicConfigurationResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data odicConfigurationResourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if data.ProviderType.ValueString() == gitHubProviderType && data.IssuerURL.ValueString() != gitHubProviderURL { + resp.Diagnostics.AddAttributeError( + path.Root("issuer_url"), + "Invalid Attribute Configuration", + fmt.Sprintf("issuer_url must be set to %s when provider_type is set to '%s'.", gitHubProviderURL, gitHubProviderType), + ) + } +} + +type odicConfigurationResourceModel struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + IssuerURL types.String `tfsdk:"issuer_url"` + ProviderType types.String `tfsdk:"provider_type"` + Audience types.String `tfsdk:"audience"` +} + +type odicConfigurationAPIModel struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + IssuerURL string `json:"issuer_url"` + ProviderType string `json:"provider_type"` + Audience string `json:"audience,omitempty"` +} + +func (r *odicConfigurationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + r.ProviderData = req.ProviderData.(util.ProvderMetadata) +} + +func (r *odicConfigurationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan odicConfigurationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + odicConfig := odicConfigurationAPIModel{ + Name: plan.Name.ValueString(), + IssuerURL: plan.IssuerURL.ValueString(), + ProviderType: plan.ProviderType.ValueString(), + Audience: plan.Audience.ValueString(), + Description: plan.Description.ValueString(), + } + + response, err := r.ProviderData.Client.R(). + SetBody(&odicConfig). + Post(odicConfigurationEndpoint) + if err != nil { + utilfw.UnableToCreateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToCreateResourceError(resp, response.String()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *odicConfigurationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state odicConfigurationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var odicConfig odicConfigurationAPIModel + + response, err := r.ProviderData.Client.R(). + SetPathParam("name", state.Name.ValueString()). + SetResult(&odicConfig). + Get(odicConfigurationEndpoint + "/{name}") + + if err != nil { + utilfw.UnableToRefreshResourceError(resp, err.Error()) + return + } + + // Treat HTTP 404 Not Found status as a signal to recreate resource + // and return early + if response.StatusCode() == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + if response.IsError() { + utilfw.UnableToRefreshResourceError(resp, response.String()) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + state.Name = types.StringValue(odicConfig.Name) + + if len(odicConfig.Description) > 0 { + state.Description = types.StringValue(odicConfig.Description) + } + + state.IssuerURL = types.StringValue(odicConfig.IssuerURL) + + if len(odicConfig.Audience) > 0 { + state.Audience = types.StringValue(odicConfig.Audience) + } + + state.ProviderType = types.StringValue(odicConfig.ProviderType) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *odicConfigurationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan odicConfigurationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + odicConfig := odicConfigurationAPIModel{ + Name: plan.Name.ValueString(), + IssuerURL: plan.IssuerURL.ValueString(), + ProviderType: plan.ProviderType.ValueString(), + Audience: plan.Audience.ValueString(), + Description: plan.Description.ValueString(), + } + + response, err := r.ProviderData.Client.R(). + SetPathParam("name", plan.Name.ValueString()). + SetBody(&odicConfig). + Put(odicConfigurationEndpoint + "/{name}") + if err != nil { + utilfw.UnableToUpdateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToUpdateResourceError(resp, response.String()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *odicConfigurationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state odicConfigurationResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.ProviderData.Client.R(). + SetPathParam("name", state.Name.ValueString()). + Delete(odicConfigurationEndpoint + "/{name}") + if err != nil { + utilfw.UnableToDeleteResourceError(resp, err.Error()) + return + } + + if response.StatusCode() != http.StatusNoContent { + utilfw.UnableToDeleteResourceError(resp, response.String()) + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +func (r *odicConfigurationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} diff --git a/pkg/platform/resource_oidc_configuration_test.go b/pkg/platform/resource_oidc_configuration_test.go new file mode 100644 index 0000000..6f3fd1a --- /dev/null +++ b/pkg/platform/resource_oidc_configuration_test.go @@ -0,0 +1,181 @@ +package platform_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/jfrog/terraform-provider-shared/testutil" +) + +func TestAccOIDCConfiguration_full(t *testing.T) { + _, fqrn, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + + temp := ` + resource "platform_oidc_configuration" "{{ .name }}" { + name = "{{ .name }}" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + }` + + testData := map[string]string{ + "name": configName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + } + + config := testutil.ExecuteTemplate(configName, temp, testData) + + updatedTemp := ` + resource "platform_oidc_configuration" "{{ .name }}" { + name = "{{ .name }}" + description = "Test Description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + }` + + updatedTestData := map[string]string{ + "name": configName, + "issuerURL": "https://token.actions.githubusercontent.com/", + "providerType": "GitHub", + "audience": "test-audience-2", + } + updatedConfig := testutil.ExecuteTemplate(configName, updatedTemp, updatedTestData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "name", testData["name"]), + resource.TestCheckResourceAttr(fqrn, "issuer_url", testData["issuerURL"]), + resource.TestCheckResourceAttr(fqrn, "provider_type", testData["providerType"]), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "name", updatedTestData["name"]), + resource.TestCheckResourceAttr(fqrn, "description", "Test Description"), + resource.TestCheckResourceAttr(fqrn, "issuer_url", updatedTestData["issuerURL"]), + resource.TestCheckResourceAttr(fqrn, "provider_type", updatedTestData["providerType"]), + resource.TestCheckResourceAttr(fqrn, "audience", updatedTestData["audience"]), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateId: configName, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "name", + }, + }, + }) +} + +func TestAccOIDCConfiguration_invalid_name(t *testing.T) { + for _, invalidName := range []string{"Test", "test!@", "1test"} { + t.Run(invalidName, func(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + + temp := ` + resource "platform_oidc_configuration" "{{ .resourceName }}" { + name = "{{ .name }}" + description = "Test description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + }` + + testData := map[string]string{ + "resourceName": configName, + "name": invalidName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + } + + config := testutil.ExecuteTemplate(configName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`must start with a lowercase letter and only contain lowercase`), + }, + }, + }) + }) + } +} + +func TestAccOIDCConfiguration_invalid_issuer_url(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + + temp := ` + resource "platform_oidc_configuration" "{{ .name }}" { + name = "{{ .name }}" + description = "Test description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + }` + + testData := map[string]string{ + "name": configName, + "issuerURL": "http://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + } + + config := testutil.ExecuteTemplate(configName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`issuer_url must use https protocol`), + }, + }, + }) +} + +func TestAccOIDCConfiguration_invalid_provider_type_issuer_url(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + + temp := ` + resource "platform_oidc_configuration" "{{ .name }}" { + name = "{{ .name }}" + description = "Test description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + }` + + testData := map[string]string{ + "name": configName, + "issuerURL": "https://tempurl.org", + "providerType": "GitHub", + "audience": "test-audience", + } + + config := testutil.ExecuteTemplate(configName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`must be set to https:\/\/token\.actions\.githubusercontent\.com\/`), + }, + }, + }) +} diff --git a/pkg/platform/resource_oidc_identity_mapping.go b/pkg/platform/resource_oidc_identity_mapping.go new file mode 100644 index 0000000..5729d48 --- /dev/null +++ b/pkg/platform/resource_oidc_identity_mapping.go @@ -0,0 +1,406 @@ +package platform + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/jfrog/terraform-provider-shared/util" + utilfw "github.com/jfrog/terraform-provider-shared/util/fw" +) + +const odicIdentityMappingEndpoint = "/access/api/v1/oidc/{provider_name}/identity_mappings" + +var _ resource.Resource = (*odicIdentityMappingResource)(nil) + +type odicIdentityMappingResource struct { + ProviderData util.ProvderMetadata +} + +func NewOIDCIdentityMappingResource() resource.Resource { + return &odicIdentityMappingResource{} +} + +func (r *odicIdentityMappingResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_oidc_identity_mapping" +} +func (r *odicIdentityMappingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[^ !@#$%^&*()+={}\[\]:;'"<>,\./?~\x60|\\]+$`), + "name cannot contain spaces or special characters", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Description: "Name of the OIDC identity mapping", + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "Description of the OIDC mapping", + }, + "provider_name": schema.StringAttribute{ + Required: true, + Validators: OIDCConfigurationNameValidators, + Description: "Name of the OIDC configuration", + }, + "priority": schema.Int64Attribute{ + Required: true, + Validators: []validator.Int64{ + int64validator.Between(1, math.MaxInt64), + }, + Description: "Priority of the identity mapping. The priority should be a number. The higher priority is set for the lower number. If you do not enter a value, the identity mapping is assigned the lowest priority. We recommend that you assign the highest priority (1) to the strongest permission gate. Set the lowest priority to the weakest permission for a logical and effective access control setup.", + }, + "claims_json": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Claims JSON from the OIDC provider. Use [Terraform jsonencode function](https://developer.hashicorp.com/terraform/language/functions/jsonencode) to encode the JSON string. Claims constitute the payload part of a JSON web token and represent a set of information exchanged between two parties. The JWT standard distinguishes between reserved claims, public claims, and private claims. In API Gateway context, both public claims and private claims are considered custom claims. For example, an ID token (which is always a JWT) can contain a claim called that asserts that the name of the user authenticating is \"John Doe\". In a JWT, a claim appears as a name/value pair where the name is always a string and the value can be any JSON value.", + }, + "token_spec": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + Description: "User name of the OIDC user. Not applicable when `scope` is set to `applied-permissions/groups`", + }, + "scope": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^(applied-permissions\/admin|applied-permissions\/user|applied-permissions\/groups:.+)$`), + "must start with either 'applied-permissions/admin', 'applied-permissions/user', or 'applied-permissions/groups:'", + ), + }, + MarkdownDescription: "Scope of the token. Must start with `applied-permissions/user`, `applied-permissions/admin`, or `applied-permissions/groups:`. Group names must be comma-separated, double quotes wrapped, e.g. `applied-permissions/groups:\\\"readers\\\",\\\"my-group\\\",`", + }, + "audience": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("*@*"), + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + MarkdownDescription: "Sets of (space separated) the JFrog services to which the mapping applies. Default value is `*@*`, which applies to all services.", + }, + "expires_in": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(60), + Validators: []validator.Int64{ + int64validator.Between(60, 86400), + }, + MarkdownDescription: "Token expiry time in seconds. Default value is 60.", + }, + }, + Description: "Specifications of the token. In case of success, a token with the following details will be generated and passed to OIDC Provider.", + }, + }, + MarkdownDescription: "Manage OIDC identity mapping for an OIDC configuration in JFrog platform. See the JFrog [OIDC identity mappings documentation](https://jfrog.com/help/r/jfrog-platform-administration-documentation/configure-identity-mappings) for more information.", + } +} + +type odicIdentityMappingResourceModel struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + ProviderName types.String `tfsdk:"provider_name"` + Priority types.Int64 `tfsdk:"priority"` + ClaimsJSON types.String `tfsdk:"claims_json"` + TokenSpec types.Object `tfsdk:"token_spec"` +} + +type odicIdentityMappingTokenSpecResourceModel struct { + Username types.String `tfsdk:"username"` + Scope types.String `tfsdk:"scope"` + Audience types.String `tfsdk:"audience"` + ExpiresIn types.Int64 `tfsdk:"expires_in"` +} + +var odicIdentityMappingTokenSpecResourceModelAttributeType map[string]attr.Type = map[string]attr.Type{ + "username": types.StringType, + "scope": types.StringType, + "audience": types.StringType, + "expires_in": types.Int64Type, +} + +func (r *odicIdentityMappingResourceModel) toAPIModel(ctx context.Context, apiModel *odicIdentityMappingAPIModel) (ds diag.Diagnostics) { + var claims map[string]any + err := json.Unmarshal([]byte(r.ClaimsJSON.ValueString()), &claims) + if err != nil { + ds.AddError( + "fails to unmarshal claims", + err.Error(), + ) + return + } + var tokenSpec odicIdentityMappingTokenSpecResourceModel + ds.Append(r.TokenSpec.As(ctx, &tokenSpec, basetypes.ObjectAsOptions{})...) + if ds.HasError() { + return + } + + *apiModel = odicIdentityMappingAPIModel{ + Name: r.Name.ValueString(), + Description: r.Description.ValueString(), + ProviderName: r.ProviderName.ValueString(), + Priority: r.Priority.ValueInt64(), + Claims: claims, + TokenSpec: odicIdentityMappingTokenSpecAPIModel{ + Username: tokenSpec.Username.ValueString(), + Scope: tokenSpec.Scope.ValueString(), + Audience: tokenSpec.Audience.ValueString(), + ExpiresIn: tokenSpec.ExpiresIn.ValueInt64(), + }, + } + + return +} + +func (r *odicIdentityMappingResourceModel) fromAPIModel(ctx context.Context, apiModel *odicIdentityMappingAPIModel) (ds diag.Diagnostics) { + r.Name = types.StringValue(apiModel.Name) + + if len(apiModel.Description) > 0 { + r.Description = types.StringValue(apiModel.Description) + } + + r.Priority = types.Int64Value(apiModel.Priority) + + claimsBytes, err := json.Marshal(apiModel.Claims) + if err != nil { + ds.AddError( + "fails to marshal claims JSON", + err.Error(), + ) + return + } + r.ClaimsJSON = types.StringValue(string(claimsBytes)) + + tokenSpecResource := odicIdentityMappingTokenSpecResourceModel{ + Scope: types.StringValue(apiModel.TokenSpec.Scope), + ExpiresIn: types.Int64Value(apiModel.TokenSpec.ExpiresIn), + } + if len(apiModel.TokenSpec.Username) > 0 { + tokenSpecResource.Username = types.StringValue(apiModel.TokenSpec.Username) + } + if len(apiModel.TokenSpec.Audience) > 0 { + tokenSpecResource.Audience = types.StringValue(apiModel.TokenSpec.Audience) + } + + tokenSpec, d := types.ObjectValueFrom( + ctx, + odicIdentityMappingTokenSpecResourceModelAttributeType, + tokenSpecResource, + ) + if d != nil { + ds = append(ds, d...) + } + if ds.HasError() { + return + } + r.TokenSpec = tokenSpec + + return +} + +type odicIdentityMappingAPIModel struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ProviderName string `json:"provider_name"` + Priority int64 `json:"priority"` + Claims map[string]any `json:"claims"` + TokenSpec odicIdentityMappingTokenSpecAPIModel `json:"token_spec"` +} + +type odicIdentityMappingTokenSpecAPIModel struct { + Username string `json:"username,omitempty"` + Scope string `json:"scope"` + Audience string `json:"audience"` + ExpiresIn int64 `json:"expires_in"` +} + +func (r *odicIdentityMappingResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + r.ProviderData = req.ProviderData.(util.ProvderMetadata) +} + +func (r *odicIdentityMappingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan odicIdentityMappingResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var odicIdentityMapping odicIdentityMappingAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &odicIdentityMapping)...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.ProviderData.Client.R(). + SetPathParam("provider_name", plan.ProviderName.ValueString()). + SetBody(&odicIdentityMapping). + Post(odicIdentityMappingEndpoint) + if err != nil { + utilfw.UnableToCreateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToCreateResourceError(resp, response.String()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *odicIdentityMappingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state odicIdentityMappingResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var odicIdentityMapping odicIdentityMappingAPIModel + + response, err := r.ProviderData.Client.R(). + SetPathParams(map[string]string{ + "provider_name": state.ProviderName.ValueString(), + "name": state.Name.ValueString(), + }). + SetResult(&odicIdentityMapping). + Get(odicIdentityMappingEndpoint + "/{name}") + + if err != nil { + utilfw.UnableToRefreshResourceError(resp, err.Error()) + return + } + + // Treat HTTP 404 Not Found status as a signal to recreate resource + // and return early + if response.StatusCode() == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + if response.IsError() { + utilfw.UnableToRefreshResourceError(resp, response.String()) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(state.fromAPIModel(ctx, &odicIdentityMapping)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *odicIdentityMappingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan odicIdentityMappingResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var odicIdentityMapping odicIdentityMappingAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &odicIdentityMapping)...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.ProviderData.Client.R(). + SetPathParams(map[string]string{ + "provider_name": plan.ProviderName.ValueString(), + "name": plan.Name.ValueString(), + }). + SetBody(&odicIdentityMapping). + Put(odicIdentityMappingEndpoint + "/{name}") + if err != nil { + utilfw.UnableToUpdateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToUpdateResourceError(resp, response.String()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *odicIdentityMappingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state odicIdentityMappingResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.ProviderData.Client.R(). + SetPathParams(map[string]string{ + "provider_name": state.ProviderName.ValueString(), + "name": state.Name.ValueString(), + }). + Delete(odicIdentityMappingEndpoint + "/{name}") + if err != nil { + utilfw.UnableToDeleteResourceError(resp, err.Error()) + return + } + + if response.StatusCode() != http.StatusNoContent { + utilfw.UnableToDeleteResourceError(resp, response.String()) + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +func (r *odicIdentityMappingResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, ":") + + if len(idParts) != 2 || len(idParts[0]) == 0 || len(idParts[1]) == 0 { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: identity_mapping_name:provider_name. Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("provider_name"), idParts[1])...) +} diff --git a/pkg/platform/resource_oidc_identity_mapping_test.go b/pkg/platform/resource_oidc_identity_mapping_test.go new file mode 100644 index 0000000..7d7eef1 --- /dev/null +++ b/pkg/platform/resource_oidc_identity_mapping_test.go @@ -0,0 +1,345 @@ +package platform_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/jfrog/terraform-provider-shared/testutil" +) + +func TestAccOIDCIdentityMapping_full(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + _, fqrn, identityMappingName := testutil.MkNames("test-oidc-identity-mapping", "platform_oidc_identity_mapping") + + temp := ` + resource "platform_oidc_configuration" "{{ .configName }}" { + name = "{{ .configName }}" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + } + + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .identityMappingName }}" + provider_name = platform_oidc_configuration.{{ .configName }}.name + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "{{ .sub }}", + updated_at = 1490198843 + }) + token_spec = { + username = "{{ .username }}" + scope = "applied-permissions/user" + } + }` + + testData := map[string]string{ + "configName": configName, + "identityMappingName": identityMappingName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "sub": fmt.Sprintf("test-subscriber-%d", testutil.RandomInt()), + "username": fmt.Sprintf("test-user-%d", testutil.RandomInt()), + } + + config := testutil.ExecuteTemplate(identityMappingName, temp, testData) + + updatedTemp := ` + resource "platform_oidc_configuration" "{{ .configName }}" { + name = "{{ .configName }}" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + } + + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .identityMappingName }}" + description = "Test description" + provider_name = platform_oidc_configuration.{{ .configName }}.name + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "{{ .sub }}", + updated_at = 1490198843 + }) + token_spec = { + username = "{{ .username }}" + scope = "applied-permissions/user" + audience = "jfrt@* jfac@* jfmc@* jfmd@* jfevt@* jfxfer@* jflnk@* jfint@* jfwks@*" + expires_in = 120 + } + }` + + updatedTestData := map[string]string{ + "configName": configName, + "identityMappingName": identityMappingName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "sub": fmt.Sprintf("test-subscriber-%d", testutil.RandomInt()), + "username": fmt.Sprintf("test-user-%d", testutil.RandomInt()), + } + + updatedConfig := testutil.ExecuteTemplate(identityMappingName, updatedTemp, updatedTestData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "name", testData["identityMappingName"]), + resource.TestCheckResourceAttr(fqrn, "priority", testData["priority"]), + resource.TestCheckResourceAttr(fqrn, "claims_json", fmt.Sprintf("{\"sub\":\"%s\",\"updated_at\":1490198843}", testData["sub"])), + resource.TestCheckResourceAttr(fqrn, "token_spec.username", testData["username"]), + resource.TestCheckResourceAttr(fqrn, "token_spec.scope", "applied-permissions/user"), + resource.TestCheckResourceAttr(fqrn, "token_spec.audience", "*@*"), + resource.TestCheckResourceAttr(fqrn, "token_spec.expires_in", "60"), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "name", updatedTestData["identityMappingName"]), + resource.TestCheckResourceAttr(fqrn, "description", "Test description"), + resource.TestCheckResourceAttr(fqrn, "priority", updatedTestData["priority"]), + resource.TestCheckResourceAttr(fqrn, "claims_json", fmt.Sprintf("{\"sub\":\"%s\",\"updated_at\":1490198843}", updatedTestData["sub"])), + resource.TestCheckResourceAttr(fqrn, "token_spec.username", updatedTestData["username"]), + resource.TestCheckResourceAttr(fqrn, "token_spec.scope", "applied-permissions/user"), + resource.TestCheckResourceAttr(fqrn, "token_spec.audience", "jfrt@* jfac@* jfmc@* jfmd@* jfevt@* jfxfer@* jflnk@* jfint@* jfwks@*"), + resource.TestCheckResourceAttr(fqrn, "token_spec.expires_in", "120"), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateId: fmt.Sprintf("%s:%s", identityMappingName, configName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "name", + }, + }, + }) +} + +func TestAccOIDCIdentityMapping_groups_scope(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + _, fqrn, identityMappingName := testutil.MkNames("test-oidc-identity-mapping", "platform_oidc_identity_mapping") + + temp := ` + resource "platform_oidc_configuration" "{{ .configName }}" { + name = "{{ .configName }}" + description = "Test description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + } + + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .identityMappingName }}" + description = "Test description" + provider_name = platform_oidc_configuration.{{ .configName }}.name + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "{{ .sub }}", + updated_at = 1490198843 + }) + token_spec = { + scope = "applied-permissions/groups:\"readers\",\"test\"" + audience = "*@*" + expires_in = 120 + } + }` + + testData := map[string]string{ + "configName": configName, + "identityMappingName": identityMappingName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "sub": fmt.Sprintf("test-subscriber-%d", testutil.RandomInt()), + "scope": "applied-permissions/groups:\"readers\",\"test\"", + } + + config := testutil.ExecuteTemplate(identityMappingName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "name", testData["identityMappingName"]), + resource.TestCheckResourceAttr(fqrn, "priority", testData["priority"]), + resource.TestCheckResourceAttr(fqrn, "claims_json", fmt.Sprintf("{\"sub\":\"%s\",\"updated_at\":1490198843}", testData["sub"])), + resource.TestCheckResourceAttr(fqrn, "token_spec.scope", "applied-permissions/groups:\"readers\",\"test\""), + resource.TestCheckResourceAttr(fqrn, "token_spec.audience", "*@*"), + resource.TestCheckResourceAttr(fqrn, "token_spec.expires_in", "120"), + ), + }, + }, + }) +} + +func TestAccOIDCIdentityMapping_invalid_name(t *testing.T) { + for _, invalidName := range []string{"invalid name", "invalid!name"} { + t.Run(invalidName, func(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + _, _, identityMappingName := testutil.MkNames("test-oidc-identity-mapping", "platform_oidc_identity_mapping") + + temp := ` + resource "platform_oidc_configuration" "{{ .configName }}" { + name = "{{ .configName }}" + description = "Test description" + issuer_url = "{{ .issuerURL }}" + provider_type = "{{ .providerType }}" + audience = "{{ .audience }}" + } + + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .invalidName }}" + description = "Test description" + provider_name = platform_oidc_configuration.{{ .configName }}.name + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "test-subscriber", + updated_at = 1490198843 + }) + token_spec = { + username = "{{ .username }}" + scope = "applied-permissions/user" + audience = "*@*" + expires_in = 120 + } + }` + + testData := map[string]string{ + "configName": configName, + "identityMappingName": identityMappingName, + "invalidName": invalidName, + "issuerURL": "https://tempurl.org", + "providerType": "generic", + "audience": "test-audience", + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "username": fmt.Sprintf("test-user-%d", testutil.RandomInt()), + } + + config := testutil.ExecuteTemplate(identityMappingName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`.*name cannot contain spaces or special characters.*`), + }, + }, + }) + }) + } +} + +func TestAccOIDCIdentityMapping_invalid_provider_name(t *testing.T) { + for _, invalidName := range []string{"Test", "test!@", "1test"} { + t.Run(invalidName, func(t *testing.T) { + _, _, identityMappingName := testutil.MkNames("test-oidc-identity-mapping", "platform_oidc_identity_mapping") + + temp := ` + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .identityMappingName }}" + description = "Test description" + provider_name = "{{ .invalidName }}" + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "{{ .sub }}", + workflow_ref = "{{ .workflowRef }}" + }) + token_spec = { + username = "{{ .username }}" + scope = "applied-permissions/user" + audience = "*@*" + expires_in = 120 + } + }` + + testData := map[string]string{ + "identityMappingName": identityMappingName, + "invalidName": invalidName, + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "sub": "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflowRef": "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main", + "username": fmt.Sprintf("test-user-%d", testutil.RandomInt()), + } + + config := testutil.ExecuteTemplate(identityMappingName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`.*must start with a lowercase letter and only contain.*`), + }, + }, + }) + }) + } +} + +func TestAccOIDCIdentityMapping_invalid_scope(t *testing.T) { + for _, invalidScope := range []string{"invalid-scope", "applied-permissions/group", "applied-permissions/groups"} { + t.Run(invalidScope, func(t *testing.T) { + _, _, configName := testutil.MkNames("test-oidc-configuration", "platform_oidc_configuration") + _, _, identityMappingName := testutil.MkNames("test-oidc-identity-mapping", "platform_oidc_identity_mapping") + + temp := ` + resource "platform_oidc_identity_mapping" "{{ .identityMappingName }}" { + name = "{{ .identityMappingName }}" + description = "Test description" + provider_name = "{{ .configName }}" + priority = {{ .priority }} + claims_json = jsonencode({ + sub = "{{ .sub }}", + workflow_ref = "{{ .workflowRef }}" + }) + token_spec = { + username = "{{ .username }}" + scope = "{{ .invalidScope }}" + audience = "*@*" + expires_in = 120 + } + }` + + testData := map[string]string{ + "identityMappingName": identityMappingName, + "configName": configName, + "priority": fmt.Sprintf("%d", testutil.RandomInt()), + "sub": "repo:humpty/access-oidc-poc:ref:refs/heads/main", + "workflowRef": "humpty/access-oidc-poc/.github/workflows/job.yaml@refs/heads/main", + "username": fmt.Sprintf("test-user-%d", testutil.RandomInt()), + "invalidScope": invalidScope, + } + + config := testutil.ExecuteTemplate(identityMappingName, temp, testData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`.*must start with either.*`), + }, + }, + }) + }) + } +} diff --git a/pkg/platform/resource_permission.go b/pkg/platform/resource_permission.go index 04d4f08..24bb7bd 100644 --- a/pkg/platform/resource_permission.go +++ b/pkg/platform/resource_permission.go @@ -19,7 +19,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/jfrog/terraform-provider-shared/util" utilfw "github.com/jfrog/terraform-provider-shared/util/fw" "github.com/samber/lo" @@ -714,10 +713,6 @@ func (r *permissionResource) Read(ctx context.Context, req resource.ReadRequest, return } - tflog.Debug(ctx, "Read1", map[string]any{ - "state": state, - }) - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } diff --git a/sample.tf b/sample.tf index 4931a6c..9c5720f 100644 --- a/sample.tf +++ b/sample.tf @@ -1,8 +1,8 @@ terraform { required_providers { platform = { - source = "registry.terraform.io/jfrog/platform" - version = "1.0.2" + source = "jfrog/platform" + version = "1.4.1" } } }