diff --git a/docs/resources/ldap_custom_mapper.md b/docs/resources/ldap_custom_mapper.md new file mode 100644 index 000000000..8efb06e27 --- /dev/null +++ b/docs/resources/ldap_custom_mapper.md @@ -0,0 +1,66 @@ +--- +page_title: "keycloak_ldap_custom_mapper Resource" +--- + +# keycloak\_ldap\_custom\_mapper Resource + +Allows for creating and managing custom attribute mappers for Keycloak users federated via LDAP. + +The LDAP custom mapper is implemented and deployed into Keycloak as a custom provider. This resource allows to +specify the custom id and custom implementation class of the self-implemented attribute mapper. +The custom mapper should already be deployed into keycloak in order to be correctly configured. + +## Example Usage + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_ldap_user_federation" "ldap_user_federation" { + name = "openldap" + realm_id = keycloak_realm.realm.id + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_custom_mapper" "custom_mapper" { + name = "custom-mapper" + realm_id = keycloak_ldap_user_federation.openldap.realm_id + ldap_user_federation_id = keycloak_ldap_user_federation.openldap.id + + provider_id = "custom-provider-registered-in-keycloak" + provider_type = "com.example.custom.ldap.mappers.CustomMapper" +} +``` + +## Argument Reference + +- `realm_id` - (Required) The realm that this LDAP mapper will exist in. +- `ldap_user_federation_id` - (Required) The ID of the LDAP user federation provider to attach this mapper to. +- `name` - (Required) Display name of this mapper when displayed in the console. +- `provider_id` - (Required) The id of the LDAP mapper implemented in MapperFactory. +- `provider_type` - (Required) The fully-qualified Java class name of the custom LDAP mapper. + +## Import + +LDAP mappers can be imported using the format `{{realm_id}}/{{ldap_user_federation_id}}/{{ldap_mapper_id}}`. +The ID of the LDAP user federation provider and the mapper can be found within the Keycloak GUI, and they are typically GUIDs. + +Example: + +```bash +$ terraform import keycloak_ldap_custom_mapper.custom_mapper my-realm/af2a6ca3-e4d7-49c3-b08b-1b3c70b4b860/3d923ece-1a91-4bf7-adaf-3b82f2a12b67 +``` diff --git a/example/main.tf b/example/main.tf index 78cc274a0..d113b4b96 100644 --- a/example/main.tf +++ b/example/main.tf @@ -426,6 +426,15 @@ resource "keycloak_ldap_full_name_mapper" "full_name_mapper" { read_only = true } +resource "keycloak_ldap_custom_mapper" "custom_mapper" { + name = "custom-mapper" + realm_id = keycloak_ldap_user_federation.openldap.realm_id + ldap_user_federation_id = keycloak_ldap_user_federation.openldap.id + + provider_id = "msad-user-account-control-mapper" + provider_type = "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" +} + resource "keycloak_custom_user_federation" "custom" { name = "custom1" realm_id = "master" diff --git a/keycloak/ldap_custom_mapper.go b/keycloak/ldap_custom_mapper.go new file mode 100644 index 000000000..0150bf401 --- /dev/null +++ b/keycloak/ldap_custom_mapper.go @@ -0,0 +1,67 @@ +package keycloak + +import ( + "context" + "fmt" +) + +type LdapCustomMapper struct { + Id string + Name string + RealmId string + LdapUserFederationId string + ProviderId string + ProviderType string +} + +func convertFromLdapCustomMapperToComponent(ldapCustomMapper *LdapCustomMapper) *component { + return &component{ + Id: ldapCustomMapper.Id, + Name: ldapCustomMapper.Name, + ProviderId: ldapCustomMapper.ProviderId, + ProviderType: ldapCustomMapper.ProviderType, + ParentId: ldapCustomMapper.LdapUserFederationId, + Config: map[string][]string{}, + } +} + +func convertFromComponentToLdapCustomMapper(component *component, realmId string) (*LdapCustomMapper, error) { + return &LdapCustomMapper{ + Id: component.Id, + Name: component.Name, + RealmId: realmId, + LdapUserFederationId: component.ParentId, + ProviderId: component.ProviderId, + ProviderType: component.ProviderType, + }, nil +} + +func (keycloakClient *KeycloakClient) NewLdapCustomMapper(ctx context.Context, ldapCustomMapper *LdapCustomMapper) error { + _, location, err := keycloakClient.post(ctx, fmt.Sprintf("/realms/%s/components", ldapCustomMapper.RealmId), convertFromLdapCustomMapperToComponent(ldapCustomMapper)) + if err != nil { + return err + } + + ldapCustomMapper.Id = getIdFromLocationHeader(location) + + return nil +} + +func (keycloakClient *KeycloakClient) GetLdapCustomMapper(ctx context.Context, realmId, id string) (*LdapCustomMapper, error) { + var component *component + + err := keycloakClient.get(ctx, fmt.Sprintf("/realms/%s/components/%s", realmId, id), &component, nil) + if err != nil { + return nil, err + } + + return convertFromComponentToLdapCustomMapper(component, realmId) +} + +func (keycloakClient *KeycloakClient) UpdateLdapCustomMapper(ctx context.Context, ldapCustomMapper *LdapCustomMapper) error { + return keycloakClient.put(ctx, fmt.Sprintf("/realms/%s/components/%s", ldapCustomMapper.RealmId, ldapCustomMapper.Id), convertFromLdapCustomMapperToComponent(ldapCustomMapper)) +} + +func (keycloakClient *KeycloakClient) DeleteLdapCustomMapper(ctx context.Context, realmId, id string) error { + return keycloakClient.DeleteComponent(ctx, realmId, id) +} diff --git a/provider/provider.go b/provider/provider.go index 941be2bdb..00e08e889 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -59,6 +59,7 @@ func KeycloakProvider(client *keycloak.KeycloakClient) *schema.Provider { "keycloak_ldap_msad_user_account_control_mapper": resourceKeycloakLdapMsadUserAccountControlMapper(), "keycloak_ldap_msad_lds_user_account_control_mapper": resourceKeycloakLdapMsadLdsUserAccountControlMapper(), "keycloak_ldap_full_name_mapper": resourceKeycloakLdapFullNameMapper(), + "keycloak_ldap_custom_mapper": resourceKeycloakLdapCustomMapper(), "keycloak_custom_user_federation": resourceKeycloakCustomUserFederation(), "keycloak_openid_user_attribute_protocol_mapper": resourceKeycloakOpenIdUserAttributeProtocolMapper(), "keycloak_openid_user_property_protocol_mapper": resourceKeycloakOpenIdUserPropertyProtocolMapper(), diff --git a/provider/resource_keycloak_ldap_custom_mapper.go b/provider/resource_keycloak_ldap_custom_mapper.go new file mode 100644 index 000000000..7d065f5d4 --- /dev/null +++ b/provider/resource_keycloak_ldap_custom_mapper.go @@ -0,0 +1,130 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/mrparkers/terraform-provider-keycloak/keycloak" +) + +func resourceKeycloakLdapCustomMapper() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeycloakLdapCustomMapperCreate, + ReadContext: resourceKeycloakLdapCustomMapperRead, + UpdateContext: resourceKeycloakLdapCustomMapperUpdate, + DeleteContext: resourceKeycloakLdapCustomMapperDelete, + // This resource can be imported using {{realm}}/{{provider_id}}/{{mapper_id}}. The Provider and Mapper IDs are displayed in the GUI + Importer: &schema.ResourceImporter{ + StateContext: resourceKeycloakLdapGenericMapperImport, + }, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Display name of the mapper when displayed in the console.", + }, + "realm_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The realm in which the ldap user federation provider exists.", + }, + "ldap_user_federation_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ldap user federation provider to attach this mapper to.", + }, + "provider_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "ID of the custom LDAP mapper.", + }, + "provider_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Fully-qualified name of the Java class implementing the custom LDAP mapper.", + }, + }, + } +} + +func getLdapCustomMapperFromData(data *schema.ResourceData) *keycloak.LdapCustomMapper { + return &keycloak.LdapCustomMapper{ + Id: data.Id(), + Name: data.Get("name").(string), + RealmId: data.Get("realm_id").(string), + LdapUserFederationId: data.Get("ldap_user_federation_id").(string), + ProviderId: data.Get("provider_id").(string), + ProviderType: data.Get("provider_type").(string), + } +} + +func setLdapCustomMapperData(data *schema.ResourceData, ldapCustomMapper *keycloak.LdapCustomMapper) { + data.SetId(ldapCustomMapper.Id) + + data.Set("name", ldapCustomMapper.Name) + data.Set("realm_id", ldapCustomMapper.RealmId) + data.Set("ldap_user_federation_id", ldapCustomMapper.LdapUserFederationId) + + data.Set("provider_id", ldapCustomMapper.ProviderId) + data.Set("provider_type", ldapCustomMapper.ProviderType) +} + +func resourceKeycloakLdapCustomMapperCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + ldapCustomMapper := getLdapCustomMapperFromData(data) + + err := keycloakClient.NewLdapCustomMapper(ctx, ldapCustomMapper) + if err != nil { + return diag.FromErr(err) + } + + setLdapCustomMapperData(data, ldapCustomMapper) + + return resourceKeycloakLdapCustomMapperRead(ctx, data, meta) +} + +func resourceKeycloakLdapCustomMapperRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + id := data.Id() + + ldapCustomMapper, err := keycloakClient.GetLdapCustomMapper(ctx, realmId, id) + if err != nil { + return handleNotFoundError(ctx, err, data) + } + + setLdapCustomMapperData(data, ldapCustomMapper) + + return nil +} + +func resourceKeycloakLdapCustomMapperUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + ldapCustomMapper := getLdapCustomMapperFromData(data) + + err := keycloakClient.UpdateLdapCustomMapper(ctx, ldapCustomMapper) + if err != nil { + return diag.FromErr(err) + } + + setLdapCustomMapperData(data, ldapCustomMapper) + + return nil +} + +func resourceKeycloakLdapCustomMapperDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + id := data.Id() + + return diag.FromErr(keycloakClient.DeleteLdapCustomMapper(ctx, realmId, id)) +} diff --git a/provider/resource_keycloak_ldap_custom_mapper_test.go b/provider/resource_keycloak_ldap_custom_mapper_test.go new file mode 100644 index 000000000..9d42fc98c --- /dev/null +++ b/provider/resource_keycloak_ldap_custom_mapper_test.go @@ -0,0 +1,371 @@ +package provider + +import ( + "fmt" + "testing" + + "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/mrparkers/terraform-provider-keycloak/keycloak" +) + +func TestAccKeycloakLdapCustomMapper_basic(t *testing.T) { + t.Parallel() + + customMapperName := acctest.RandomWithPrefix("tf-acc") + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakLdapCustomMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakLdapCustomMapper_basic(customMapperName), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + { + ResourceName: "keycloak_ldap_custom_mapper.sample_mapper", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getLdapGenericMapperImportId("keycloak_ldap_custom_mapper.sample_mapper"), + }, + }, + }) +} + +func TestAccKeycloakLdapCustomMapper_createAfterManualDestroy(t *testing.T) { + t.Parallel() + + var mapper = &keycloak.LdapCustomMapper{} + + customMapperName := acctest.RandomWithPrefix("tf-acc") + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakLdapCustomMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakLdapCustomMapper_basic(customMapperName), + Check: testAccCheckKeycloakLdapCustomMapperFetch("keycloak_ldap_custom_mapper.sample_mapper", mapper), + }, + { + PreConfig: func() { + err := keycloakClient.DeleteLdapCustomMapper(testCtx, mapper.RealmId, mapper.Id) + if err != nil { + t.Fatal(err) + } + }, + Config: testKeycloakLdapCustomMapper_basic(customMapperName), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + }, + }) +} + +func TestAccKeycloakLdapCustomMapper_updateLdapUserFederation(t *testing.T) { + t.Parallel() + + customMapperName := acctest.RandomWithPrefix("tf-acc") + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakLdapCustomMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakLdapCustomMapper_updateLdapUserFederationBefore(customMapperName), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + { + Config: testKeycloakLdapCustomMapper_updateLdapUserFederationAfter(customMapperName), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + }, + }) +} + +func TestAccKeycloakLdapCustomMapper_updateInPlace(t *testing.T) { + t.Parallel() + + customMapperBefore := &keycloak.LdapCustomMapper{ + Name: acctest.RandString(10), + ProviderId: "msad-user-account-control-mapper", + ProviderType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", + } + customMapperAfter := &keycloak.LdapCustomMapper{ + Name: acctest.RandString(10), + ProviderId: "msad-user-account-control-mapper", + ProviderType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckKeycloakLdapCustomMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakLdapCustomMapper_basicFromInterface(customMapperBefore), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + { + Config: testKeycloakLdapCustomMapper_basicFromInterface(customMapperAfter), + Check: testAccCheckKeycloakLdapCustomMapperExists("keycloak_ldap_custom_mapper.sample_mapper"), + }, + }, + }) +} + +func testAccCheckKeycloakLdapCustomMapperExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + _, err := getLdapCustomMapperFromState(s, resourceName) + if err != nil { + return err + } + + return nil + } +} + +func testAccCheckKeycloakLdapCustomMapperFetch(resourceName string, mapper *keycloak.LdapCustomMapper) resource.TestCheckFunc { + return func(s *terraform.State) error { + fetchedMapper, err := getLdapCustomMapperFromState(s, resourceName) + if err != nil { + return err + } + + mapper.Id = fetchedMapper.Id + mapper.RealmId = fetchedMapper.RealmId + + return nil + } +} + +func testAccCheckKeycloakLdapCustomMapperDestroy() resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "keycloak_ldap_custom_mapper" { + continue + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + + ldapCustomMapper, _ := keycloakClient.GetLdapCustomMapper(testCtx, realm, id) + if ldapCustomMapper != nil { + return fmt.Errorf("ldap user attribute mapper with id %s still exists", id) + } + } + + return nil + } +} + +func getLdapCustomMapperFromState(s *terraform.State, resourceName string) (*keycloak.LdapCustomMapper, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return nil, fmt.Errorf("resource not found: %s", resourceName) + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + + ldapCustomMapper, err := keycloakClient.GetLdapCustomMapper(testCtx, realm, id) + if err != nil { + return nil, fmt.Errorf("error getting ldap user attribute mapper with id %s: %s", id, err) + } + + return ldapCustomMapper, nil +} + +func testKeycloakLdapCustomMapper_basic(customMapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_ldap_user_federation" "openldap" { + name = "openldap" + realm_id = data.keycloak_realm.realm.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_custom_mapper" "sample_mapper" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + ldap_user_federation_id = "${keycloak_ldap_user_federation.openldap.id}" + + provider_id = "msad-user-account-control-mapper" + provider_type = "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" +} + `, testAccRealmUserFederation.Realm, customMapperName) +} + +func testKeycloakLdapCustomMapper_basicFromInterface(mapper *keycloak.LdapCustomMapper) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_ldap_user_federation" "openldap" { + name = "openldap" + realm_id = data.keycloak_realm.realm.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_custom_mapper" "sample_mapper" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + ldap_user_federation_id = "${keycloak_ldap_user_federation.openldap.id}" + + provider_id = "%s" + provider_type = "%s" + +} + `, testAccRealmUserFederation.Realm, mapper.Name, mapper.ProviderId, mapper.ProviderType) +} + +func testKeycloakLdapCustomMapper_updateLdapUserFederationBefore(customMapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm_one" { + realm = "%s" +} + +data "keycloak_realm" "realm_two" { + realm = "%s" +} + +resource "keycloak_ldap_user_federation" "openldap_one" { + name = "openldap" + realm_id = data.keycloak_realm.realm_one.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_user_federation" "openldap_two" { + name = "openldap" + realm_id = data.keycloak_realm.realm_two.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_custom_mapper" "sample_mapper" { + name = "%s" + realm_id = data.keycloak_realm.realm_one.id + ldap_user_federation_id = "${keycloak_ldap_user_federation.openldap_one.id}" + + provider_id = "msad-user-account-control-mapper" + provider_type = "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" +} + `, testAccRealmUserFederation.Realm, testAccRealmTwo.Realm, customMapperName) +} + +func testKeycloakLdapCustomMapper_updateLdapUserFederationAfter(customMapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm_one" { + realm = "%s" +} + +data "keycloak_realm" "realm_two" { + realm = "%s" +} + +resource "keycloak_ldap_user_federation" "openldap_one" { + name = "openldap" + realm_id = data.keycloak_realm.realm_one.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_user_federation" "openldap_two" { + name = "openldap" + realm_id = data.keycloak_realm.realm_two.id + + enabled = true + + username_ldap_attribute = "cn" + rdn_ldap_attribute = "cn" + uuid_ldap_attribute = "entryDN" + user_object_classes = [ + "simpleSecurityObject", + "organizationalRole" + ] + connection_url = "ldap://openldap" + users_dn = "dc=example,dc=org" + bind_dn = "cn=admin,dc=example,dc=org" + bind_credential = "admin" +} + +resource "keycloak_ldap_custom_mapper" "sample_mapper" { + name = "%s" + realm_id = data.keycloak_realm.realm_two.id + ldap_user_federation_id = "${keycloak_ldap_user_federation.openldap_two.id}" + + provider_id = "msad-user-account-control-mapper" + provider_type = "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" +} + `, testAccRealmUserFederation.Realm, testAccRealmTwo.Realm, customMapperName) +}