diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE b/LICENSE-MIT similarity index 96% rename from LICENSE rename to LICENSE-MIT index d57fe5ccd..3b74241e9 100644 --- a/LICENSE +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2020 Michael Parker +Copyright (c) 2018-2024 Michael Parker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f511e2352..f6a2b4e59 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ After cloning the repository, you can build the project by running `make build`. ### Local Environment -You can spin up a local developer environment via [Docker Compose](https://docs.docker.com/compose/) by running `make local`. +You can spin up a local developer environment via [Docker Compose](https://docs.docker.com/compose/) by running `make user-federation-example` and `make local`. This will spin up a few containers for Keycloak, PostgreSQL, and OpenLDAP, which can be used for testing the provider. This environment and its setup via `make local` is not intended for production use. @@ -92,6 +92,16 @@ KEYCLOAK_URL="http://localhost:8080" \ make testacc ``` +### Install and test new version + +To build/install a new version of this provider on your local machine, set `GOOS` and `GOARCH` according to your system, +then run `make build-example && cd example`. + ## License -[MIT](https://github.com/mrparkers/terraform-provider-keycloak/blob/master/LICENSE) +This software is licensed under either of the following, at your option: + +- Apache License, Version 2.0, (LICENSE-APACHE-2.0 or https://www.apache.org/licenses/LICENSE-2.0) +- MIT License (LICENSE-MIT or https://opensource.org/licenses/MIT) + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this software by you shall be dual licensed under the MIT License and Apache License, Version 2.0, without any additional terms or conditions. diff --git a/docs/resources/authentication_execution.md b/docs/resources/authentication_execution.md index 15f5b1bd3..b993aff1e 100644 --- a/docs/resources/authentication_execution.md +++ b/docs/resources/authentication_execution.md @@ -51,6 +51,7 @@ resource "keycloak_authentication_execution" "execution_two" { - `parent_flow_alias` - (Required) The alias of the flow this execution is attached to. - `authenticator` - (Required) The name of the authenticator. This can be found by experimenting with the GUI and looking at HTTP requests within the network tab of your browser's development tools. - `requirement`- (Optional) The requirement setting, which can be one of `REQUIRED`, `ALTERNATIVE`, `OPTIONAL`, `CONDITIONAL`, or `DISABLED`. Defaults to `DISABLED`. +- `import` - (Optional) When `true`, the authentication execution with the specified `authenticator` inside the authentication flow with the specified alias `parent_flow_alias` is assumed to already exist, and it will be imported into state instead of being created. This attribute is useful when dealing with authentication executions that Keycloak creates automatically during realm creation, such as `browser/identity-provider-redirector` and `registration/registration-user-creation`. Note, that the execution will not be removed during destruction if `import` is `true`. ## Import diff --git a/docs/resources/authentication_flow.md b/docs/resources/authentication_flow.md index 588777172..78b4f48c5 100644 --- a/docs/resources/authentication_flow.md +++ b/docs/resources/authentication_flow.md @@ -37,6 +37,8 @@ resource "keycloak_authentication_execution" "execution" { - `alias` - (Required) The alias for this authentication flow. - `description` - (Optional) A description for the authentication flow. - `provider_id` - (Optional) The type of authentication flow to create. Valid choices include `basic-flow` and `client-flow`. Defaults to `basic-flow`. +- `import` - (Optional) When `true`, the authentication flow with the specified `alias` is assumed to already exist, and it will be imported into state instead of being created. This attribute is useful when dealing with authentication flows that Keycloak creates automatically during realm creation, such as `browser` and `clients`. Note, that the flow will not be removed during destruction if `import` is `true`. + ## Import diff --git a/docs/resources/authentication_subflow.md b/docs/resources/authentication_subflow.md index e22d189e8..3804837c0 100644 --- a/docs/resources/authentication_subflow.md +++ b/docs/resources/authentication_subflow.md @@ -43,6 +43,7 @@ and `client-flow`. Defaults to `basic-flow`. authenticators. In general this will remain empty. - `requirement`- (Optional) The requirement setting, which can be one of `REQUIRED`, `ALTERNATIVE`, `OPTIONAL`, `CONDITIONAL`, or `DISABLED`. Defaults to `DISABLED`. +- `import` - (Optional) When `true`, the authentication subflow with the specified `alias` inside the parent flow with the specified alias `parent_flow_alias` is assumed to already exist, and it will be imported into state instead of being created. This attribute is useful when dealing with authentication subflows that Keycloak creates automatically during realm creation, such as `browser/forms` and `first broker login/User creation of linking`. Note, that the subflow will not be removed during destruction if `import` is `true`. ## Import diff --git a/docs/resources/role.md b/docs/resources/role.md index e64e27912..8ab02954c 100644 --- a/docs/resources/role.md +++ b/docs/resources/role.md @@ -152,6 +152,7 @@ resource "keycloak_role" "admin_role" { - `description` - (Optional) The description of the role - `composite_roles` - (Optional) When specified, this role will be a composite role, composed of all roles that have an ID present within this list. - `attributes` - (Optional) A map representing attributes for the role. In order to add multivalue attributes, use `##` to seperate the values. Max length for each value is 255 chars +- - `import` - (Optional) When `true`, the role with the specified `name` is assumed to already exist, and it will be imported into state instead of being created. This attribute is useful when dealing with roles that Keycloak creates automatically during realm creation, such as the client roles `create-client`, `view-realm`, ... for the client `realm-management` created per realm. Note, that the role will not be removed during destruction if `import` is `true`. Also note, that if this value is set and `composite_roles` has been overwritten (set in any way), it will overwrite the state at keycloak. ## Import diff --git a/docs/resources/user.md b/docs/resources/user.md index a5601cb0d..c5339748c 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -62,11 +62,13 @@ resource "keycloak_user" "user_with_initial_password" { - `first_name` - (Optional) The user's first name. - `last_name` - (Optional) The user's last name. - `attributes` - (Optional) A map representing attributes for the user. In order to add multivalue attributes, use `##` to seperate the values. Max length for each value is 255 chars -- `required_actions` - (Optional) A list of required user actions. +- `required_actions` - (Optional) A list of required user actions. - `federated_identity` - (Optional) When specified, the user will be linked to a federated identity provider. Refer to the [federated user example](https://github.com/mrparkers/terraform-provider-keycloak/blob/master/example/federated_user_example.tf) for more details. - `identity_provider` - (Required) The name of the identity provider - `user_id` - (Required) The ID of the user defined in the identity provider - `user_name` - (Required) The user name of the user defined in the identity provider +- `import` - (Optional) When `true`, the user with the specified `username` is assumed to already exist, and it will be imported into state instead of being created. This attribute is useful when dealing with users that Keycloak creates automatically during realm creation, such as `admin`. Note, that the user will not be removed during destruction if `import` is `true`. + ## Import diff --git a/example/import_authentication_flow_example.tf b/example/import_authentication_flow_example.tf new file mode 100644 index 000000000..1542a0ca6 --- /dev/null +++ b/example/import_authentication_flow_example.tf @@ -0,0 +1,31 @@ +resource "keycloak_realm" "default-config-test-realm" { + realm = "default-config-test-realm" + enabled = true +} + +resource "keycloak_authentication_flow" "imported-flow" { + realm_id = keycloak_realm.default-config-test-realm.id + alias = "browser" + import = true + # changed attributes + description = "new description" + provider_id="client-flow" +} + +resource "keycloak_authentication_subflow" "imported-subflow" { + realm_id = keycloak_realm.default-config-test-realm.id + parent_flow_alias = keycloak_authentication_flow.imported-flow.alias + alias = "forms" + import = true + # changed attributes + description = "new description" # default: Username, password, otp and other auth forms +} + +resource "keycloak_authentication_execution" "imported-execution" { + realm_id = keycloak_realm.default-config-test-realm.id + parent_flow_alias = keycloak_authentication_flow.imported-flow.alias + authenticator = "identity-provider-redirector" + import = true + # changed attributes + requirement = "REQUIRED" # default: ALTERNATIVE +} diff --git a/keycloak/authentication_execution.go b/keycloak/authentication_execution.go index 971448eea..34c73901c 100644 --- a/keycloak/authentication_execution.go +++ b/keycloak/authentication_execution.go @@ -148,6 +148,52 @@ func (keycloakClient *KeycloakClient) GetAuthenticationExecution(ctx context.Con return &authenticationExecution, nil } +func (keycloakClient *KeycloakClient) GetAuthenticationExecutionFromAuthenticator(ctx context.Context, realmId, parentFlowAlias, execAuthenticator string) (*AuthenticationExecution, error) { + var authenticationExecutions []*AuthenticationExecutionInfo + + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/authentication/flows/%s/executions", realmId, parentFlowAlias), &authenticationExecutions, nil) + if err != nil { + return nil, fmt.Errorf("no parent flow with alias %s exists", parentFlowAlias) + } + + // Retry 3 more times if not found, sometimes it took split milliseconds the Authentication Executions to populate + if len(authenticationExecutions) == 0 { + for i := 0; i < 3; i++ { + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/authentication/flows/%s/executions", realmId, parentFlowAlias), &authenticationExecutions, nil) + + if len(authenticationExecutions) > 0 { + break + } + + if err != nil { + return nil, err + } + + time.Sleep(time.Millisecond * 50) + } + + if len(authenticationExecutions) == 0 { + return nil, fmt.Errorf("no authentication executions found for parent flow alias %s", parentFlowAlias) + } + } + + for _, aExecution := range authenticationExecutions { + if aExecution != nil && !aExecution.AuthenticationFlow { + + exec, err := keycloakClient.GetAuthenticationExecution(ctx, realmId, parentFlowAlias, aExecution.Id) + if err != nil { + return nil, err + } + + if exec.Authenticator == execAuthenticator { + return exec, nil + } + } + } + + return nil, fmt.Errorf("no authentication execution under parent flow alias %s with authenticator %s found", parentFlowAlias, execAuthenticator) +} + func (keycloakClient *KeycloakClient) UpdateAuthenticationExecution(ctx context.Context, execution *AuthenticationExecution) error { authenticationExecutionUpdateRequirement := &authenticationExecutionRequirementUpdate{ RealmId: execution.RealmId, diff --git a/keycloak/authentication_subflow.go b/keycloak/authentication_subflow.go index 593facb94..69bfe8b0a 100644 --- a/keycloak/authentication_subflow.go +++ b/keycloak/authentication_subflow.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" ) type AuthenticationSubFlow struct { @@ -75,6 +76,52 @@ func (keycloakClient *KeycloakClient) GetAuthenticationSubFlow(ctx context.Conte return &authenticationSubFlow, nil } +func (keycloakClient *KeycloakClient) GetAuthenticationSubFlowFromAlias(ctx context.Context, realmId, parentFlowAlias, subflowAlias string) (*AuthenticationSubFlow, error) { + var authenticationExecutions []*AuthenticationExecutionInfo + + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/authentication/flows/%s/executions", realmId, parentFlowAlias), &authenticationExecutions, nil) + if err != nil { + return nil, fmt.Errorf("no parent flow with alias %s exists", parentFlowAlias) + } + + // Retry 3 more times if not found, sometimes it took split milliseconds the Authentication Executions to populate + if len(authenticationExecutions) == 0 { + for i := 0; i < 3; i++ { + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/authentication/flows/%s/executions", realmId, parentFlowAlias), &authenticationExecutions, nil) + + if len(authenticationExecutions) > 0 { + break + } + + if err != nil { + return nil, err + } + + time.Sleep(time.Millisecond * 50) + } + + if len(authenticationExecutions) == 0 { + return nil, fmt.Errorf("no authentication executions found for parent flow alias %s", parentFlowAlias) + } + } + + for _, aExecution := range authenticationExecutions { + if aExecution != nil && aExecution.AuthenticationFlow { + + subflow, err := keycloakClient.GetAuthenticationSubFlow(ctx, realmId, parentFlowAlias, aExecution.FlowId) + if err != nil { + return nil, err + } + + if subflow.Alias == subflowAlias { + return subflow, nil + } + } + } + + return nil, fmt.Errorf("no authentication execution under parent flow alias %s with alias %s found", parentFlowAlias, subflowAlias) +} + func (keycloakClient *KeycloakClient) getExecutionId(ctx context.Context, authenticationSubFlow *AuthenticationSubFlow) (string, error) { list, err := keycloakClient.ListAuthenticationExecutions(ctx, authenticationSubFlow.RealmId, authenticationSubFlow.ParentFlowAlias) if err != nil { @@ -89,6 +136,20 @@ func (keycloakClient *KeycloakClient) getExecutionId(ctx context.Context, authen return "", errors.New("no execution id found for subflow") } +func (keycloakClient *KeycloakClient) getExecutionIdBySubflowAlias(ctx context.Context, authenticationSubFlow *AuthenticationSubFlow) (string, error) { + list, err := keycloakClient.ListAuthenticationExecutions(ctx, authenticationSubFlow.RealmId, authenticationSubFlow.ParentFlowAlias) + if err != nil { + return "", err + } + + for _, ex := range list { + if ex.Alias == authenticationSubFlow.Alias { + return ex.Id, nil + } + } + return "", errors.New("no execution id found for subflow") +} + func (keycloakClient *KeycloakClient) UpdateAuthenticationSubFlow(ctx context.Context, authenticationSubFlow *AuthenticationSubFlow) error { authenticationSubFlow.TopLevel = false authenticationSubFlow.BuiltIn = false diff --git a/keycloak/role.go b/keycloak/role.go index f935213d7..d6d24f1cf 100644 --- a/keycloak/role.go +++ b/keycloak/role.go @@ -174,6 +174,20 @@ func (keycloakClient *KeycloakClient) DeleteRole(ctx context.Context, realmId, i return nil } +func (keycloakClient *KeycloakClient) MapCompositeRoleIdsToRoleObjects(ctx context.Context, compositeRoleIds []interface{}, realmId string) ([]*Role, error) { + var compositeRoles []*Role + + for _, compositeRoleId := range compositeRoleIds { + compositeRoleToAdd, err := keycloakClient.GetRole(ctx, realmId, compositeRoleId.(string)) + if err != nil { + return nil, err + } + + compositeRoles = append(compositeRoles, compositeRoleToAdd) + } + return compositeRoles, nil +} + func (keycloakClient *KeycloakClient) AddCompositesToRole(ctx context.Context, role *Role, compositeRoles []*Role) error { _, _, err := keycloakClient.post(ctx, fmt.Sprintf("/realms/%s/roles-by-id/%s/composites", role.RealmId, role.Id), compositeRoles) if err != nil { diff --git a/provider/resource_keycloak_authentication_execution.go b/provider/resource_keycloak_authentication_execution.go index 59a3d21ba..4b6775f61 100644 --- a/provider/resource_keycloak_authentication_execution.go +++ b/provider/resource_keycloak_authentication_execution.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/imdario/mergo" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -43,6 +44,12 @@ func resourceKeycloakAuthenticationExecution() *schema.Resource { ValidateFunc: validation.StringInSlice([]string{"REQUIRED", "ALTERNATIVE", "OPTIONAL", "CONDITIONAL", "DISABLED"}, false), //OPTIONAL is removed from 8.0.0 onwards Default: "DISABLED", }, + "import": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + }, }, } } @@ -80,9 +87,29 @@ func resourceKeycloakAuthenticationExecutionCreate(ctx context.Context, data *sc authenticationExecution := mapFromDataToAuthenticationExecution(data) - err := keycloakClient.NewAuthenticationExecution(ctx, authenticationExecution) - if err != nil { - return diag.FromErr(err) + if data.Get("import").(bool) { + realmId := data.Get("realm_id").(string) + parentFlowAlias := data.Get("parent_flow_alias").(string) + authenticator := data.Get("authenticator").(string) + + existingAuthExecution, err := keycloakClient.GetAuthenticationExecutionFromAuthenticator(ctx, realmId, parentFlowAlias, authenticator) + if err != nil { + return handleNotFoundError(ctx, err, data) + } + + if err = mergo.Merge(authenticationExecution, existingAuthExecution); err != nil { + return diag.FromErr(err) + } + + err = keycloakClient.UpdateAuthenticationExecution(ctx, authenticationExecution) + if err != nil { + return diag.FromErr(err) + } + } else { + err := keycloakClient.NewAuthenticationExecution(ctx, authenticationExecution) + if err != nil { + return diag.FromErr(err) + } } mapFromAuthenticationExecutionToData(data, authenticationExecution) @@ -124,6 +151,9 @@ func resourceKeycloakAuthenticationExecutionUpdate(ctx context.Context, data *sc func resourceKeycloakAuthenticationExecutionDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { keycloakClient := meta.(*keycloak.KeycloakClient) + if data.Get("import").(bool) { + return nil + } realmId := data.Get("realm_id").(string) id := data.Id() @@ -147,6 +177,7 @@ func resourceKeycloakAuthenticationExecutionImport(ctx context.Context, d *schem d.Set("realm_id", parts[0]) d.Set("parent_flow_alias", parts[1]) + d.Set("import", false) d.SetId(parts[2]) diagnostics := resourceKeycloakAuthenticationExecutionRead(ctx, d, meta) diff --git a/provider/resource_keycloak_authentication_execution_test.go b/provider/resource_keycloak_authentication_execution_test.go index b4f3d4a41..9ccf3ee95 100644 --- a/provider/resource_keycloak_authentication_execution_test.go +++ b/provider/resource_keycloak_authentication_execution_test.go @@ -2,6 +2,7 @@ package provider import ( "fmt" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -33,6 +34,31 @@ func TestAccKeycloakAuthenticationExecution_basic(t *testing.T) { }) } +func TestAccKeycloakAuthenticationExecution_import(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakAuthenticationExecutionNotDestroyed(), + Steps: []resource.TestStep{ + { + Config: testKeycloakAuthenticationExecution_import("non-existing-flow", "non-existing-execution", "REQUIRED"), + ExpectError: regexp.MustCompile("no parent flow with alias non-existing-flow exists"), + }, + { + Config: testKeycloakAuthenticationExecution_import("browser", "non-existing-execution", "REQUIRED"), + ExpectError: regexp.MustCompile("no authentication execution under parent flow alias browser with authenticator non-existing-execution"), + }, + { + Config: testKeycloakAuthenticationExecution_import("browser", "identity-provider-redirector", "REQUIRED"), + Check: testAccCheckKeycloakAuthenticationExecutionExistsWithRequirement("keycloak_authentication_execution.imported-execution", "REQUIRED"), + }, + }, + }) + +} + func TestAccKeycloakAuthenticationExecution_createAfterManualDestroy(t *testing.T) { t.Parallel() var authenticationExecution = &keycloak.AuthenticationExecution{} @@ -180,6 +206,42 @@ func getExecutionImportId(resourceName string) resource.ImportStateIdFunc { } } +func testAccCheckKeycloakAuthenticationExecutionExistsWithRequirement(resourceName, expectedReq string) resource.TestCheckFunc { + return func(s *terraform.State) error { + exec, err := getAuthenticationExecutionFromState(s, resourceName) + if err != nil { + return err + } + + if exec.Requirement != expectedReq { + return fmt.Errorf("expected authentication execution's requirement to be %s, but was %s", expectedReq, exec.Requirement) + } + + return nil + } +} + +func testAccCheckKeycloakAuthenticationExecutionNotDestroyed() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_authentication_execution" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + parentFlowAlias := rs.Primary.Attributes["parent_flow_alias"] + + execution, _ := keycloakClient.GetAuthenticationExecution(testCtx, realm, parentFlowAlias, id) + if execution == nil { + return fmt.Errorf("execution %s does not exists", id) + } + } + + return nil + } +} + func testKeycloakAuthenticationExecution_basic(parentAlias string) string { return fmt.Sprintf(` data "keycloak_realm" "realm" { @@ -218,3 +280,20 @@ resource "keycloak_authentication_execution" "execution" { } `, testAccRealm.Realm, parentAlias, requirement) } + +func testKeycloakAuthenticationExecution_import(parentAlias, authenticator, newReq string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_authentication_execution" "imported-execution" { + realm_id = data.keycloak_realm.realm.id + parent_flow_alias = "%s" + + authenticator = "%s" + requirement = "%s" + import = true +} + `, testAccRealm.Realm, parentAlias, authenticator, newReq) +} diff --git a/provider/resource_keycloak_authentication_flow.go b/provider/resource_keycloak_authentication_flow.go index 8dba589d6..284d7baa7 100644 --- a/provider/resource_keycloak_authentication_flow.go +++ b/provider/resource_keycloak_authentication_flow.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/imdario/mergo" "github.com/mrparkers/terraform-provider-keycloak/keycloak" "strings" ) @@ -40,14 +41,20 @@ func resourceKeycloakAuthenticationFlow() *schema.Resource { Type: schema.TypeString, Optional: true, }, + "import": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + }, }, } } func mapFromDataToAuthenticationFlow(data *schema.ResourceData) *keycloak.AuthenticationFlow { authenticationFlow := &keycloak.AuthenticationFlow{ - Id: data.Id(), RealmId: data.Get("realm_id").(string), + Id: data.Id(), Alias: data.Get("alias").(string), ProviderId: data.Get("provider_id").(string), Description: data.Get("description").(string), @@ -75,9 +82,25 @@ func resourceKeycloakAuthenticationFlowCreate(ctx context.Context, data *schema. authenticationFlow := mapFromDataToAuthenticationFlow(data) - err := keycloakClient.NewAuthenticationFlow(ctx, authenticationFlow) - if err != nil { - return diag.FromErr(err) + if data.Get("import").(bool) { + existingAuthFlow, err := keycloakClient.GetAuthenticationFlowFromAlias(ctx, data.Get("realm_id").(string), data.Get("alias").(string)) + if err != nil { + return diag.FromErr(err) + } + + if err = mergo.Merge(authenticationFlow, existingAuthFlow); err != nil { + return diag.FromErr(err) + } + + err = keycloakClient.UpdateAuthenticationFlow(ctx, authenticationFlow) + if err != nil { + return diag.FromErr(err) + } + } else { + err := keycloakClient.NewAuthenticationFlow(ctx, authenticationFlow) + if err != nil { + return diag.FromErr(err) + } } mapFromAuthenticationFlowToData(data, authenticationFlow) @@ -115,6 +138,9 @@ func resourceKeycloakAuthenticationFlowUpdate(ctx context.Context, data *schema. } func resourceKeycloakAuthenticationFlowDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + if data.Get("import").(bool) { + return nil + } keycloakClient := meta.(*keycloak.KeycloakClient) realmId := data.Get("realm_id").(string) @@ -138,6 +164,7 @@ func resourceKeycloakAuthenticationFlowImport(ctx context.Context, d *schema.Res } d.Set("realm_id", parts[0]) + d.Set("import", false) d.SetId(parts[1]) diagnostics := resourceKeycloakAuthenticationFlowRead(ctx, d, meta) diff --git a/provider/resource_keycloak_authentication_flow_test.go b/provider/resource_keycloak_authentication_flow_test.go index 5b62c16ec..35632feeb 100644 --- a/provider/resource_keycloak_authentication_flow_test.go +++ b/provider/resource_keycloak_authentication_flow_test.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/mrparkers/terraform-provider-keycloak/keycloak" + "regexp" "testing" ) @@ -121,6 +122,27 @@ func TestAccKeycloakAuthenticationFlow_updateRealm(t *testing.T) { }) } +func TestAccKeycloakAuthenticationFlow_import(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakAuthenticationFlowNotDestroyed(), + Steps: []resource.TestStep{ + { + Config: testKeycloakAuthenticationFlow_import("non-existing-auth-flow", "client-flow"), + ExpectError: regexp.MustCompile("no authentication flow found for alias non-existing-auth-flow"), + }, + { + // use existing browser flow and change the provider-id to be "client-flow" (instead of "basic-flow") + Config: testKeycloakAuthenticationFlow_import("browser", "descr"), + Check: testAccCheckKeycloakAuthenticationFlowExistsWithDescription("keycloak_authentication_flow.flow", "descr"), + }, + }, + }) +} + func testAccCheckKeycloakAuthenticationFlowExists(resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { _, err := getAuthenticationFlowFromState(s, resourceName) @@ -132,6 +154,21 @@ func testAccCheckKeycloakAuthenticationFlowExists(resourceName string) resource. } } +func testAccCheckKeycloakAuthenticationFlowExistsWithDescription(resourceName string, description string) resource.TestCheckFunc { + return func(s *terraform.State) error { + flow, err := getAuthenticationFlowFromState(s, resourceName) + if err != nil { + return err + } + + if flow.Description != description { + return fmt.Errorf("expected authentication flow's description to be %s, but was %s", description, flow.Description) + } + + return nil + } +} + func testAccCheckKeycloakAuthenticationFlowFetch(resourceName string, authenticationFlow *keycloak.AuthenticationFlow) resource.TestCheckFunc { return func(s *terraform.State) error { fetchedAuthenticationFlow, err := getAuthenticationFlowFromState(s, resourceName) @@ -181,6 +218,26 @@ func testAccCheckKeycloakAuthenticationFlowDestroy() resource.TestCheckFunc { } } +func testAccCheckKeycloakAuthenticationFlowNotDestroyed() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_authentication_flow" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + + flow, _ := keycloakClient.GetAuthenticationFlow(testCtx, realm, id) + if flow == nil { + return fmt.Errorf("authentication flow %s does not exists", id) + } + } + + return nil + } +} + func getAuthenticationFlowFromState(s *terraform.State, resourceName string) (*keycloak.AuthenticationFlow, error) { rs, ok := s.RootModule().Resources[resourceName] if !ok { @@ -244,3 +301,18 @@ resource "keycloak_authentication_flow" "flow" { } `, testAccRealm.Realm, testAccRealmTwo.Realm, alias) } + +func testKeycloakAuthenticationFlow_import(flowAlias string, description string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_authentication_flow" "flow" { + realm_id = data.keycloak_realm.realm.id + alias = "%s" + import = true + description = "%s" +} + `, testAccRealm.Realm, flowAlias, description) +} diff --git a/provider/resource_keycloak_authentication_subflow.go b/provider/resource_keycloak_authentication_subflow.go index d9e5728e6..75a1df061 100644 --- a/provider/resource_keycloak_authentication_subflow.go +++ b/provider/resource_keycloak_authentication_subflow.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/imdario/mergo" "github.com/mrparkers/terraform-provider-keycloak/keycloak" "strings" ) @@ -59,6 +60,12 @@ func resourceKeycloakAuthenticationSubFlow() *schema.Resource { ValidateFunc: validation.StringInSlice([]string{"REQUIRED", "ALTERNATIVE", "OPTIONAL", "CONDITIONAL", "DISABLED"}, false), //OPTIONAL is removed from 8.0.0 onwards Default: "DISABLED", }, + "import": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + }, }, } } @@ -94,10 +101,27 @@ func resourceKeycloakAuthenticationSubFlowCreate(ctx context.Context, data *sche authenticationFlow := mapFromDataToAuthenticationSubFlow(data) - err := keycloakClient.NewAuthenticationSubFlow(ctx, authenticationFlow) - if err != nil { - return diag.FromErr(err) + if data.Get("import").(bool) { + existingAuthFlow, err := keycloakClient.GetAuthenticationSubFlowFromAlias(ctx, data.Get("realm_id").(string), data.Get("parent_flow_alias").(string), data.Get("alias").(string)) + if err != nil { + return diag.FromErr(err) + } + + if err = mergo.Merge(authenticationFlow, existingAuthFlow); err != nil { + return diag.FromErr(err) + } + + err = keycloakClient.UpdateAuthenticationSubFlow(ctx, authenticationFlow) + if err != nil { + return diag.FromErr(err) + } + } else { + err := keycloakClient.NewAuthenticationSubFlow(ctx, authenticationFlow) + if err != nil { + return diag.FromErr(err) + } } + mapFromAuthenticationSubFlowToData(data, authenticationFlow) return resourceKeycloakAuthenticationSubFlowRead(ctx, data, meta) } @@ -131,6 +155,9 @@ func resourceKeycloakAuthenticationSubFlowUpdate(ctx context.Context, data *sche } func resourceKeycloakAuthenticationSubFlowDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + if data.Get("import").(bool) { + return nil + } keycloakClient := meta.(*keycloak.KeycloakClient) realmId := data.Get("realm_id").(string) @@ -156,6 +183,7 @@ func resourceKeycloakAuthenticationSubFlowImport(ctx context.Context, d *schema. d.Set("realm_id", parts[0]) d.Set("parent_flow_alias", parts[1]) + d.Set("import", false) d.SetId(parts[2]) diagnostics := resourceKeycloakAuthenticationSubFlowRead(ctx, d, meta) diff --git a/provider/resource_keycloak_authentication_subflow_test.go b/provider/resource_keycloak_authentication_subflow_test.go index 3b5438ced..41f061362 100644 --- a/provider/resource_keycloak_authentication_subflow_test.go +++ b/provider/resource_keycloak_authentication_subflow_test.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/mrparkers/terraform-provider-keycloak/keycloak" + "regexp" "testing" ) @@ -145,6 +146,21 @@ func testAccCheckKeycloakAuthenticationSubFlowExists(resourceName string) resour } } +func testAccCheckKeycloakAuthenticationSubFlowExistsWithDescription(resourceName, expectedDescr string) resource.TestCheckFunc { + return func(s *terraform.State) error { + subflow, err := getAuthenticationSubFlowFromState(s, resourceName) + if err != nil { + return err + } + + if subflow.Description != expectedDescr { + return fmt.Errorf("expected authentication subflow's description to be %s, but was %s", expectedDescr, subflow.Description) + } + + return nil + } +} + func testAccCheckKeycloakAuthenticationSubFlowFetch(resourceName string, authenticationSubFlow *keycloak.AuthenticationSubFlow) resource.TestCheckFunc { return func(s *terraform.State) error { fetchedAuthenticationSubFlow, err := getAuthenticationSubFlowFromState(s, resourceName) @@ -181,6 +197,51 @@ func testAccCheckKeycloakAuthenticationSubFlowDestroy() resource.TestCheckFunc { return nil } } +func testAccCheckKeycloakAuthenticationSubFlowNotDestroyed() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_authentication_subflow" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + parentFlowAlias := rs.Primary.Attributes["parent_flow_alias"] + + subflow, _ := keycloakClient.GetAuthenticationSubFlow(testCtx, realm, parentFlowAlias, id) + if subflow == nil { + return fmt.Errorf("authentication subflow %s does not exists", id) + } + } + + return nil + } +} + +func TestAccKeycloakAuthenticationSubFlowImport(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakAuthenticationSubFlowNotDestroyed(), + Steps: []resource.TestStep{ + { + Config: testKeycloakAuthenticationSubFlow_import("non-existing-flow", "non-existing-auth-flow", "new-descr"), + ExpectError: regexp.MustCompile("no parent flow with alias non-existing-flow exists"), + }, + { + Config: testKeycloakAuthenticationSubFlow_import("browser", "non-existing-auth-flow", "new-descr"), + ExpectError: regexp.MustCompile("no authentication execution under parent flow alias browser with alias non-existing-auth-flow found"), + }, + { + // use existing browser flow and change the description to be "descr" (instead of "basic-flow") + Config: testKeycloakAuthenticationSubFlow_import("browser", "forms", "new-descr"), + Check: testAccCheckKeycloakAuthenticationSubFlowExistsWithDescription("keycloak_authentication_subflow.imported-subflow", "new-descr"), + }, + }, + }) +} func getAuthenticationSubFlowFromState(s *terraform.State, resourceName string) (*keycloak.AuthenticationSubFlow, error) { rs, ok := s.RootModule().Resources[resourceName] @@ -258,3 +319,20 @@ resource "keycloak_authentication_subflow" "subflow" { } `, testAccRealm.Realm, parentAlias, alias, requirement) } + +func testKeycloakAuthenticationSubFlow_import(parentAlias, subflowAlias, description string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_authentication_subflow" "imported-subflow" { + realm_id = data.keycloak_realm.realm.id + parent_flow_alias = "%s" + + alias = "%s" + description = "%s" + import = true +} + `, testAccRealm.Realm, parentAlias, subflowAlias, description) +} diff --git a/provider/resource_keycloak_openid_client_default_scopes_test.go b/provider/resource_keycloak_openid_client_default_scopes_test.go index c7b121e21..3b1dd65bc 100644 --- a/provider/resource_keycloak_openid_client_default_scopes_test.go +++ b/provider/resource_keycloak_openid_client_default_scopes_test.go @@ -39,6 +39,10 @@ func TestAccKeycloakOpenidClientDefaultScopes_basic(t *testing.T) { }) } +// TODO: This test intermittently fails in the deployment pipeline with this error message, usually fixed by a retry +/* + error sending GET request to /admin/realms/tf-acc-8218065411161322722/client-scopes: 500 Internal Server Error. Response body: [{"id":"4927ffba-896f-4353-858a-c1d7c64dd91b","name":"microprofile-jwt","description":"Microprofile - JWT built-in scope","protocol":"openid-connect","attributes":{"include.in.token.scope":"true","display.on.consent.screen":"false"},"protocolMappers":[{"id":"ce4d3546-0c1d-456e-8747-5654920faedc","name":"groups","protocol":"openid-connect","protocolMapper":"oidc-usermodel-realm-role-mapper","consentRequired":false,"config":{"multivalued":"true","user.attribute":"foo","id.token.claim":"true","access.token.claim":"true","claim.name":"groups","jsonType.label":"String"}},{"id":"34a6a17c-6a1c-44aa-92fd-7819acde4f29","name":"upn","protocol":"openid-connect","protocolMapper":"oidc-usermodel-property-mapper","consentRequired":false,"config":{"userinfo.token.claim":"true","user.attribute":"username","id.token.claim":"true","access.token.claim":"true","claim.name":"upn","jsonType.label":"String"}}]}]{"error":"unknown_error"} +*/ func TestAccKeycloakOpenidClientDefaultScopes_updateClientForceNew(t *testing.T) { t.Parallel() clientOne := acctest.RandomWithPrefix("tf-acc") diff --git a/provider/resource_keycloak_role.go b/provider/resource_keycloak_role.go index 7fa1fd7e7..3fb04c0c3 100644 --- a/provider/resource_keycloak_role.go +++ b/provider/resource_keycloak_role.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/imdario/mergo" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -52,6 +53,12 @@ func resourceKeycloakRole() *schema.Resource { Type: schema.TypeMap, Optional: true, }, + "import": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + }, }, } } @@ -95,34 +102,62 @@ func resourceKeycloakRoleCreate(ctx context.Context, data *schema.ResourceData, role := mapFromDataToRole(data) - var compositeRoles []*keycloak.Role - if v, ok := data.GetOk("composite_roles"); ok { - compositeRolesTf := v.(*schema.Set).List() + if data.Get("import").(bool) { + realmId := data.Get("realm_id").(string) + name := data.Get("name").(string) + clientId := data.Get("client_id").(string) + compositeRoles, err := keycloakClient.MapCompositeRoleIdsToRoleObjects(ctx, data.Get("composite_roles").(*schema.Set).List(), role.RealmId) + if err != nil { + return diag.FromErr(err) + } + if len(compositeRoles) != 0 { + role.Composite = true + } - for _, compositeRoleId := range compositeRolesTf { - compositeRoleToAdd, err := keycloakClient.GetRole(ctx, role.RealmId, compositeRoleId.(string)) - if err != nil { + existingRole, err := keycloakClient.GetRoleByName(ctx, realmId, clientId, name) + if err != nil { + return diag.FromErr(err) + } + + if err = mergo.Merge(role, existingRole); err != nil { + return diag.FromErr(err) + } + if err = keycloakClient.UpdateRole(ctx, role); err != nil { + return diag.FromErr(err) + } + + existingCompositeRoles, err := keycloakClient.GetRoleComposites(ctx, role) + if err != nil { + return diag.FromErr(err) + } + if role.Composite { + if err = keycloakClient.RemoveCompositesFromRole(ctx, role, existingCompositeRoles); err != nil { + return diag.FromErr(err) + } + if err = keycloakClient.AddCompositesToRole(ctx, role, compositeRoles); err != nil { return diag.FromErr(err) } + } - compositeRoles = append(compositeRoles, compositeRoleToAdd) + } else { + compositeRoles, err := keycloakClient.MapCompositeRoleIdsToRoleObjects(ctx, data.Get("composite_roles").(*schema.Set).List(), role.RealmId) + if err != nil { + return diag.FromErr(err) } if len(compositeRoles) != 0 { // technically you can still specify composite_roles = [] in HCL role.Composite = true } - } - - err := keycloakClient.CreateRole(ctx, role) - if err != nil { - return diag.FromErr(err) - } - if role.Composite { - err = keycloakClient.AddCompositesToRole(ctx, role, compositeRoles) - if err != nil { + if err = keycloakClient.CreateRole(ctx, role); err != nil { return diag.FromErr(err) } + + if role.Composite { + if err = keycloakClient.AddCompositesToRole(ctx, role, compositeRoles); err != nil { + return diag.FromErr(err) + } + } } mapFromRoleToData(data, role) @@ -232,6 +267,10 @@ func resourceKeycloakRoleUpdate(ctx context.Context, data *schema.ResourceData, } func resourceKeycloakRoleDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + if data.Get("import").(bool) { + return nil + } + keycloakClient := meta.(*keycloak.KeycloakClient) realmId := data.Get("realm_id").(string) @@ -254,6 +293,7 @@ func resourceKeycloakRoleImport(ctx context.Context, d *schema.ResourceData, met } d.Set("realm_id", parts[0]) + d.Set("import", false) d.SetId(parts[1]) diagnostics := resourceKeycloakRoleRead(ctx, d, meta) diff --git a/provider/resource_keycloak_role_test.go b/provider/resource_keycloak_role_test.go index 942474465..e68f1d2ba 100644 --- a/provider/resource_keycloak_role_test.go +++ b/provider/resource_keycloak_role_test.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/mrparkers/terraform-provider-keycloak/keycloak" + "regexp" "strings" "testing" ) @@ -131,6 +132,45 @@ func TestAccKeycloakRole_basicRealmUpdate(t *testing.T) { }) } +func TestAccKeycloakRole_import(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakRoleNotDestroyed(), + Steps: []resource.TestStep{ + { + Config: testKeycloakRole_importRealmRole("non-existing-role", "updated-descr"), + ExpectError: regexp.MustCompile("Could not find role"), + }, + { + Config: testKeycloakRole_importClientRole("view-profile", "non-existing-client", "updated-descr"), + ExpectError: regexp.MustCompile("openid client with name non-existing-client does not exist"), + }, + { + Config: testKeycloakRole_importClientRole("non-existing-role", "account", "updated-descr"), + ExpectError: regexp.MustCompile("Could not find role"), + }, + { + Config: testKeycloakRole_importRealmRole("offline_access", "updated-descr"), + Check: testAccCheckKeycloakRoleHasDescription("keycloak_role.imported-realm-role", "updated-descr"), + }, + { + Config: testKeycloakRole_importClientRole("view-profile", "account", "updated-descr"), + Check: testAccCheckKeycloakRoleHasDescription("keycloak_role.imported-client-role", "updated-descr"), + }, + //{ + // // TODO: fix permadiff error "After applying this test step, the plan was not empty." + // // https://googlecloudplatform.github.io/magic-modules/develop/permadiff/ + // // commented out for now, because the change itself works + // Config: testKeycloakRole_importCompositeRole("default-roles-"+testAccRealm.Realm, "offline_access"), + // Check: testAccCheckKeycloakRoleHasComposites("keycloak_role.imported-composite-role", []string{"offline_access"}), + //}, + }, + }) +} + func TestAccKeycloakRole_basicClientUpdate(t *testing.T) { t.Parallel() clientId := acctest.RandomWithPrefix("tf-acc") @@ -303,6 +343,26 @@ func testAccCheckKeycloakRoleExists(resourceName string) resource.TestCheckFunc } } +func testAccCheckKeycloakRoleNotDestroyed() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_role" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + + role, _ := keycloakClient.GetRole(testCtx, realm, id) + if role == nil { + return fmt.Errorf("role %s does not exists", id) + } + } + + return nil + } +} + func testAccCheckKeycloakRoleDestroy() resource.TestCheckFunc { return func(s *terraform.State) error { for name, rs := range s.RootModule().Resources { @@ -354,6 +414,21 @@ func testAccCheckKeycloakRoleHasAttribute(resourceName, attributeName, attribute } } +func testAccCheckKeycloakRoleHasDescription(resourceName, expectedDescr string) resource.TestCheckFunc { + return func(state *terraform.State) error { + role, err := getRoleFromState(state, resourceName) + if err != nil { + return err + } + + if role.Description != expectedDescr { + return fmt.Errorf("expected role's description to be %s, but was %s", expectedDescr, role.Description) + } + + return nil + } +} + func testAccCheckKeycloakRoleHasComposites(resourceName string, compositeRoleNames []string) resource.TestCheckFunc { return func(state *terraform.State) error { role, err := getRoleFromState(state, resourceName) @@ -579,3 +654,62 @@ resource "keycloak_role" "role" { } `, testAccRealm.Realm, role, attributeName, attributeValue) } + +func testKeycloakRole_importRealmRole(name, newDescr string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_role" "imported-realm-role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + import = true + description = "%s" +} + `, testAccRealm.Realm, name, newDescr) +} + +func testKeycloakRole_importClientRole(name, clientId, newDescr string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "imported-client" { + realm_id = data.keycloak_realm.realm.id + client_id = "%s" + import = true + access_type = "PUBLIC" +} + +resource "keycloak_role" "imported-client-role" { + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_openid_client.imported-client.id + name = "%s" + import = true + description = "%s" +} + `, testAccRealm.Realm, clientId, name, newDescr) +} + +func testKeycloakRole_importCompositeRole(name, nestedRoleName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_role" "imported-noncomposite-role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + import = true +} + +resource "keycloak_role" "imported-composite-role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + import = true + composite_roles = [keycloak_role.imported-noncomposite-role.id] +} + `, testAccRealm.Realm, nestedRoleName, name) +} diff --git a/provider/resource_keycloak_user.go b/provider/resource_keycloak_user.go index da04cd264..a08e84969 100644 --- a/provider/resource_keycloak_user.go +++ b/provider/resource_keycloak_user.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/imdario/mergo" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -114,6 +115,12 @@ func resourceKeycloakUser() *schema.Resource { Optional: true, Default: true, }, + "import": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + }, }, } } @@ -204,20 +211,40 @@ func resourceKeycloakUserCreate(ctx context.Context, data *schema.ResourceData, user := mapFromDataToUser(data) - err := keycloakClient.NewUser(ctx, user) - if err != nil { - return diag.FromErr(err) - } + if data.Get("import").(bool) { + username := data.Get("username").(string) + existingUser, err := keycloakClient.GetUserByUsername(ctx, data.Get("realm_id").(string), username) + if err != nil { + return diag.FromErr(err) + } + if existingUser == nil { + return diag.FromErr(fmt.Errorf("no user found for username %s", username)) + } + + if err = mergo.Merge(user, existingUser); err != nil { + return diag.FromErr(err) + } - v, isInitialPasswordSet := data.GetOk("initial_password") - if isInitialPasswordSet { - passwordBlock := v.([]interface{})[0].(map[string]interface{}) - passwordValue := passwordBlock["value"].(string) - isPasswordTemporary := passwordBlock["temporary"].(bool) - err := keycloakClient.ResetUserPassword(ctx, user.RealmId, user.Id, passwordValue, isPasswordTemporary) + err = keycloakClient.UpdateUser(ctx, user) if err != nil { return diag.FromErr(err) } + } else { + err := keycloakClient.NewUser(ctx, user) + if err != nil { + return diag.FromErr(err) + } + + v, isInitialPasswordSet := data.GetOk("initial_password") + if isInitialPasswordSet { + passwordBlock := v.([]interface{})[0].(map[string]interface{}) + passwordValue := passwordBlock["value"].(string) + isPasswordTemporary := passwordBlock["temporary"].(bool) + err := keycloakClient.ResetUserPassword(ctx, user.RealmId, user.Id, passwordValue, isPasswordTemporary) + if err != nil { + return diag.FromErr(err) + } + } } mapFromUserToData(data, user) @@ -257,6 +284,10 @@ func resourceKeycloakUserUpdate(ctx context.Context, data *schema.ResourceData, } func resourceKeycloakUserDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + if data.Get("import").(bool) { + return nil + } + keycloakClient := meta.(*keycloak.KeycloakClient) realmId := data.Get("realm_id").(string) @@ -279,6 +310,7 @@ func resourceKeycloakUserImport(ctx context.Context, d *schema.ResourceData, met } d.Set("realm_id", parts[0]) + d.Set("import", false) d.SetId(parts[1]) diagnostics := resourceKeycloakUserRead(ctx, d, meta) diff --git a/provider/resource_keycloak_user_test.go b/provider/resource_keycloak_user_test.go index d8cbe5e27..fd340410d 100644 --- a/provider/resource_keycloak_user_test.go +++ b/provider/resource_keycloak_user_test.go @@ -42,6 +42,26 @@ func TestAccKeycloakUser_basic(t *testing.T) { }) } +func TestAccKeycloakUser_import(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakUserNotDestroyed(), + Steps: []resource.TestStep{ + { + Config: testKeycloakUser_import("master", "non-existing-username"), + ExpectError: regexp.MustCompile("no user found for username non-existing-username"), + }, + { + Config: testKeycloakUser_import("master", "service-account-terraform"), + Check: testAccCheckKeycloakUserExistsWithUsername("keycloak_user.user", "service-account-terraform"), + }, + }, + }) +} + func TestAccKeycloakUser_withInitialPassword(t *testing.T) { t.Parallel() username := acctest.RandomWithPrefix("tf-acc") @@ -323,6 +343,21 @@ func testAccCheckKeycloakUserExists(resourceName string) resource.TestCheckFunc } } +func testAccCheckKeycloakUserExistsWithUsername(resourceName, username string) resource.TestCheckFunc { + return func(s *terraform.State) error { + user, err := getUserFromState(s, resourceName) + if err != nil { + return err + } + + if user.Username != username { + return fmt.Errorf("no user found for username %s", username) + } + + return nil + } +} + func testAccCheckKeycloakUserFetch(resourceName string, user *keycloak.User) resource.TestCheckFunc { return func(s *terraform.State) error { fetchedUser, err := getUserFromState(s, resourceName) @@ -390,6 +425,26 @@ func testAccCheckKeycloakUserDestroy() resource.TestCheckFunc { } } +func testAccCheckKeycloakUserNotDestroyed() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_user" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + + user, _ := keycloakClient.GetUser(testCtx, realm, id) + if user == nil { + return fmt.Errorf("user %s does not exists", id) + } + } + + return nil + } +} + func getUserFromState(s *terraform.State, resourceName string) (*keycloak.User, error) { rs, ok := s.RootModule().Resources[resourceName] if !ok { @@ -423,6 +478,20 @@ resource "keycloak_user" "user" { `, testAccRealm.Realm, username, attributeName, attributeValue) } +func testKeycloakUser_import(realmId, username string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_user" "user" { + realm_id = data.keycloak_realm.realm.id + username = "%s" + import = "true" +} + `, realmId, username) +} + func testKeycloakUser_initialPassword(username string, password string, clientId string) string { return fmt.Sprintf(` data "keycloak_realm" "realm" {