diff --git a/.github/workflows/build-test-image.yml b/.github/workflows/build-test-image.yml index a7593b33f..15c3459ca 100644 --- a/.github/workflows/build-test-image.yml +++ b/.github/workflows/build-test-image.yml @@ -13,6 +13,7 @@ jobs: strategy: matrix: keycloak-version: + - '21.7.1' - '21.0.1' - '20.0.5' - '19.0.2' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..9ced90e61 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "configurations": [ + { + "name": "Debug Terraform Provider", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceRoot}", + "env": { + }, + "args": [ + "-plugin-dir", + "${workspaceRoot}" + ], + "cwd": "${workspaceRoot}" + }, + { + "name": "Debug Terraform Provider Active", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceRoot}", + "env": {}, + "args": [ + "-debug", + ] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index f511e2352..de1cbe091 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,7 @@ This provider will officially support the latest three major versions of Keycloa The following versions are used when running acceptance tests in CI: -- 21.0.1 (latest) -- 20.0.5 -- 19.0.2 +- 21.7.1 ## Releases @@ -62,7 +60,7 @@ build you can use the `linux_amd64` build as long as `libc6-compat` is installed ## Development -This project requires Go 1.19 and Terraform 1.4.1. +This project requires Go 1.22.5 and Terraform 1.4.1. This project uses [Go Modules](https://github.com/golang/go/wiki/Modules) for dependency management, which allows this project to exist outside of an existing GOPATH. After cloning the repository, you can build the project by running `make build`. diff --git a/docker-compose.yml b/docker-compose.yml index d05dfe5c2..97e5211d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: ports: - 8389:389 keycloak: - image: quay.io/keycloak/keycloak:21.0.1 + image: quay.io/keycloak/keycloak:21.7.1 command: start-dev --features=preview depends_on: - postgres diff --git a/docs/resources/keycloak_openid_propertymapper_claim_protocol_mapper.md b/docs/resources/keycloak_openid_propertymapper_claim_protocol_mapper.md new file mode 100644 index 000000000..4b853ae4d --- /dev/null +++ b/docs/resources/keycloak_openid_propertymapper_claim_protocol_mapper.md @@ -0,0 +1,183 @@ +--- +page_title: "keycloak_openid_propertymapper_claim_protocol_mapper Resource" +--- + +# keycloak\_openid\_propertymapper\_claim\_protocol\_mapper Resource + +Allows for creating and managing claim protocol mappers within Keycloak. + +The property claim mappers allow you to define a claim with based on dynamic values to support latest keycloak apis. + +Protocol mappers can be defined for a single client, or they can be defined for a client scope which can be shared between multiple different clients. + +## Example Usage (Client) + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_openid_client" "openid_client" { + realm_id = keycloak_realm.realm.id + client_id = "client" + + name = "client" + enabled = true + + access_type = "CONFIDENTIAL" + valid_redirect_uris = [ + "http://localhost:8080/openid-callback" + ] +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "userattribute_id_claim_mapper" { + realm_id = keycloak_realm.realm.id + client_id = keycloak_openid_client.openid_client.id + name = "property-mapper" + + claim_name = "property" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "clientrole_claim_mapper" { + realm_id = keycloak_realm.realm.id + client_id = keycloak_openid_client.openid_client.id + name = "client-role-mapper" + + claim_name = "clientrole" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-client-role-mapper" + + add_to_introspection_token = true + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true + add_to_lightweight_claim = true + + set { + name = "multivalued" + value = "false" + } + + set { + name = "usermodel.clientRoleMapping.clientId" + value = "admin-cli" + } + + set { + name = "usermodel.clientRoleMapping.rolePrefix" + value = "prefix" + } +} +``` + +## Example Usage (Client Scope) + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_openid_client_scope" "client_scope" { + realm_id = keycloak_realm.realm.id + name = "client-scope" +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "userattribute_id_claim_mapper" { + realm_id = keycloak_realm.realm.id + client_scope_id = keycloak_openid_client_scope.client_scope.id + name = "property-mapper" + + claim_name = "property" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "clientrole_claim_mapper" { + realm_id = keycloak_realm.realm.id + client_scope_id = keycloak_openid_client_scope.client_scope.id + name = "client-role-mapper" + + claim_name = "clientrole" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-client-role-mapper" + + add_to_introspection_token = true + add_to_id_token = true + add_to_access_token = true + add_to_userinfo = true + add_to_lightweight_claim = true + + set { + name = "multivalued" + value = "false" + } + + set { + name = "usermodel.clientRoleMapping.clientId" + value = "admin-cli" + } + + set { + name = "usermodel.clientRoleMapping.rolePrefix" + value = "prefix" + } +} +``` + +## Argument Reference + +- `realm_id` - (Required) The realm this protocol mapper exists within. +- `name` - (Required) The display name of this protocol mapper in the GUI. +- `claim_name` - (Required) The name of the claim to insert into a token. +- `claim_value` - (Required) The hardcoded value of the claim. +- `client_id` - (Optional) The client this protocol mapper should be attached to. Conflicts with `client_scope_id`. One of `client_id` or `client_scope_id` must be specified. +- `client_scope_id` - (Optional) The client scope this protocol mapper should be attached to. Conflicts with `client_id`. One of `client_id` or `client_scope_id` must be specified. +- `claim_value_type` - (Optional) The claim type used when serializing JSON tokens. Can be one of `String`, `JSON`, `long`, `int`, or `boolean`. Defaults to `String`. +- `add_to_id_token` - (Optional) Indicates if the property should be added as a claim to the id token. Defaults to `true`. +- `add_to_access_token` - (Optional) Indicates if the property should be added as a claim to the access token. Defaults to `true`. +- `add_to_userinfo` - (Optional) Indicates if the property should be added as a claim to the UserInfo response body. Defaults to `true`. +- `add_to_introspection_token` - (Optional) Indicates if the property should be added as a claim to the introspection token. Defaults to `true`. +- `add_to_lightweight_claim` - (Optional) Indicates if the property should be added as a lightweight claim. Defaults to `false`. +- `set` - (Block Set) Custom values to be merged with the values. (see below for nested schema) + +### Nested Schema for `set` + +Required: + +- `name` (String) +- `value` (String) + +## Import + +Protocol mappers can be imported using one of the following formats: +- Client: `{{realm_id}}/client/{{client_keycloak_id}}/{{protocol_mapper_id}}` +- Client Scope: `{{realm_id}}/client-scope/{{client_scope_keycloak_id}}/{{protocol_mapper_id}}` + +Example: + +```bash +$ terraform import keycloak_openid_propertymapper_claim_protocol_mapper.claim_mapper my-realm/client/a7202154-8793-4656-b655-1dd18c181e14/71602afa-f7d1-4788-8c49-ef8fd00af0f4 +$ terraform import keycloak_openid_propertymapper_claim_protocol_mapper.claim_mapper my-realm/client-scope/b799ea7e-73ee-4a73-990a-1eafebe8e20a/71602afa-f7d1-4788-8c49-ef8fd00af0f4 +``` diff --git a/go.mod b/go.mod index aaf5fae2e..8fc371ca1 100644 --- a/go.mod +++ b/go.mod @@ -54,4 +54,4 @@ require ( google.golang.org/protobuf v1.30.0 // indirect ) -go 1.19 +go 1.22.5 diff --git a/keycloak/keycloak_client.go b/keycloak/keycloak_client.go index 23e896128..010221460 100644 --- a/keycloak/keycloak_client.go +++ b/keycloak/keycloak_client.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/json" "fmt" - "github.com/hashicorp/terraform-plugin-log/tflog" "io/ioutil" "net/http" "net/http/cookiejar" @@ -17,6 +16,8 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/go-version" "golang.org/x/net/publicsuffix" diff --git a/keycloak/openid_propertymapper_claim_protocol_mapper.go b/keycloak/openid_propertymapper_claim_protocol_mapper.go new file mode 100644 index 000000000..5b3d8c4a7 --- /dev/null +++ b/keycloak/openid_propertymapper_claim_protocol_mapper.go @@ -0,0 +1,159 @@ +package keycloak + +import ( + "context" + "fmt" + "maps" + "slices" + "strconv" +) + +type OpenIdPropertyMapperClaimProtocolMapper struct { + Id string + Name string + Protocol string + ProtocolMapper string + RealmId string + ClientId string + ClientScopeId string + + AddToIdToken bool + AddToAccessToken bool + AddToUserInfo bool + AddToIntrospectionToken bool + AddToLightweightClaim bool + + ClaimName string + JsonType string + + AdditionalConfig map[string]string +} + +func (mapper *OpenIdPropertyMapperClaimProtocolMapper) convertToGenericProtocolMapper() *protocolMapper { + + config := map[string]string{ + addToIdTokenField: strconv.FormatBool(mapper.AddToIdToken), + addToAccessTokenField: strconv.FormatBool(mapper.AddToAccessToken), + addToUserInfoField: strconv.FormatBool(mapper.AddToUserInfo), + addToIntrospectionTokenField: strconv.FormatBool(mapper.AddToIntrospectionToken), + addToLightweightClaimField: strconv.FormatBool(mapper.AddToLightweightClaim), + jsonTypeField: mapper.JsonType, + claimNameField: mapper.ClaimName, + } + + maps.Copy(config, mapper.AdditionalConfig) + return &protocolMapper{ + Id: mapper.Id, + Name: mapper.Name, + Protocol: mapper.Protocol, + ProtocolMapper: mapper.ProtocolMapper, + Config: config, + } +} + +func (protocolMapper *protocolMapper) convertToOpenIdPropertyMapperClaimProtocolMapper(realmId, clientId, clientScopeId string) (*OpenIdPropertyMapperClaimProtocolMapper, error) { + addToIdToken, err := parseBoolAndTreatEmptyStringAsFalse(protocolMapper.Config[addToIdTokenField]) + if err != nil { + return nil, err + } + + addToAccessToken, err := parseBoolAndTreatEmptyStringAsFalse(protocolMapper.Config[addToAccessTokenField]) + if err != nil { + return nil, err + } + + addToUserInfo, err := parseBoolAndTreatEmptyStringAsFalse(protocolMapper.Config[addToUserInfoField]) + if err != nil { + return nil, err + } + + addToIntrospectionTokenField, err := parseBoolAndTreatEmptyStringAsFalse(protocolMapper.Config[addToIntrospectionTokenField]) + if err != nil { + return nil, err + } + + addToLightweightClaimField, err := parseBoolAndTreatEmptyStringAsFalse(protocolMapper.Config[addToLightweightClaimField]) + if err != nil { + return nil, err + } + + additionalConfig := map[string]string{} + for k, v := range protocolMapper.Config { + if !slices.Contains(protocolMapperIgnore, k) { + additionalConfig[k] = v + } + } + + return &OpenIdPropertyMapperClaimProtocolMapper{ + Id: protocolMapper.Id, + Name: protocolMapper.Name, + RealmId: realmId, + ClientId: clientId, + ClientScopeId: clientScopeId, + + AddToIdToken: addToIdToken, + AddToAccessToken: addToAccessToken, + AddToUserInfo: addToUserInfo, + AddToIntrospectionToken: addToIntrospectionTokenField, + AddToLightweightClaim: addToLightweightClaimField, + + Protocol: protocolMapper.Protocol, + ProtocolMapper: protocolMapper.ProtocolMapper, + ClaimName: protocolMapper.Config[claimNameField], + JsonType: protocolMapper.Config[jsonTypeField], + AdditionalConfig: additionalConfig, + }, nil +} + +func (keycloakClient *KeycloakClient) GetOpenIdPropertyMapperClaimProtocolMapper(ctx context.Context, realmId, clientId, clientScopeId, mapperId string) (*OpenIdPropertyMapperClaimProtocolMapper, error) { + var protocolMapper *protocolMapper + + err := keycloakClient.get(ctx, individualProtocolMapperPath(realmId, clientId, clientScopeId, mapperId), &protocolMapper, nil) + if err != nil { + return nil, err + } + + return protocolMapper.convertToOpenIdPropertyMapperClaimProtocolMapper(realmId, clientId, clientScopeId) +} + +func (keycloakClient *KeycloakClient) DeleteOpenIdPropertyMapperClaimProtocolMapper(ctx context.Context, realmId, clientId, clientScopeId, mapperId string) error { + return keycloakClient.delete(ctx, individualProtocolMapperPath(realmId, clientId, clientScopeId, mapperId), nil) +} + +func (keycloakClient *KeycloakClient) NewOpenIdPropertyMapperClaimProtocolMapper(ctx context.Context, mapper *OpenIdPropertyMapperClaimProtocolMapper) error { + path := protocolMapperPath(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId) + + _, location, err := keycloakClient.post(ctx, path, mapper.convertToGenericProtocolMapper()) + if err != nil { + return err + } + + mapper.Id = getIdFromLocationHeader(location) + + return nil +} + +func (keycloakClient *KeycloakClient) UpdateOpenIdPropertyMapperClaimProtocolMapper(ctx context.Context, mapper *OpenIdPropertyMapperClaimProtocolMapper) error { + path := individualProtocolMapperPath(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId, mapper.Id) + + return keycloakClient.put(ctx, path, mapper.convertToGenericProtocolMapper()) +} + +func (keycloakClient *KeycloakClient) ValidateOpenIdPropertyMapperClaimProtocolMapper(ctx context.Context, mapper *OpenIdPropertyMapperClaimProtocolMapper) error { + if mapper.ClientId == "" && mapper.ClientScopeId == "" { + return fmt.Errorf("validation error: one of ClientId or ClientScopeId must be set") + } + + protocolMappers, err := keycloakClient.listGenericProtocolMappers(ctx, mapper.RealmId, mapper.ClientId, mapper.ClientScopeId) + if err != nil { + return err + } + + for _, protocolMapper := range protocolMappers { + if protocolMapper.Name == mapper.Name && protocolMapper.Id != mapper.Id { + return fmt.Errorf("validation error: a protocol mapper with name %s already exists for this client", mapper.Name) + } + } + + return nil +} diff --git a/keycloak/protocol_mapper.go b/keycloak/protocol_mapper.go index 05ea467ff..22957085a 100644 --- a/keycloak/protocol_mapper.go +++ b/keycloak/protocol_mapper.go @@ -18,11 +18,14 @@ var ( addToAccessTokenField = "access.token.claim" addToIdTokenField = "id.token.claim" addToUserInfoField = "userinfo.token.claim" + addToIntrospectionTokenField = "introspection.token.claim" + addToLightweightClaimField = "lightweight.claim" attributeNameField = "attribute.name" attributeNameFormatField = "attribute.nameformat" claimNameField = "claim.name" claimValueField = "claim.value" claimValueTypeField = "jsonType.label" + jsonTypeField = "jsonType.label" friendlyNameField = "friendly.name" fullPathField = "full.path" includedClientAudienceField = "included.client.audience" @@ -38,6 +41,8 @@ var ( userClientRoleMappingRolePrefixField = "usermodel.clientRoleMapping.rolePrefix" userSessionNoteField = "user.session.note" aggregateAttributeValuesField = "aggregate.attrs" + + protocolMapperIgnore = []string{addToIntrospectionTokenField, addToUserInfoField, addToIdTokenField, addToAccessTokenField, addToLightweightClaimField, claimNameField, jsonTypeField} ) func protocolMapperPath(realmId, clientId, clientScopeId string) string { diff --git a/main.go b/main.go index e8544827e..93b47a558 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,26 @@ package main import ( + "flag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" "github.com/mrparkers/terraform-provider-keycloak/provider" ) func main() { - plugin.Serve(&plugin.ServeOpts{ + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := &plugin.ServeOpts{ + Debug: debug, + ProviderAddr: "registry.terraform.io/mrparkers/keycloak", ProviderFunc: func() *schema.Provider { return provider.KeycloakProvider(nil) }, - }) + } + + plugin.Serve(opts) } diff --git a/provider/provider.go b/provider/provider.go index 00e08e889..2ff7237b1 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -66,6 +66,7 @@ func KeycloakProvider(client *keycloak.KeycloakClient) *schema.Provider { "keycloak_openid_group_membership_protocol_mapper": resourceKeycloakOpenIdGroupMembershipProtocolMapper(), "keycloak_openid_full_name_protocol_mapper": resourceKeycloakOpenIdFullNameProtocolMapper(), "keycloak_openid_hardcoded_claim_protocol_mapper": resourceKeycloakOpenIdHardcodedClaimProtocolMapper(), + "keycloak_openid_propertymapper_claim_protocol_mapper": resourceKeycloakOpenIdPropertyMapperClaimProtocolMapper(), "keycloak_openid_audience_protocol_mapper": resourceKeycloakOpenIdAudienceProtocolMapper(), "keycloak_openid_audience_resolve_protocol_mapper": resourceKeycloakOpenIdAudienceResolveProtocolMapper(), "keycloak_openid_hardcoded_role_protocol_mapper": resourceKeycloakOpenIdHardcodedRoleProtocolMapper(), diff --git a/provider/provider_test.go b/provider/provider_test.go index 04c45eb4c..f6f8e113a 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -3,12 +3,13 @@ package provider import ( "context" "fmt" + "os" + "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/meta" "github.com/mrparkers/terraform-provider-keycloak/keycloak" - "os" - "testing" ) var testAccProviderFactories map[string]func() (*schema.Provider, error) @@ -29,7 +30,7 @@ var requiredEnvironmentVariables = []string{ func init() { testCtx = context.Background() userAgent := fmt.Sprintf("HashiCorp Terraform/%s (+https://www.terraform.io) Terraform Plugin SDK/%s", schema.Provider{}.TerraformVersion, meta.SDKVersionString()) - keycloakClient, _ = keycloak.NewKeycloakClient(testCtx, os.Getenv("KEYCLOAK_URL"), "", os.Getenv("KEYCLOAK_CLIENT_ID"), os.Getenv("KEYCLOAK_CLIENT_SECRET"), os.Getenv("KEYCLOAK_REALM"), "", "", true, 5, "", false, userAgent, false, map[string]string{ + keycloakClient, _ = keycloak.NewKeycloakClient(testCtx, os.Getenv("KEYCLOAK_URL"), "", os.Getenv("KEYCLOAK_CLIENT_ID"), os.Getenv("KEYCLOAK_CLIENT_SECRET"), os.Getenv("KEYCLOAK_REALM"), os.Getenv("KEYCLOAK_USER"), os.Getenv("KEYCLOAK_PASSWORD"), true, 5, "", false, userAgent, false, map[string]string{ "foo": "bar", }) testAccProvider = KeycloakProvider(keycloakClient) diff --git a/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper.go b/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper.go new file mode 100644 index 000000000..ca360c700 --- /dev/null +++ b/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper.go @@ -0,0 +1,251 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "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/mrparkers/terraform-provider-keycloak/keycloak" +) + +func resourceKeycloakOpenIdPropertyMapperClaimProtocolMapper() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperCreate, + ReadContext: resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperRead, + UpdateContext: resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperUpdate, + DeleteContext: resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperDelete, + Importer: &schema.ResourceImporter{ + // import a mapper tied to a client: + // {{realmId}}/client/{{clientId}}/{{protocolMapperId}} + // or a client scope: + // {{realmId}}/client-scope/{{clientScopeId}}/{{protocolMapperId}} + StateContext: genericProtocolMapperImport, + }, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "A human-friendly name that will appear in the Keycloak console.", + }, + "realm_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The realm id where the associated client or client scope exists.", + }, + "client_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The mapper's associated client. Cannot be used at the same time as client_scope_id.", + ConflictsWith: []string{"client_scope_id"}, + }, + "client_scope_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The mapper's associated client scope. Cannot be used at the same time as client_id.", + ConflictsWith: []string{"client_id"}, + }, + "protocol": { + Type: schema.TypeString, + Required: true, + Description: "The protocol type for the expected extra parameters.", + }, + "protocol_mapper": { + Type: schema.TypeString, + Required: true, + Description: "The protocol property mapper type.", + }, + "add_to_id_token": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Indicates if the attribute should be a claim in the id token.", + }, + "add_to_access_token": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Indicates if the attribute should be a claim in the access token.", + }, + "add_to_userinfo": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Indicates if the attribute should appear in the userinfo response body.", + }, + "add_to_introspection_token": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Indicates if the attribute should be a claim in the introspect token.", + }, + "add_to_lightweight_claim": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Indicates if the attribute should appear in the lightweight claim.", + }, + "claim_name": { + Type: schema.TypeString, + Required: true, + Description: "The claim name to display in the token.", + }, + "json_type": { + Type: schema.TypeString, + Optional: true, + Description: "Claim type used when serializing tokens.", + Default: "String", + ValidateFunc: validation.StringInSlice([]string{"JSON", "String", "long", "int", "boolean"}, true), + }, + "set": { + Type: schema.TypeSet, + Optional: true, + Description: "Mapper values to be merged with the other attributes.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "value": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + }, + } +} + +func mapFromDataToOpenIdPropertyMapperClaimProtocolMapper(data *schema.ResourceData) *keycloak.OpenIdPropertyMapperClaimProtocolMapper { + + additionalConfig := map[string]string{} + setlist := data.Get("set").(*schema.Set).List() + + for _, raw := range setlist { + set := raw.(map[string]interface{}) + additionalConfig[set["name"].(string)] = set["value"].(string) + } + + return &keycloak.OpenIdPropertyMapperClaimProtocolMapper{ + Id: data.Id(), + Name: data.Get("name").(string), + RealmId: data.Get("realm_id").(string), + ClientId: data.Get("client_id").(string), + ClientScopeId: data.Get("client_scope_id").(string), + AddToIdToken: data.Get("add_to_id_token").(bool), + AddToAccessToken: data.Get("add_to_access_token").(bool), + AddToUserInfo: data.Get("add_to_userinfo").(bool), + AddToIntrospectionToken: data.Get("add_to_introspection_token").(bool), + AddToLightweightClaim: data.Get("add_to_lightweight_claim").(bool), + + Protocol: data.Get("protocol").(string), + ProtocolMapper: data.Get("protocol_mapper").(string), + ClaimName: data.Get("claim_name").(string), + JsonType: data.Get("json_type").(string), + + AdditionalConfig: additionalConfig, + } +} + +func mapFromOpenIdPropertyMapperClaimMapperToData(mapper *keycloak.OpenIdPropertyMapperClaimProtocolMapper, data *schema.ResourceData) { + data.SetId(mapper.Id) + data.Set("name", mapper.Name) + data.Set("realm_id", mapper.RealmId) + + if mapper.ClientId != "" { + data.Set("client_id", mapper.ClientId) + } else { + data.Set("client_scope_id", mapper.ClientScopeId) + } + + data.Set("add_to_id_token", mapper.AddToIdToken) + data.Set("add_to_access_token", mapper.AddToAccessToken) + data.Set("add_to_userinfo", mapper.AddToUserInfo) + data.Set("add_to_introspection_token", mapper.AddToIntrospectionToken) + data.Set("add_to_lightweight_claim", mapper.AddToLightweightClaim) + + data.Set("protocol", mapper.Protocol) + data.Set("protocol_mapper", mapper.ProtocolMapper) + data.Set("claim_name", mapper.ClaimName) + data.Set("json_type", mapper.JsonType) + + additionalConfig := make([]interface{}, 0, len(mapper.AdditionalConfig)) + for k, v := range mapper.AdditionalConfig { + item := map[string]interface{}{ + "name": k, + "value": v, + } + additionalConfig = append(additionalConfig, item) + } + data.Set("set", additionalConfig) +} + +func resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + openIdPropertyMapperClaimMapper := mapFromDataToOpenIdPropertyMapperClaimProtocolMapper(data) + + err := keycloakClient.ValidateOpenIdPropertyMapperClaimProtocolMapper(ctx, openIdPropertyMapperClaimMapper) + if err != nil { + return diag.FromErr(err) + } + + err = keycloakClient.NewOpenIdPropertyMapperClaimProtocolMapper(ctx, openIdPropertyMapperClaimMapper) + if err != nil { + return diag.FromErr(err) + } + + tflog.Info(ctx, "resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperCreate") + + mapFromOpenIdPropertyMapperClaimMapperToData(openIdPropertyMapperClaimMapper, data) + + return resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperRead(ctx, data, meta) +} + +func resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + realmId := data.Get("realm_id").(string) + clientId := data.Get("client_id").(string) + clientScopeId := data.Get("client_scope_id").(string) + + openIdPropertyMapperClaimMapper, err := keycloakClient.GetOpenIdPropertyMapperClaimProtocolMapper(ctx, realmId, clientId, clientScopeId, data.Id()) + if err != nil { + return handleNotFoundError(ctx, err, data) + } + mapFromOpenIdPropertyMapperClaimMapperToData(openIdPropertyMapperClaimMapper, data) + + return nil +} + +func resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + openIdPropertyMapperClaimMapper := mapFromDataToOpenIdPropertyMapperClaimProtocolMapper(data) + + err := keycloakClient.ValidateOpenIdPropertyMapperClaimProtocolMapper(ctx, openIdPropertyMapperClaimMapper) + if err != nil { + return diag.FromErr(err) + } + + err = keycloakClient.UpdateOpenIdPropertyMapperClaimProtocolMapper(ctx, openIdPropertyMapperClaimMapper) + if err != nil { + return diag.FromErr(err) + } + return resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperRead(ctx, data, meta) +} + +func resourceKeycloakOpenIdPropertyMapperClaimProtocolMapperDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + keycloakClient := meta.(*keycloak.KeycloakClient) + + realmId := data.Get("realm_id").(string) + clientId := data.Get("client_id").(string) + clientScopeId := data.Get("client_scope_id").(string) + + return diag.FromErr(keycloakClient.DeleteOpenIdPropertyMapperClaimProtocolMapper(ctx, realmId, clientId, clientScopeId, data.Id())) +} diff --git a/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper_test.go b/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper_test.go new file mode 100644 index 000000000..cda4361f4 --- /dev/null +++ b/provider/resource_keycloak_openid_propertymapper_claim_protocol_mapper_test.go @@ -0,0 +1,429 @@ +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 TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_basicClient(t *testing.T) { + t.Parallel() + clientId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_client(clientId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_basicClientScope(t *testing.T) { + t.Parallel() + clientScopeId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client_scope" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_clientScope(clientScopeId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_import(t *testing.T) { + t.Parallel() + clientId := acctest.RandomWithPrefix("tf-acc") + clientScopeId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + clientResourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client" + clientScopeResourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client_scope" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdFullNameProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_import(clientId, clientScopeId, mapperName), + Check: resource.ComposeTestCheckFunc( + testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(clientResourceName), + testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(clientScopeResourceName), + ), + }, + { + ResourceName: clientResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getGenericProtocolMapperIdForClient(clientResourceName), + }, + { + ResourceName: clientScopeResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: getGenericProtocolMapperIdForClientScope(clientScopeResourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_update(t *testing.T) { + t.Parallel() + clientId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + claimName := acctest.RandomWithPrefix("tf-acc") + updatedClaimName := acctest.RandomWithPrefix("tf-acc") + claimValue := acctest.RandomWithPrefix("tf-acc") + updatedClaimValue := acctest.RandomWithPrefix("tf-acc") + + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, claimName, claimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, updatedClaimName, updatedClaimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_createAfterManualDestroy(t *testing.T) { + t.Parallel() + var mapper = &keycloak.OpenIdPropertyMapperClaimProtocolMapper{} + + clientId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_client(clientId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperFetch(resourceName, mapper), + }, + { + PreConfig: func() { + err := keycloakClient.DeleteOpenIdPropertyMapperClaimProtocolMapper(testCtx, mapper.RealmId, mapper.ClientId, mapper.ClientScopeId, mapper.Id) + if err != nil { + t.Error(err) + } + }, + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_client(clientId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_updateClientIdForceNew(t *testing.T) { + t.Parallel() + clientId := acctest.RandomWithPrefix("tf-acc") + updatedClientId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + claimName := acctest.RandomWithPrefix("tf-acc") + claimValue := acctest.RandomWithPrefix("tf-acc") + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, claimName, claimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(updatedClientId, mapperName, claimName, claimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_updateClientScopeForceNew(t *testing.T) { + t.Parallel() + mapperName := acctest.RandomWithPrefix("tf-acc") + clientScopeId := acctest.RandomWithPrefix("tf-acc") + newClientScopeId := acctest.RandomWithPrefix("tf-acc") + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper_client_scope" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_clientScope(clientScopeId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_clientScope(newClientScopeId, mapperName), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func TestAccKeycloakOpenIdPropertyMapperClaimProtocolMapper_updateRealmIdForceNew(t *testing.T) { + t.Parallel() + clientId := acctest.RandomWithPrefix("tf-acc") + mapperName := acctest.RandomWithPrefix("tf-acc") + + claimName := acctest.RandomWithPrefix("tf-acc") + claimValue := acctest.RandomWithPrefix("tf-acc") + resourceName := "keycloak_openid_propertymapper_claim_protocol_mapper.propertymapper_claim_mapper" + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy(), + Steps: []resource.TestStep{ + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, claimName, claimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + { + Config: testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, claimName, claimValue), + Check: testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName), + }, + }, + }) +} + +func testAccKeycloakOpenIdPropertyMapperClaimProtocolMapperDestroy() resource.TestCheckFunc { + return func(state *terraform.State) error { + for resourceName, rs := range state.RootModule().Resources { + if rs.Type != "keycloak_openid_propertymapper_claim_protocol_mapper" { + continue + } + + mapper, _ := getPropertyMapperClaimMapperUsingState(state, resourceName) + + if mapper != nil { + return fmt.Errorf("openid user attribute protocol mapper with id %s still exists", rs.Primary.ID) + } + } + + return nil + } +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapperExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + _, err := getPropertyMapperClaimMapperUsingState(state, resourceName) + if err != nil { + return err + } + + return nil + } +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapperFetch(resourceName string, mapper *keycloak.OpenIdPropertyMapperClaimProtocolMapper) resource.TestCheckFunc { + return func(state *terraform.State) error { + fetchedMapper, err := getPropertyMapperClaimMapperUsingState(state, resourceName) + if err != nil { + return err + } + + mapper.Id = fetchedMapper.Id + mapper.ClientId = fetchedMapper.ClientId + mapper.ClientScopeId = fetchedMapper.ClientScopeId + mapper.RealmId = fetchedMapper.RealmId + + return nil + } +} + +func getPropertyMapperClaimMapperUsingState(state *terraform.State, resourceName string) (*keycloak.OpenIdPropertyMapperClaimProtocolMapper, error) { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return nil, fmt.Errorf("resource not found in TF state: %s ", resourceName) + } + + id := rs.Primary.ID + realm := rs.Primary.Attributes["realm_id"] + clientId := rs.Primary.Attributes["client_id"] + clientScopeId := rs.Primary.Attributes["client_scope_id"] + + return keycloakClient.GetOpenIdPropertyMapperClaimProtocolMapper(testCtx, realm, clientId, clientScopeId, id) +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_client(clientId, mapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "openid_client" { + realm_id = data.keycloak_realm.realm.id + client_id = "%s" + + access_type = "BEARER-ONLY" +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "propertymapper_claim_mapper_client" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = "${keycloak_openid_client.openid_client.id}" + + claim_name = "foo" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +}`, testAccRealm.Realm, clientId, mapperName) +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapper_basic_clientScope(clientScopeId, mapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client_scope" "client_scope" { + name = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "propertymapper_claim_mapper_client_scope" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_scope_id = "${keycloak_openid_client_scope.client_scope.id}" + + claim_name = "foo" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +}`, testAccRealm.Realm, clientScopeId, mapperName) +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapper_import(clientId, clientScopeId, mapperName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "openid_client" { + realm_id = data.keycloak_realm.realm.id + client_id = "%s" + + access_type = "BEARER-ONLY" +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "propertymapper_claim_mapper_client" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = "${keycloak_openid_client.openid_client.id}" + + claim_name = "foo" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +} + +resource "keycloak_openid_client_scope" "client_scope" { + name = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "propertymapper_claim_mapper_client_scope" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_scope_id = "${keycloak_openid_client_scope.client_scope.id}" + + claim_name = "foo" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "id" + } +}`, testAccRealm.Realm, clientId, mapperName, clientScopeId, mapperName) +} + +func testKeycloakOpenIdPropertyMapperClaimProtocolMapper_userModel(clientId, mapperName, claimName, attribute string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "openid_client" { + realm_id = data.keycloak_realm.realm.id + client_id = "%s" + + access_type = "BEARER-ONLY" +} + +resource "keycloak_openid_propertymapper_claim_protocol_mapper" "propertymapper_claim_mapper" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = "${keycloak_openid_client.openid_client.id}" + + claim_name = "%s" + json_type = "String" + + protocol = "openid-connect" + protocol_mapper = "oidc-usermodel-property-mapper" + + set { + name = "user.attribute" + value = "%s" + } +}`, testAccRealm.Realm, clientId, mapperName, claimName, attribute) +}