From 425c754e48af28e6999a4ffdaddeb12270b506e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20B=C3=B6hm?= Date: Mon, 20 Jan 2025 14:31:18 +0100 Subject: [PATCH] add support for configuring default and optional client scopes per realm via dedicated resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Philipp Böhm --- docs/resources/realm.md | 4 +- docs/resources/realm_default_client_scopes.md | 47 ++++++ .../resources/realm_optional_client_scopes.md | 47 ++++++ keycloak/openid_client.go | 19 --- keycloak/realm_client_scope.go | 97 ++++++++++++ provider/provider.go | 2 + ...ce_keycloak_realm_default_client_scopes.go | 99 +++++++++++++ ...ycloak_realm_default_client_scopes_test.go | 132 +++++++++++++++++ ...e_keycloak_realm_optional_client_scopes.go | 99 +++++++++++++ ...cloak_realm_optional_client_scopes_test.go | 138 ++++++++++++++++++ 10 files changed, 663 insertions(+), 21 deletions(-) create mode 100644 docs/resources/realm_default_client_scopes.md create mode 100644 docs/resources/realm_optional_client_scopes.md create mode 100644 keycloak/realm_client_scope.go create mode 100644 provider/resource_keycloak_realm_default_client_scopes.go create mode 100644 provider/resource_keycloak_realm_default_client_scopes_test.go create mode 100644 provider/resource_keycloak_realm_optional_client_scopes.go create mode 100644 provider/resource_keycloak_realm_optional_client_scopes_test.go diff --git a/docs/resources/realm.md b/docs/resources/realm.md index 82dc30cd5..9422a947a 100644 --- a/docs/resources/realm.md +++ b/docs/resources/realm.md @@ -242,8 +242,8 @@ Each of these attributes are blocks with the following attributes: ## Default Client Scopes -- `default_default_client_scopes` - (Optional) A list of default `default client scopes` to be used for client definitions. Defaults to `[]` or keycloak's built-in default `default client-scopes`. -- `default_optional_client_scopes` - (Optional) A list of default `optional client scopes` to be used for client definitions. Defaults to `[]` or keycloak's built-in default `optional client-scopes`. +- `default_default_client_scopes` - (Optional) A list of default `default client scopes` to be used for client definitions. Defaults to `[]` or keycloak's built-in default `default client-scopes`. For an alternative, please refer to the dedicated resource `keycloak_realm_default_client_scopes`. +- `default_optional_client_scopes` - (Optional) A list of default `optional client scopes` to be used for client definitions. Defaults to `[]` or keycloak's built-in default `optional client-scopes`. For an alternative, please refer to the dedicated resource `keycloak_realm_optional_client_scopes`. ## Import diff --git a/docs/resources/realm_default_client_scopes.md b/docs/resources/realm_default_client_scopes.md new file mode 100644 index 000000000..9fc391f98 --- /dev/null +++ b/docs/resources/realm_default_client_scopes.md @@ -0,0 +1,47 @@ +--- +page_title: "keycloak_realm_default_client_scopes Resource" +--- + +# keycloak\_realm\_default\_client\_scopes Resource + +Allows you to manage the set of default client scopes for a Keycloak realm, which are used when new clients are created. + +Note that this resource attempts to be an **authoritative** source over the default client scopes for a Keycloak realm, +so any Keycloak defaults and manual adjustments will be overwritten. + + +## Example Usage + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_openid_client_scope" "client_scope" { + realm_id = keycloak_realm.realm.id + name = "test-client-scope" +} + +resource "keycloak_realm_default_client_scopes" "default_scopes" { + realm_id = keycloak_realm.realm.id + + default_scopes = [ + "profile", + "email", + "roles", + "web-origins", + keycloak_openid_client_scope.client_scope.name, + ] +} +``` + +## Argument Reference + +- `realm_id` - (Required) The realm this client and scopes exists in. +- `default_scopes` - (Required) An array of default client scope names that should be used when creating new Keycloak clients. + +## Import + +This resource does not support import. Instead of importing, feel free to create this resource +as if it did not already exist on the server. diff --git a/docs/resources/realm_optional_client_scopes.md b/docs/resources/realm_optional_client_scopes.md new file mode 100644 index 000000000..43605d8af --- /dev/null +++ b/docs/resources/realm_optional_client_scopes.md @@ -0,0 +1,47 @@ +--- +page_title: "keycloak_realm_optional_client_scopes Resource" +--- + +# keycloak\_realm\_optional\_client\_scopes Resource + +Allows you to manage the set of optional client scopes for a Keycloak realm, which are used when new clients are created. + +Note that this resource attempts to be an **authoritative** source over the optional client scopes for a Keycloak realm, +so any Keycloak defaults and manual adjustments will be overwritten. + + +## Example Usage + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_openid_client_scope" "client_scope" { + realm_id = keycloak_realm.realm.id + name = "test-client-scope" +} + +resource "keycloak_realm_optional_client_scopes" "optional_scopes" { + realm_id = keycloak_realm.realm.id + + optional_scopes = [ + "address", + "phone", + "offline_access", + "microprofile-jwt", + keycloak_openid_client_scope.client_scope.name + ] +} +``` + +## Argument Reference + +- `realm_id` - (Required) The realm this client and scopes exists in. +- `optional_scopes` - (Required) An array of optional client scope names that should be used when creating new Keycloak clients. + +## Import + +This resource does not support import. Instead of importing, feel free to create this resource +as if it did not already exist on the server. diff --git a/keycloak/openid_client.go b/keycloak/openid_client.go index df4cdafb3..49e48b267 100644 --- a/keycloak/openid_client.go +++ b/keycloak/openid_client.go @@ -256,25 +256,6 @@ func (keycloakClient *KeycloakClient) GetOpenidClientOptionalScopes(ctx context. return keycloakClient.getOpenidClientScopes(ctx, realmId, clientId, "optional") } -func (keycloakClient *KeycloakClient) getRealmClientScopes(ctx context.Context, realmId, t string) ([]*OpenidClientScope, error) { - var scopes []*OpenidClientScope - - err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/default-%s-client-scopes", realmId, t), &scopes, nil) - if err != nil { - return nil, err - } - - return scopes, nil -} - -func (keycloakClient *KeycloakClient) GetRealmDefaultClientScopes(ctx context.Context, realmId string) ([]*OpenidClientScope, error) { - return keycloakClient.getRealmClientScopes(ctx, realmId, "default") -} - -func (keycloakClient *KeycloakClient) GetRealmOptionalClientScopes(ctx context.Context, realmId string) ([]*OpenidClientScope, error) { - return keycloakClient.getRealmClientScopes(ctx, realmId, "optional") -} - func (keycloakClient *KeycloakClient) attachOpenidClientScopes(ctx context.Context, realmId, clientId, t string, scopeNames []string) error { openidClient, err := keycloakClient.GetOpenidClient(ctx, realmId, clientId) if err != nil && ErrorIs404(err) { diff --git a/keycloak/realm_client_scope.go b/keycloak/realm_client_scope.go new file mode 100644 index 000000000..2f5b429bb --- /dev/null +++ b/keycloak/realm_client_scope.go @@ -0,0 +1,97 @@ +package keycloak + +import ( + "context" + "errors" + "fmt" +) + +func (keycloakClient *KeycloakClient) getRealmClientScopesOfType(ctx context.Context, realmId, t string) ([]*OpenidClientScope, error) { + var scopes []*OpenidClientScope + + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/default-%s-client-scopes", realmId, t), &scopes, nil) + if err != nil { + return nil, err + } + + return scopes, nil +} + +func (keycloakClient *KeycloakClient) GetRealmDefaultClientScopes(ctx context.Context, realmId string) ([]*OpenidClientScope, error) { + return keycloakClient.getRealmClientScopesOfType(ctx, realmId, "default") +} + +func (keycloakClient *KeycloakClient) GetRealmOptionalClientScopes(ctx context.Context, realmId string) ([]*OpenidClientScope, error) { + return keycloakClient.getRealmClientScopesOfType(ctx, realmId, "optional") +} + +func (keycloakClient *KeycloakClient) resolveClientScopeNamesIntoIds(ctx context.Context, realmId string, scopeNames []string) ([]string, error) { + var scopeIds []string + var clientScopes []OpenidClientScope + + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/client-scopes", realmId), &clientScopes, nil) + if err != nil { + return nil, err + } + +ScopeNames: + for _, scopeName := range scopeNames { + for _, clientScope := range clientScopes { + if clientScope.Name == scopeName { + scopeIds = append(scopeIds, clientScope.Id) + continue ScopeNames + } + } + + return nil, errors.New(fmt.Sprintf("Client scope with name %s not found in realm %s", scopeName, realmId)) + } + + return scopeIds, nil +} + +func (keycloakClient *KeycloakClient) resolveAndHandleClientScopes(ctx context.Context, realmId string, scopeNames []string, handler func(context.Context, string, string) error) error { + scopeIds, err := keycloakClient.resolveClientScopeNamesIntoIds(ctx, realmId, scopeNames) + if err != nil { + return err + } + + for _, scopeId := range scopeIds { + if err := handler(ctx, realmId, scopeId); err != nil { + return err + } + } + + return nil +} + +func (keycloakClient *KeycloakClient) markClientScopeAs(ctx context.Context, realmId, scopeId, t string) error { + return keycloakClient.put(ctx, fmt.Sprintf("/realms/%s/default-%s-client-scopes/%s", realmId, t, scopeId), nil) +} + +func (keycloakClient *KeycloakClient) MarkClientScopesAsRealmDefault(ctx context.Context, realmId string, scopeNames []string) error { + return keycloakClient.resolveAndHandleClientScopes(ctx, realmId, scopeNames, func(ctx context.Context, realmId, scopeId string) error { + return keycloakClient.markClientScopeAs(ctx, realmId, scopeId, "default") + }) +} + +func (keycloakClient *KeycloakClient) MarkClientScopesAsRealmOptional(ctx context.Context, realmId string, scopeNames []string) error { + return keycloakClient.resolveAndHandleClientScopes(ctx, realmId, scopeNames, func(ctx context.Context, realmId, scopeId string) error { + return keycloakClient.markClientScopeAs(ctx, realmId, scopeId, "optional") + }) +} + +func (keycloakClient *KeycloakClient) unmarkClientScopeAs(ctx context.Context, realmId, scopeId, t string) error { + return keycloakClient.delete(ctx, fmt.Sprintf("/realms/%s/default-%s-client-scopes/%s", realmId, t, scopeId), nil) +} + +func (keycloakClient *KeycloakClient) UnmarkClientScopesAsRealmDefault(ctx context.Context, realmId string, scopeNames []string) error { + return keycloakClient.resolveAndHandleClientScopes(ctx, realmId, scopeNames, func(ctx context.Context, realmId, scopeId string) error { + return keycloakClient.unmarkClientScopeAs(ctx, realmId, scopeId, "default") + }) +} + +func (keycloakClient *KeycloakClient) UnmarkClientScopesAsRealmOptional(ctx context.Context, realmId string, scopeNames []string) error { + return keycloakClient.resolveAndHandleClientScopes(ctx, realmId, scopeNames, func(ctx context.Context, realmId, scopeId string) error { + return keycloakClient.unmarkClientScopeAs(ctx, realmId, scopeId, "optional") + }) +} diff --git a/provider/provider.go b/provider/provider.go index a30091284..f165ca6ce 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -32,6 +32,8 @@ func KeycloakProvider(client *keycloak.KeycloakClient) *schema.Provider { ResourcesMap: map[string]*schema.Resource{ "keycloak_realm": resourceKeycloakRealm(), "keycloak_realm_events": resourceKeycloakRealmEvents(), + "keycloak_realm_default_client_scopes": resourceKeycloakRealmDefaultClientScopes(), + "keycloak_realm_optional_client_scopes": resourceKeycloakRealmOptionalClientScopes(), "keycloak_realm_keystore_aes_generated": resourceKeycloakRealmKeystoreAesGenerated(), "keycloak_realm_keystore_ecdsa_generated": resourceKeycloakRealmKeystoreEcdsaGenerated(), "keycloak_realm_keystore_hmac_generated": resourceKeycloakRealmKeystoreHmacGenerated(), diff --git a/provider/resource_keycloak_realm_default_client_scopes.go b/provider/resource_keycloak_realm_default_client_scopes.go new file mode 100644 index 000000000..581c61a80 --- /dev/null +++ b/provider/resource_keycloak_realm_default_client_scopes.go @@ -0,0 +1,99 @@ +package provider + +import ( + "context" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/keycloak/terraform-provider-keycloak/keycloak" +) + +func resourceKeycloakRealmDefaultClientScopes() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeycloakRealmDefaultClientScopesReconcile, + ReadContext: resourceKeycloakRealmDefaultClientScopesRead, + DeleteContext: resourceKeycloakRealmDefaultClientScopesDelete, + UpdateContext: resourceKeycloakRealmDefaultClientScopesReconcile, + Schema: map[string]*schema.Schema{ + "realm_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "default_scopes": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Required: true, + Set: schema.HashString, + }, + }, + } +} + +func resourceKeycloakRealmDefaultClientScopesRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + + defaultClientScopes, err := keycloakClient.GetRealmDefaultClientScopes(ctx, realmId) + if err != nil { + return handleNotFoundError(ctx, err, data) + } + + var scopeNames []string + for _, clientScope := range defaultClientScopes { + scopeNames = append(scopeNames, clientScope.Name) + } + + data.Set("default_scopes", scopeNames) + data.SetId(realmId) + + return nil +} + +func resourceKeycloakRealmDefaultClientScopesReconcile(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + tfDefaultClientScopes := data.Get("default_scopes").(*schema.Set) + + keycloakDefaultClientScopes, err := keycloakClient.GetRealmDefaultClientScopes(ctx, realmId) + if err != nil { + return diag.FromErr(err) + } + + var scopesToUnmark []string + for _, keycloakDefaultClientScope := range keycloakDefaultClientScopes { + // if this scope is a default client scope in keycloak and tf state, no update is required + if tfDefaultClientScopes.Contains(keycloakDefaultClientScope.Name) { + tfDefaultClientScopes.Remove(keycloakDefaultClientScope.Name) + } else { + // if this scope is marked as default in keycloak but not in tf state unmark it + scopesToUnmark = append(scopesToUnmark, keycloakDefaultClientScope.Name) + } + } + + // unmark scopes that aren't in tf state + err = keycloakClient.UnmarkClientScopesAsRealmDefault(ctx, realmId, scopesToUnmark) + if err != nil { + return diag.FromErr(err) + } + + // mark scopes as default that exist in tf state but not in keycloak + err = keycloakClient.MarkClientScopesAsRealmDefault(ctx, realmId, interfaceSliceToStringSlice(tfDefaultClientScopes.List())) + if err != nil { + return diag.FromErr(err) + } + + data.SetId(realmId) + + return resourceKeycloakRealmDefaultClientScopesRead(ctx, data, meta) +} + +func resourceKeycloakRealmDefaultClientScopesDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + defaultClientScopes := data.Get("default_scopes").(*schema.Set) + + return diag.FromErr(keycloakClient.UnmarkClientScopesAsRealmDefault(ctx, realmId, interfaceSliceToStringSlice(defaultClientScopes.List()))) +} diff --git a/provider/resource_keycloak_realm_default_client_scopes_test.go b/provider/resource_keycloak_realm_default_client_scopes_test.go new file mode 100644 index 000000000..fafce191e --- /dev/null +++ b/provider/resource_keycloak_realm_default_client_scopes_test.go @@ -0,0 +1,132 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/keycloak/terraform-provider-keycloak/keycloak" + "slices" + "sort" + "testing" +) + +func TestAccKeycloakRealmDefaultClientScopes_basic(t *testing.T) { + t.Parallel() + realmName := acctest.RandomWithPrefix("tf-acc") + clientScope := acctest.RandomWithPrefix("tf-acc") + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testKeycloakRealmDefaultScopes_basic(realmName, clientScope), + Check: testAccCheckKeycloakRealmHasDefaultScopes( + "keycloak_realm_default_client_scopes.default_scopes", + []string{"profile", "email", "web-origins", "roles", "role_list", clientScope}), + }, + }, + }) +} + +func TestAccKeycloakRealmDefaultClientScopes_empty(t *testing.T) { + t.Parallel() + realmName := acctest.RandomWithPrefix("tf-acc") + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testKeycloakRealmDefaultScopes_empty(realmName), + Check: testAccCheckKeycloakRealmHasDefaultScopes( + "keycloak_realm_default_client_scopes.default_scopes", + []string{}), + }, + }, + }) +} + +func getRealmDefaultClientScopesFromState(resourceName string, s *terraform.State) ([]*keycloak.OpenidClientScope, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return nil, fmt.Errorf("resource not found: %s", resourceName) + } + + realm := rs.Primary.Attributes["realm_id"] + + keycloakDefaultClientScopes, err := keycloakClient.GetRealmDefaultClientScopes(testCtx, realm) + if err != nil { + return nil, err + } + + return keycloakDefaultClientScopes, nil +} + +func testAccCheckKeycloakRealmHasDefaultScopes(resourceName string, expectedClientScopeNames []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + keycloakDefaultClientScopes, err := getRealmDefaultClientScopesFromState(resourceName, s) + if err != nil { + return err + } + + var assignedClientScopeNames []string + for _, keycloakDefaultScope := range keycloakDefaultClientScopes { + assignedClientScopeNames = append(assignedClientScopeNames, keycloakDefaultScope.Name) + } + + sort.Strings(expectedClientScopeNames) + sort.Strings(assignedClientScopeNames) + + if !slices.Equal(assignedClientScopeNames, expectedClientScopeNames) { + return fmt.Errorf( + "assigned and expected realm default client scopes do not match %v != %v", + assignedClientScopeNames, + expectedClientScopeNames, + ) + } + + return nil + } +} + +func testKeycloakRealmDefaultScopes_basic(realmName string, clientScope string) string { + return fmt.Sprintf(` +resource "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client_scope" "client_scope" { + name = "%s" + realm_id = keycloak_realm.realm.id + + description = "test description" +} + +resource "keycloak_realm_default_client_scopes" "default_scopes" { + realm_id = keycloak_realm.realm.id + default_scopes = [ + "profile", + "email", + "roles", + "role_list", + "web-origins", + keycloak_openid_client_scope.client_scope.name + ] +} + `, realmName, clientScope) +} + +func testKeycloakRealmDefaultScopes_empty(realmName string) string { + return fmt.Sprintf(` +resource "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_realm_default_client_scopes" "default_scopes" { + realm_id = keycloak_realm.realm.id + default_scopes = [] +} + `, realmName) +} diff --git a/provider/resource_keycloak_realm_optional_client_scopes.go b/provider/resource_keycloak_realm_optional_client_scopes.go new file mode 100644 index 000000000..9120c10fe --- /dev/null +++ b/provider/resource_keycloak_realm_optional_client_scopes.go @@ -0,0 +1,99 @@ +package provider + +import ( + "context" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/keycloak/terraform-provider-keycloak/keycloak" +) + +func resourceKeycloakRealmOptionalClientScopes() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeycloakRealmOptionalClientScopesReconcile, + ReadContext: resourceKeycloakRealmOptionalClientScopesRead, + DeleteContext: resourceKeycloakRealmOptionalClientScopesDelete, + UpdateContext: resourceKeycloakRealmOptionalClientScopesReconcile, + Schema: map[string]*schema.Schema{ + "realm_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "optional_scopes": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Required: true, + Set: schema.HashString, + }, + }, + } +} + +func resourceKeycloakRealmOptionalClientScopesRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + + optionalClientScopes, err := keycloakClient.GetRealmOptionalClientScopes(ctx, realmId) + if err != nil { + return handleNotFoundError(ctx, err, data) + } + + var scopeNames []string + for _, clientScope := range optionalClientScopes { + scopeNames = append(scopeNames, clientScope.Name) + } + + data.Set("optional_scopes", scopeNames) + data.SetId(realmId) + + return nil +} + +func resourceKeycloakRealmOptionalClientScopesReconcile(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + tfOptionalClientScopes := data.Get("optional_scopes").(*schema.Set) + + keycloakOptionalClientScopes, err := keycloakClient.GetRealmOptionalClientScopes(ctx, realmId) + if err != nil { + return diag.FromErr(err) + } + + var scopesToUnmark []string + for _, keycloakOptionalClientScope := range keycloakOptionalClientScopes { + // if this scope is an optional client scope in keycloak and tf state, no update is required + if tfOptionalClientScopes.Contains(keycloakOptionalClientScope.Name) { + tfOptionalClientScopes.Remove(keycloakOptionalClientScope.Name) + } else { + // if this scope is marked as optional in keycloak but not in tf state unmark it + scopesToUnmark = append(scopesToUnmark, keycloakOptionalClientScope.Name) + } + } + + // unmark scopes that aren't in tf state + err = keycloakClient.UnmarkClientScopesAsRealmOptional(ctx, realmId, scopesToUnmark) + if err != nil { + return diag.FromErr(err) + } + + // mark scopes as optional that exist in tf state but not in keycloak + err = keycloakClient.MarkClientScopesAsRealmOptional(ctx, realmId, interfaceSliceToStringSlice(tfOptionalClientScopes.List())) + if err != nil { + return diag.FromErr(err) + } + + data.SetId(realmId) + + return resourceKeycloakRealmOptionalClientScopesRead(ctx, data, meta) +} + +func resourceKeycloakRealmOptionalClientScopesDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + optionalClientScopes := data.Get("optional_scopes").(*schema.Set) + + return diag.FromErr(keycloakClient.UnmarkClientScopesAsRealmOptional(ctx, realmId, interfaceSliceToStringSlice(optionalClientScopes.List()))) +} diff --git a/provider/resource_keycloak_realm_optional_client_scopes_test.go b/provider/resource_keycloak_realm_optional_client_scopes_test.go new file mode 100644 index 000000000..0d6a9be91 --- /dev/null +++ b/provider/resource_keycloak_realm_optional_client_scopes_test.go @@ -0,0 +1,138 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/keycloak/terraform-provider-keycloak/keycloak" + "slices" + "sort" + "testing" +) + +func TestAccKeycloakRealmOptionalClientScopes_basic(t *testing.T) { + t.Parallel() + realmName := acctest.RandomWithPrefix("tf-acc") + clientScope := acctest.RandomWithPrefix("tf-acc") + + resource.Test( + t, + resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testKeycloakRealmOptionalScopes_basic(realmName, clientScope), + Check: testAccCheckKeycloakRealmHasOptionalScopes( + "keycloak_realm_optional_client_scopes.optional_scopes", + []string{"address", "phone", "offline_access", clientScope}, + ), + }, + }, + }, + ) +} + +func TestAccKeycloakRealmOptionalClientScopes_empty(t *testing.T) { + t.Parallel() + realmName := acctest.RandomWithPrefix("tf-acc") + + resource.Test( + t, + resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testKeycloakRealmOptionalScopes_empty(realmName), + Check: testAccCheckKeycloakRealmHasOptionalScopes( + "keycloak_realm_optional_client_scopes.optional_scopes", + []string{}, + ), + }, + }, + }, + ) +} + +func getRealmOptionalClientScopesFromState(resourceName string, s *terraform.State) ([]*keycloak.OpenidClientScope, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return nil, fmt.Errorf("resource not found: %s", resourceName) + } + + realm := rs.Primary.Attributes["realm_id"] + + keycloakDefaultClientScopes, err := keycloakClient.GetRealmOptionalClientScopes(testCtx, realm) + if err != nil { + return nil, err + } + + return keycloakDefaultClientScopes, nil +} + +func testAccCheckKeycloakRealmHasOptionalScopes(resourceName string, expectedClientScopeNames []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + keycloakOptionalClientScopes, err := getRealmOptionalClientScopesFromState(resourceName, s) + if err != nil { + return err + } + + var assignedClientScopeNames []string + for _, keycloakDefaultScope := range keycloakOptionalClientScopes { + assignedClientScopeNames = append(assignedClientScopeNames, keycloakDefaultScope.Name) + } + + sort.Strings(expectedClientScopeNames) + sort.Strings(assignedClientScopeNames) + + if !slices.Equal(assignedClientScopeNames, expectedClientScopeNames) { + return fmt.Errorf( + "assigned and expected realm optional client scopes do not match %v != %v", + assignedClientScopeNames, + expectedClientScopeNames, + ) + } + + return nil + } +} + +func testKeycloakRealmOptionalScopes_basic(realmName, clientScope string) string { + return fmt.Sprintf(` +resource "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client_scope" "client_scope" { + name = "%s" + realm_id = keycloak_realm.realm.id + + description = "test description" +} + +resource "keycloak_realm_optional_client_scopes" "optional_scopes" { + realm_id = keycloak_realm.realm.id + optional_scopes = [ + "address", + "phone", + "offline_access", + keycloak_openid_client_scope.client_scope.name + ] +} + `, realmName, clientScope) +} + +func testKeycloakRealmOptionalScopes_empty(realmName string) string { + return fmt.Sprintf(` +resource "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_realm_optional_client_scopes" "optional_scopes" { + realm_id = keycloak_realm.realm.id + optional_scopes = [] +} + `, realmName) +}