Repository: mrparkers/terraform-provider-keycloak
Pull request #274 (2020-04-16 12:28:46)

Added support for setting and updating federated link on a user
Added support for setting and updating federated link on a user.
+ extensive example with federated user and 2 keycloak realms + 2 idp links and clients.


Patch:
diff --git a/example/federated_user_example.tf b/example/federated_user_example.tf
new file mode 100644
index 0000000..d45ee8f
--- /dev/null
+++ b/example/federated_user_example.tf
@@ -0,0 +1,95 @@
+resource "keycloak_realm" "source_realm" {
+  realm   = "source_realm"
+  enabled = true
+}
+
+resource "keycloak_openid_client" "destination_client" {
+  realm_id                 = "${keycloak_realm.source_realm.id}"
+  name                     = "destination_client"
+  client_id                = "destination_client"
+  client_secret            = "secret"
+  description              = "a client used by the destination realm"
+  access_type              = "CONFIDENTIAL"
+  standard_flow_enabled    = true
+  valid_redirect_uris = [
+    "http://localhost:8080/*",
+  ]
+}
+
+//do not get confused this just to have multiple federate idps on the destination realm
+resource "keycloak_openid_client" "destination_double_client" {
+  realm_id                 = "${keycloak_realm.source_realm.id}"
+  name                     = "destination_double_client"
+  client_id                = "destination_double_client"
+  client_secret            = "secret2"
+  description              = "a second client used by the destination realm"
+  access_type              = "CONFIDENTIAL"
+  standard_flow_enabled    = true
+  valid_redirect_uris = [
+    "http://localhost:8080/*",
+  ]
+}
+
+resource "keycloak_user" "source_user" {
+  realm_id   = "${keycloak_realm.source_realm.id}"
+  username   = "source"
+  email      = "source@fakedomain.com"
+  first_name = "source"
+  last_name  = "source"
+  initial_password {
+    value     = "source"
+    temporary = false
+  }
+}
+
+resource "keycloak_realm" "destination_realm" {
+  realm   = "destination_realm"
+  enabled = true
+}
+
+resource keycloak_oidc_identity_provider source_oidc_idp {
+  realm              = "${keycloak_realm.destination_realm.id}"
+  alias              = "source"
+  authorization_url  = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/auth"
+  token_url          = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/token"
+  user_info_url      = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/userinfo"
+  jwks_url           = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/certs"
+  validate_signature = true
+  client_id          = "${keycloak_openid_client.destination_client.client_id}"
+  client_secret      = "${keycloak_openid_client.destination_client.client_secret}"
+  default_scopes     = "openid"
+}
+
+//do not get confused this second idp towards source_realm, this could a completly different idp
+resource keycloak_oidc_identity_provider second_source_oidc_idp {
+  realm              = "${keycloak_realm.destination_realm.id}"
+  alias              = "source2"
+  authorization_url  = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/auth"
+  token_url          = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/token"
+  user_info_url      = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/userinfo"
+  jwks_url           = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/certs"
+  validate_signature = true
+  client_id          = "${keycloak_openid_client.destination_double_client.client_id}"
+  client_secret      = "${keycloak_openid_client.destination_double_client.client_secret}"
+  default_scopes     = "openid"
+}
+
+resource "keycloak_user" "destination_user" {
+  realm_id   = "${keycloak_realm.destination_realm.id}"
+  username   = "my_destination_username"
+  email      = "source@otherdomain.be"
+  first_name = "Destination_source"
+  last_name  = "Destination_source"
+  //federated link through source idp
+  federated_identity {
+    identity_provider = "${keycloak_oidc_identity_provider.source_oidc_idp.alias}"
+    user_id           = "${keycloak_user.source_user.id}"
+    user_name         = "${keycloak_user.source_user.username}"
+  }
+  //federated link through second source idp
+  federated_identity {
+    identity_provider = "${keycloak_oidc_identity_provider.second_source_oidc_idp.alias}"
+    user_id           = "${keycloak_user.source_user.id}"
+    user_name         = "${keycloak_user.source_user.username}"
+  }
+}
diff --git a/keycloak/openid_client.go b/keycloak/openid_client.go
index fd9e922..92b4a4f 100644
--- a/keycloak/openid_client.go
+++ b/keycloak/openid_client.go
@@ -45,6 +45,7 @@ type OpenidClient struct {
 	WebOrigins                         []string                                 `json:"webOrigins"`
 	AdminUrl                           string                                   `json:"adminUrl"`
 	BaseUrl                            string                                   `json:"baseUrl"`
+	RootUrl                            *string                                  `json:"rootUrl,omitempty"`
 	FullScopeAllowed                   bool                                     `json:"fullScopeAllowed"`
 	Attributes                         OpenidClientAttributes                   `json:"attributes"`
 	AuthorizationSettings              *OpenidClientAuthorizationSettings       `json:"authorizationSettings,omitempty"`
diff --git a/keycloak/user.go b/keycloak/user.go
index bd28a7f..48fe90b 100644
--- a/keycloak/user.go
+++ b/keycloak/user.go
@@ -40,6 +40,13 @@ func (keycloakClient *KeycloakClient) NewUser(user *User) error {
 
 	user.Id = getIdFromLocationHeader(location)
 
+	for _, federatedIdentity := range user.FederatedIdentities {
+		_, _, err := keycloakClient.post(fmt.Sprintf("/realms/%s/users/%s/federated-identity/%s", user.RealmId, user.Id, federatedIdentity.IdentityProvider), federatedIdentity)
+		if err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -111,7 +118,29 @@ func (keycloakClient *KeycloakClient) GetUser(realmId, id string) (*User, error)
 }
 
 func (keycloakClient *KeycloakClient) UpdateUser(user *User) error {
-	return keycloakClient.put(fmt.Sprintf("/realms/%s/users/%s", user.RealmId, user.Id), user)
+	err := keycloakClient.put(fmt.Sprintf("/realms/%s/users/%s", user.RealmId, user.Id), user)
+	if err != nil {
+		return err
+	}
+
+	var federatedIdentities []*FederatedIdentity
+	err = keycloakClient.get(fmt.Sprintf("/realms/%s/users/%s/federated-identity", user.RealmId, user.Id), &federatedIdentities, nil)
+	if err != nil {
+		return err
+	}
+
+	for _, federatedIdentity := range federatedIdentities {
+		keycloakClient.delete(fmt.Sprintf("/realms/%s/users/%s/federated-identity/%s", user.RealmId, user.Id, federatedIdentity.IdentityProvider), nil)
+	}
+
+	for _, federatedIdentity := range user.FederatedIdentities {
+		_, _, err := keycloakClient.post(fmt.Sprintf("/realms/%s/users/%s/federated-identity/%s", user.RealmId, user.Id, federatedIdentity.IdentityProvider), federatedIdentity)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
 func (keycloakClient *KeycloakClient) DeleteUser(realmId, id string) error {
diff --git a/provider/data_source_keycloak_openid_client.go b/provider/data_source_keycloak_openid_client.go
index eb5caea..a9e8243 100644
--- a/provider/data_source_keycloak_openid_client.go
+++ b/provider/data_source_keycloak_openid_client.go
@@ -71,6 +71,10 @@ func dataSourceKeycloakOpenidClient() *schema.Resource {
 				Type:     schema.TypeBool,
 				Computed: true,
 			},
+			"root_url": {
+				Type:     schema.TypeString,
+				Computed: true,
+			},
 			"resource_server_id": {
 				Type:     schema.TypeString,
 				Computed: true,
diff --git a/provider/resource_keycloak_openid_client.go b/provider/resource_keycloak_openid_client.go
index ac3b3b3..22aed5a 100644
--- a/provider/resource_keycloak_openid_client.go
+++ b/provider/resource_keycloak_openid_client.go
@@ -94,6 +94,10 @@ func resourceKeycloakOpenidClient() *schema.Resource {
 				Type:     schema.TypeString,
 				Optional: true,
 			},
+			"root_url": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
 			"service_accounts_enabled": {
 				Type:     schema.TypeBool,
 				Optional: true,
@@ -180,18 +184,37 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
 	validRedirectUris := make([]string, 0)
 	webOrigins := make([]string, 0)
 
-	if v, ok := data.GetOk("valid_redirect_uris"); ok {
-		for _, validRedirectUri := range v.(*schema.Set).List() {
+	rootUrlData, rootUrlOk := data.GetOkExists("root_url")
+	validRedirectUrisData, validRedirectUrisOk := data.GetOk("valid_redirect_uris")
+	webOriginsData, webOriginsOk := data.GetOk("web_origins")
+
+	rootUrlString := rootUrlData.(string)
+
+	if validRedirectUrisOk {
+		for _, validRedirectUri := range validRedirectUrisData.(*schema.Set).List() {
 			validRedirectUris = append(validRedirectUris, validRedirectUri.(string))
 		}
 	}
 
-	if v, ok := data.GetOk("web_origins"); ok {
-		for _, webOrigin := range v.(*schema.Set).List() {
+	if webOriginsOk {
+		for _, webOrigin := range webOriginsData.(*schema.Set).List() {
 			webOrigins = append(webOrigins, webOrigin.(string))
 		}
 	}
 
+	// Keycloak uses the root URL for web origins if not specified otherwise
+	if rootUrlOk && rootUrlString != "" {
+		if !validRedirectUrisOk {
+			return nil, errors.New("valid_redirect_uris is required when root_url is given1")
+		}
+		if !webOriginsOk {
+			return nil, errors.New("web_origins is required when root_url is given")
+		}
+		if _, adminOk := data.GetOk("admin_url"); !adminOk {
+			return nil, errors.New("admin_url is required when root_url is given")
+		}
+	}
+
 	openidClient := &keycloak.OpenidClient{
 		Id:                        data.Id(),
 		ClientId:                  data.Get("client_id").(string),
@@ -217,6 +240,10 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
 		ConsentRequired:   data.Get("consent_required").(bool),
 	}
 
+	if rootUrlOk {
+		openidClient.RootUrl = &rootUrlString
+	}
+
 	if !openidClient.ImplicitFlowEnabled && !openidClient.StandardFlowEnabled {
 		if _, ok := data.GetOk("valid_redirect_uris"); ok {
 			return nil, errors.New("valid_redirect_uris cannot be set when standard or implicit flow is not enabled")
@@ -285,6 +312,7 @@ func setOpenidClientData(keycloakClient *keycloak.KeycloakClient, data *schema.R
 	data.Set("web_origins", client.WebOrigins)
 	data.Set("admin_url", client.AdminUrl)
 	data.Set("base_url", client.BaseUrl)
+	data.Set("root_url", &client.RootUrl)
 	data.Set("authorization_services_enabled", client.AuthorizationServicesEnabled)
 	data.Set("full_scope_allowed", client.FullScopeAllowed)
 	data.Set("consent_required", client.ConsentRequired)


Test patch:
diff --git a/provider/resource_keycloak_openid_client_test.go b/provider/resource_keycloak_openid_client_test.go
index f9c3f47..755c343 100644
--- a/provider/resource_keycloak_openid_client_test.go
+++ b/provider/resource_keycloak_openid_client_test.go
@@ -156,6 +156,24 @@ func TestAccKeycloakOpenidClient_baseUrl(t *testing.T) {
 	})
 }
 
+func TestAccKeycloakOpenidClient_rootUrl(t *testing.T) {
+	realmName := "terraform-" + acctest.RandString(10)
+	clientId := "terraform-" + acctest.RandString(10)
+	rootUrl := "https://www.example.com"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccCheckKeycloakOpenidClientDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClient_rootUrl(realmName, clientId, rootUrl),
+				Check:  testAccCheckKeycloakOpenidClientRootUrl("keycloak_openid_client.client", rootUrl),
+			},
+		},
+	})
+}
+
 func TestAccKeycloakOpenidClient_updateInPlace(t *testing.T) {
 	realm := "terraform-" + acctest.RandString(10)
 	clientId := "terraform-" + acctest.RandString(10)
@@ -169,6 +187,7 @@ func TestAccKeycloakOpenidClient_updateInPlace(t *testing.T) {
 		implicitFlowEnabled = !standardFlowEnabled
 	}
 
+	rootUrlBefore := acctest.RandString(20)
 	openidClientBefore := &keycloak.OpenidClient{
 		RealmId:                   realm,
 		ClientId:                  clientId,
@@ -184,10 +203,12 @@ func TestAccKeycloakOpenidClient_updateInPlace(t *testing.T) {
 		WebOrigins:                []string{acctest.RandString(10), acctest.RandString(10), acctest.RandString(10)},
 		AdminUrl:                  acctest.RandString(20),
 		BaseUrl:                   acctest.RandString(20),
+		RootUrl:                   &rootUrlBefore,
 	}
 
 	standardFlowEnabled, implicitFlowEnabled = implicitFlowEnabled, standardFlowEnabled
 
+	rootUrlAfter := acctest.RandString(20)
 	openidClientAfter := &keycloak.OpenidClient{
 		RealmId:                   realm,
 		ClientId:                  clientId,
@@ -203,6 +224,7 @@ func TestAccKeycloakOpenidClient_updateInPlace(t *testing.T) {
 		WebOrigins:                []string{acctest.RandString(10), acctest.RandString(10), acctest.RandString(10), acctest.RandString(10), acctest.RandString(10)},
 		AdminUrl:                  acctest.RandString(20),
 		BaseUrl:                   acctest.RandString(20),
+		RootUrl:                   &rootUrlAfter,
 	}
 
 	resource.Test(t, resource.TestCase{
@@ -539,6 +561,21 @@ func testAccCheckKeycloakOpenidClientBaseUrl(resourceName string, baseUrl string
 	}
 }
 
+func testAccCheckKeycloakOpenidClientRootUrl(resourceName string, rootUrl string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		client, err := getOpenidClientFromState(s, resourceName)
+		if err != nil {
+			return err
+		}
+
+		if *client.RootUrl != rootUrl {
+			return fmt.Errorf("expected openid client to have rootUrl set to %s, but got %s", rootUrl, *client.RootUrl)
+		}
+
+		return nil
+	}
+}
+
 func testAccCheckKeycloakOpenidClientBelongsToRealm(resourceName, realm string) resource.TestCheckFunc {
 	return func(s *terraform.State) error {
 		client, err := getOpenidClientFromState(s, resourceName)
@@ -777,6 +814,25 @@ resource "keycloak_openid_client" "client" {
 	`, realm, clientId, baseUrl)
 }
 
+func testKeycloakOpenidClient_rootUrl(realm, clientId, rootUrl string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id             = "%s"
+	realm_id			  = "${keycloak_realm.realm.id}"
+	root_url			  = "%s"
+	valid_redirect_uris   = ["http://example.com"]
+	web_origins			  = ["http://example.com"]
+	admin_url			  = "http://example.com"
+	access_type           = "CONFIDENTIAL"
+	standard_flow_enabled = true
+}
+	`, realm, clientId, rootUrl)
+}
+
 func testKeycloakOpenidClient_pkceChallengeMethod(realm, clientId, pkceChallengeMethod string) string {
 
 	return fmt.Sprintf(`
@@ -901,8 +957,9 @@ resource "keycloak_openid_client" "client" {
 	web_origins                  = %s
 	admin_url					 = "%s"
 	base_url                     = "%s"
+	root_url                     = "%s"
 }
-	`, openidClient.RealmId, openidClient.ClientId, openidClient.Name, openidClient.Enabled, openidClient.Description, openidClient.ClientSecret, openidClient.StandardFlowEnabled, openidClient.ImplicitFlowEnabled, openidClient.DirectAccessGrantsEnabled, openidClient.ServiceAccountsEnabled, arrayOfStringsForTerraformResource(openidClient.ValidRedirectUris), arrayOfStringsForTerraformResource(openidClient.WebOrigins), openidClient.AdminUrl, openidClient.BaseUrl)
+	`, openidClient.RealmId, openidClient.ClientId, openidClient.Name, openidClient.Enabled, openidClient.Description, openidClient.ClientSecret, openidClient.StandardFlowEnabled, openidClient.ImplicitFlowEnabled, openidClient.DirectAccessGrantsEnabled, openidClient.ServiceAccountsEnabled, arrayOfStringsForTerraformResource(openidClient.ValidRedirectUris), arrayOfStringsForTerraformResource(openidClient.WebOrigins), openidClient.AdminUrl, openidClient.BaseUrl, *openidClient.RootUrl)
 }
 
 func testKeycloakOpenidClient_secret(realm, clientId, clientSecret string) string {
diff --git a/provider/resource_keycloak_user_test.go b/provider/resource_keycloak_user_test.go
index 12137fb..596a4c8 100644
--- a/provider/resource_keycloak_user_test.go
+++ b/provider/resource_keycloak_user_test.go
@@ -293,6 +293,55 @@ func TestAccKeycloakUser_validateLowercaseUsernames(t *testing.T) {
 	})
 }
 
+func TestAccKeycloakUser_federatedLink(t *testing.T) {
+	sourceUserName := "terraform-source-user-" + acctest.RandString(10)
+	sourceUserName2 := "terraform-source-user2-" + acctest.RandString(10)
+	destinationRealmName := "terraform-dest-" + acctest.RandString(10)
+
+	resourceName := "keycloak_user.destination_user"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccCheckKeycloakUserDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakUser_FederationLink(sourceUserName, destinationRealmName),
+				Check:  testAccCheckKeycloakUserHasFederationLinkWithSourceUserName(resourceName, sourceUserName),
+			},
+			{
+				Config: testKeycloakUser_FederationLink(sourceUserName2, destinationRealmName),
+				Check:  testAccCheckKeycloakUserHasFederationLinkWithSourceUserName(resourceName, sourceUserName2),
+			},
+		},
+	})
+}
+
+func testAccCheckKeycloakUserHasFederationLinkWithSourceUserName(resourceName, sourceUserName string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		fetchedUser, err := getUserFromState(s, resourceName)
+		if err != nil {
+			return err
+		}
+
+		var found bool = false
+		for _, federatedIdentity := range fetchedUser.FederatedIdentities {
+			if federatedIdentity.UserName == sourceUserName {
+				found = true
+			}
+			if !found {
+				return fmt.Errorf("user had unexpected federatedLink %s or unexpected username %s", federatedIdentity.IdentityProvider, federatedIdentity.UserName)
+			}
+		}
+
+		if !found {
+			return fmt.Errorf("user had no federatedLink, but one was expected")
+		}
+
+		return nil
+	}
+}
+
 func testAccCheckKeycloakUserExists(resourceName string) resource.TestCheckFunc {
 	return func(s *terraform.State) error {
 		_, err := getUserFromState(s, resourceName)
@@ -488,3 +537,57 @@ resource "keycloak_user" "user" {
 }
 	`, user.RealmId, user.Username, user.Email, user.FirstName, user.LastName, user.Enabled, user.EmailVerified)
 }
+
+func testKeycloakUser_FederationLink(sourceRealmUserName, destinationRealmId string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "source_realm" {
+  realm   = "source_test_realm"
+  enabled = true
+}
+
+resource "keycloak_openid_client" "destination_client" {
+  realm_id                 = "${keycloak_realm.source_realm.id}"
+  client_id                = "destination_client"
+  client_secret            = "secret"
+  access_type              = "CONFIDENTIAL"
+  standard_flow_enabled    = true
+  valid_redirect_uris = [
+    "http://localhost:8080/*",
+  ]
+}
+
+resource "keycloak_user" "source_user" {
+  realm_id   = "${keycloak_realm.source_realm.id}"
+  username   = "%s"
+  initial_password {
+    value     = "source"
+    temporary = false
+  }
+}
+
+resource "keycloak_realm" "destination_realm" {
+  realm   = "%s"
+  enabled = true
+}
+
+resource keycloak_oidc_identity_provider source_oidc_idp {
+  realm              = "${keycloak_realm.destination_realm.id}"
+  alias              = "source"
+  authorization_url  = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/auth"
+  token_url          = "http://localhost:8080/auth/realms/${keycloak_realm.source_realm.id}/protocol/openid-connect/token"
+  client_id          = "${keycloak_openid_client.destination_client.client_id}"
+  client_secret      = "${keycloak_openid_client.destination_client.client_secret}"
+  default_scopes     = "openid"
+}
+
+resource "keycloak_user" "destination_user" {
+  realm_id   = "${keycloak_realm.destination_realm.id}"
+  username   = "my_destination_username"
+  federated_identity {
+    identity_provider = "${keycloak_oidc_identity_provider.source_oidc_idp.alias}"
+    user_id           = "${keycloak_user.source_user.id}"
+    user_name         = "${keycloak_user.source_user.username}"
+  }
+}
+	`, sourceRealmUserName, destinationRealmId)
+}