Skip to content

SCIM 2.0 Gateway

SCIM, System for Cross-domain Identity Management, is a popular RESTful API for integrating applications into identity management systems such as SailPoint. Not all applications provide SCIM endpoints or require more complex integrations then a simple gateway can provide. For instance, it's not unusual for applications to provide a simple CRUD (Create, Read, Update, Delete) API that would require custom integration into your identity system. OpenUnison's SCIM gateway makes it easier to provide integration for these use cases by creating a simpler implementation. OpenUnison handles the work of processing SCIM calls for you, while a custom provisioning target does the work of interacting with your application's APIs. Since these components can be implemented on their own and tested on their own, it makes for quicker development and faster integrations.

Creating a Custom Target

The first step is to create a custom provisioning target to interact with your application. For the rest of this tutorial, we will work with the example created in our documentation. Once your target is created, make sure to deploy it to your OpenUnison.

Authenticating to the Gateway

Your identity management system will need to authenticate to your gateway. You can use any of OpenUnison's Authentication Mechanisms as an authentication chain. Since most OpenUnisons are deployed on Kubernetes, we're going to use a ServiceAccount token that's not bound to a Pod.

NOTE This is not a good production use of Kubernetes ServiceAccount tokens. We're using it as a simple example. In production, it would be better to have an identity provider issue a token based on a credentials grant, certificate authentication, or some other form of strong API authentication.

Once you have OpenUnison deployed:

kubectl create serviceaccount scim-gateway -n openunison
kubectl create token scim-gateway -n openunison
Keep this token in a safe place, we'll use it when configuring our downstream system. Next, create an authentication chain using the Kubernetes JWT mechanism:

If you haven't deployed the orchestra-login-portal chart

---
apiVersion: openunison.tremolo.io/v1
kind: AuthenticationMechanism
metadata:
  name: oauth2k8s
  namespace: openunison
spec:
  className: com.tremolosecurity.proxy.auth.OAuth2K8sServiceAccount
  uri: "/auth/oauth2k8s"
  init: {}
  secretParams: []

Then create our chain:

---
apiVersion: openunison.tremolo.io/v1
kind: AuthenticationChain
metadata:
  name: scim-gateway
  namespace: openunison
spec:
  authMechs:
  - name: oauth2k8s
    required: required
    params:
      # The name of the Kubernetes target
      k8sTarget: "k8s"
      # If true, the user will be looked up in the virtual directory
      linkToDirectory: "false"
      # The OU in the DN to use if the user isn't found in the virtual directory
      noMatchOU: "oauth2"
      # If not linked to a user, the claim to use as the user identifier
      uidAttr: "sub"
      # LDAP filter to lookup the user, can include attributes from the JWT
      lookupFilter: "(uid=${sub})"
      # The objectClass to use if the user can't be found
      defaultObjectClass: "inetOrgPerson"
      # HTTP Realm
      realm: "kubernetes"
      # HTTP Scope
      scope: "auth"
    secretParams: []
  level: 20
  root: o=Tremolo

Once deployed, we can create our SCIM gateway.

Deploying Our Gateway

So far, we've setup OpenUnison and configured it to authenticate a ServiceAccount token. Next, let's deploy an Applicaiton with our gateway configured. Here's an example with incline comments for documentation:

---
apiVersion: openunison.tremolo.io/v2
kind: Application
metadata:
  labels:
    app.kubernetes.io/component: openunison-applications
    app.kubernetes.io/instance: openunison-orchestra-login-portal
    app.kubernetes.io/name: openunison
    app.kubernetes.io/part-of: openunison
  name: scim-example
  namespace: openunison
spec:
  azTimeoutMillis: 3000
  cookieConfig:
    cookiesEnabled: false
    domain: '#[OU_HOST]'
    httpOnly: true
    keyAlias: session-unison
    logoutURI: /logout
    scope: -1
    secure: true
    sessionCookieName: tremolosession
    timeout: 900
  isApp: true
  urls:
  - azRules:
    # limit access to our ServiceAccount
    - constraint: "username=system:serviceaccount:openunison:scim-gateway,ou=oauth2,o=Tremolo"
      scope: dn
    filterChain:
    - className: com.tremolosecurity.proxy.filters.scim.Scim
      params:
        # determine if the lookups are done via the internal LDAP virtual directory, usually false
        lookupFromLDAP: "false"

        # LDAP search base if lookFromLDAP is true, otherwise leave empty
        searchBase: ""

        # the name of the target to lookup users from
        lookupTarget: "custom-target"

        # the user's login id attribute
        uidAttributeName: userName

        # if there is a separate id attribute
        idAttributeName: userName

        # attribute to lookup users by
        lookupAttributeName: userName

        # workflow for synchronizing a user
        workflowName: sync-user-to-target

        # workflow for deleting a user
        deleteUserWorkflowName: delete-from-target

        # workflow for adding a user to, or removing from, a group
        groupWorkflow: target-group-membership

        # workflow for deleting groups (if supported)
        groupDeleteWorkflow: delete-group-from-target

        # attribute to look groups up by
        groupLookupAttributeName: name

        # attribute that stores a group's unique id, if supported
        groupIdName: cn

        # group display name attribute
        groupDisplayName: displayName

        # group's membership attribute
        groupMemberAttributeName: uniqueMember

        # list each attribute for your target that will be mapped to by scim attributes
        scim2tremolo:
          - username
          - mail
          - first
          - last

        # for each attribute, create a mapping from the SCIM attributes
        # user will come from the userName SCIM attribute
        scim2tremolo.username.source: userName
        scim2tremolo.username.sourceType: user

        # mail will come from emails, which is a complex type.  the JsonPathMapping
        # uses JSON Path syntax to extract the value
        scim2tremolo.mail.source: com.tremolosecurity.mappers.JsonPathMapping|emails,$[*].value
        scim2tremolo.mail.sourceType: custom

        # first will come from name, which is a complex type.  the JsonPathMapping
        # uses JSON Path syntax to extract the value
        scim2tremolo.first.source: com.tremolosecurity.mappers.JsonPathMapping|name,$.givenName
        scim2tremolo.first.sourceType: custom

        # last will come from name, which is a complex type.  the JsonPathMapping
        # uses JSON Path syntax to extract the value
        scim2tremolo.last.source: com.tremolosecurity.mappers.JsonPathMapping|name,$.familyName
        scim2tremolo.last.sourceType: custom

        # list each SCIM attribute your target will support
        tremolo2scim:
          - userName
          - emails
          - name
          - groups

        # for each SCIM attribute, configure how OpenUnison will map to them
        # userName will come from the user
        tremolo2scim.userName.source: username
        tremolo2scim.userName.sourceType: user

        # since emails is a json complex type (array of json objects), a special custom mapping will help.  The base 64 encoded parameter
        # is [{"type":"work","primary":true,"value":"${email}"}].  It's base64 encoded because custom parameters parameters to custom mappings
        # are comma separated
        tremolo2scim.emails.source: com.tremolosecurity.mappers.CompositeNoReturn|W3sidHlwZSI6IndvcmsiLCJwcmltYXJ5Ijp0cnVlLCJ2YWx1ZSI6IiR7ZW1haWx9In1d
        tremolo2scim.emails.sourceType: custom

        # since name doesn't require an array we can use generic custom mappings
        tremolo2scim.name.source: "{\"givenName\": \"${first}\", \"familyName\" : \"${last}\"}"
        tremolo2scim.name.sourceType: composite

        # groups is a read only attribute built from the user's group memberships.  The GenerateGroups mapper's first parameter should match 
        # lookupFromLDAP above, then include the URL prefix for your scim gateway, finally the name of the target you're loading
        # users from
        tremolo2scim.groups.source: com.tremolosecurity.mappers.GenerateGroups|false,https://#[OU_HOST]/api/scim-gateway,custom-target
        tremolo2scim.groups.sourceType: custom


        # mapping from a SCIM attribute to an LDAP filter attribute for users, comma separated
        userFilterScim2Ldap: emails.value=mail

        # mapping from a SCIM attribute to an LDAP filter attribute for groups, comma separated
        groupFilterScim2Ldap: description=cn,id=cn


    hosts:
    - '#[OU_HOST]'
    # the name of the authentication chain we created to authenticate from Kubernetes
    # service account tokens
    authChain: scim-gateway
    results: {}
    uri: /api/scim-gateway

With your Application and AuthenticationChain deployed, we can verify that we can load a user:

curl -H "Authorization: Bearer $(kubectl create token scim-gateway  -n openunison)" https://ouscim.domain.com/api/scim-gateway/Users/mmosley

will result in the JSON:

{
  "id": "mmosley",
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "emails": [
    {
      "type": "work",
      "primary": true,
      "value": "mmosley@nodomain.io"
    }
  ],
  "name": {
    "givenName": "Matt",
    "familyName": "Mosley"
  },
  "groups": [
    {
      "value": "group1",
      "$ref": "https://ouscim.domain.com/api/scim-gateway/Groups/group1"
    },
    {
      "value": "group2",
      "$ref": "https://ouscim.domain.com/api/scim-gateway/Groups/group2"
    },
    {
      "value": "group3",
      "$ref": "https://ouscim.domain.com/api/scim-gateway/Groups/group3"
    }
  ],
  "userName": "mmosley",
  "meta": {
    "resourceType": "User",
    "location": "https://ouscim.domain.com/api/scim-gateway/Users/mmosley/Users/mmosley"
  }
}

We have a working read-only gateway that can be called with curl or even integrated into a remote identity system. If your remote identity system is schema aware, then it can poll the gateway to get just the attributes that you have configured in your gateway, making integration easier. With search access working, next we'll create the workflows needed for creating, updating, and deleting users.

User Management Workflows

So far, we have configured a gateway that allows us to search for users and groups. The next step is to create Workflow objects that will do the work of updating users. Workflow objects in OpenUnison are built around creating a user identity as it should exist, with the Target being responsible for making that a reality. SCIM, on the other hand, is a CRUD (Create, Read, Update, Delete) API which means that it expects to perform individual operations on each object. The gateway does the work of making these two different concepts come together. There are three workflows we configured in our gateway that we now need to implement:

Configuration Name Value Description
workflowName sync-user-to-target Synchronizes the user's attributes or creates the user. This workflow is NOT responsible for group memberships
groupWorkflow target-group-membership Responsible for adding users to groups, not attributes
deleteUserWorkflowName delete-from-target Responsible for deleting a user

OpenUnison's workflow system has several built in tasks and can be extended with custom tasks or you can implement your own custom task in javascript to provide more flexibility in mapping. For our gateway, the workflows are pretty simple.

First, let's create the workflow for syncing our user:

---
apiVersion: openunison.tremolo.io/v1
kind: Workflow
metadata:
  name: sync-user-to-target
  namespace: openunison
spec:
  description: update user
  inList: false
  label: update user
  orgId: internal-does-not-exist
  tasks: |-
    # useful for debugging
    - taskType: customTask
      className: com.tremolosecurity.provisioning.customTasks.PrintUserInfo
      params:
        message: enter workflow

    # provision to our target.  OpenUnison does all the work to build your input
    - taskType: provision
      sync: true
      target: "custom-target"
      attributes:
      - username
      - first
      - last
      - email

Once created, we can test adding a new user using curl:

curl -X POST \
  --insecure \
  -H "Content-Type: application/scim+json" \
  -H "Accept: application/scim+json" \
  -H "Authorization: Bearer $(kubectl create token scim-gateway  -n openunison)" \
  https://ouscim.domain.com/api/scim-gateway/Users \
  -d '{
    "schemas": [
      "urn:ietf:params:scim:schemas:core:2.0:User"
    ],
    "userName": "jjackson",
    "name": {
      "givenName": "Jennifer",
      "familyName": "Jackson"
    },
    "emails": [
      {
        "value": "jjackson@nodomain.io",
        "type": "work",
        "primary": true
      }
    ],
    "active": true
  }'

In the OpenUnison logs, you'll see:

[[2026-01-19 15:45:18,852][XNIO-1 task-2] INFO  PrintUserInfo - enter workflow - jjackson - {mail=mail : 'jjackson@nodomain.io' , last=last : 'Jackson' , first=first : 'Jennifer' , username=username : 'jjackson' } / []
[2026-01-19 15:45:18,939][XNIO-1 task-2] INFO  custom-target - In find user
[2026-01-19 15:45:18,990][XNIO-1 task-2] INFO  custom-target - user: undefined
[2026-01-19 15:45:18,991][XNIO-1 task-2] INFO  custom-target - No user found
[2026-01-19 15:45:19,074][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=true Add user=jjackson workflow=sync-user-to-target approval=0 username='jjackson'
[2026-01-19 15:45:19,076][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=false Add user=jjackson workflow=sync-user-to-target approval=0 last='Jackson'
[2026-01-19 15:45:19,077][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=false Add user=jjackson workflow=sync-user-to-target approval=0 first='Jennifer'
[2026-01-19 15:45:19,078][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=false Add user=jjackson workflow=sync-user-to-target approval=0 username='jjackson'

The first log line from PrintUserInfo is from the PrintUserInfo custom task and is useful for debugging your workflows. Next we see the jjackson object being created. Finally, we see all the attributes being set.

At this point with can use HTTP PATCH SCIM api calls to change the user's attributes, but not their memberships. In order to add our new user to a group, we need to create a group membership workflow:

---
apiVersion: openunison.tremolo.io/v1
kind: Workflow
metadata:
  name: target-group-membership
  namespace: openunison
spec:
  description: group members
  inList: false
  label: group members
  orgId: internal-does-not-exist
  tasks: |-
    # print out the current user object
    - taskType: customTask
      className: com.tremolosecurity.provisioning.customTasks.PrintUserInfo
      params:
        message: in group member

    # load the existing groups from our target to make updates
    - taskType: customTask
      className: com.tremolosecurity.provisioning.customTasks.LoadGroupsFromTarget
      params:
        nameAttr: username
        inverse: "false"
        target: "custom-target"

    # add or remove the group, depending on what the gateway determines needs to happen
    # the $groupname$ is the group being manipulated, while $removegroup$ will be true if
    # the group is being removed from the user, and false if the group is being added.
    # these are entries in the request Map object and are generated by the gateway
    - taskType: addGroup
      name: "$groupname$"
      remove: "$removegroup$"

    # the new user once groups are manipulated
    - taskType: customTask
      className: com.tremolosecurity.provisioning.customTasks.PrintUserInfo
      params:
        message: added group

    # synchronize our user.  Only act on groups and the username attribute
    # to make sure none of our other attributes are changed
    - taskType: provision
      sync: true
      target: "custom-target"
      attributes:
      - username

With our workflow deployed, we can now test adding our user to a group:

  curl -X PATCH \
  --insecure \
  -H "Content-Type: application/scim+json" \
  -H "Accept: application/scim+json" \
  -H "Authorization: Bearer $(kubectl create token scim-gateway  -n openunison)" \
  https://ouscim.domain.com/api/scim-gateway/Groups/group2 \
  -d '{
    "schemas": [
      "urn:ietf:params:scim:api:messages:2.0:PatchOp"
    ],
    "Operations": [
      {
        "op": "add",
        "path": "members",
        "value": [
          {
            "value": "jjackson",
            "type": "User"
          }
        ]
      }
    ]
  }'

In our logs, we'll see:

[2026-01-19 16:04:56,761][XNIO-1 task-2] INFO  PrintUserInfo - in group member - jjackson - {userName=userName : 'jjackson' , username=username : 'jjackson' } / []
[2026-01-19 16:04:56,791][XNIO-1 task-2] INFO  custom-target - In find user
[2026-01-19 16:04:56,794][XNIO-1 task-2] INFO  custom-target - user: {"username":"jjackson","last":"Jackson","first":"Jennifer","groups":[]}
[2026-01-19 16:04:56,795][XNIO-1 task-2] INFO  custom-target - Returning user with attributes [last, first, email, username]
[2026-01-19 16:04:56,860][XNIO-1 task-2] INFO  PrintUserInfo - added group - jjackson - {userName=userName : 'jjackson' , username=username : 'jjackson' } / [group2]
[2026-01-19 16:04:56,891][XNIO-1 task-2] INFO  custom-target - In find user
[2026-01-19 16:04:56,894][XNIO-1 task-2] INFO  custom-target - user: {"username":"jjackson","last":"Jackson","first":"Jennifer","groups":[]}
[2026-01-19 16:04:56,895][XNIO-1 task-2] INFO  custom-target - Returning user with attributes [username]
[2026-01-19 16:04:56,945][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=false Add user=jjackson workflow=target-group-membership approval=0 group='group2'

We can see that in our workflow first the user jjackson had no groups, then had group2, and then group2 was added to the user. Finally, we'll want to delete our user:

---
apiVersion: openunison.tremolo.io/v1
kind: Workflow
metadata:
  name: delete-from-target
  namespace: openunison
spec:
  description: delete user
  inList: false
  label: delete user
  orgId: internal-does-not-exist
  tasks: |-
    # debug to print who the user is that's getting deleted
    - taskType: customTask
      className: com.tremolosecurity.provisioning.customTasks.PrintUserInfo
      params:
        message: in delete workflow

    # delete the user
    - taskType: delete
      target: custom-target

Now let's test deleting our jjackson user:

curl -X DELETE \
  --insecure \
  -H "Accept: application/scim+json" \
  -H "Authorization: Bearer $(kubectl create token scim-gateway  -n openunison)" \
  https://ouscim.tremolo.dev/api/scim-gateway/Users/jjackson

and in our logs:

[2026-01-19 16:10:46,586][XNIO-1 task-2] INFO  PrintUserInfo - in delete workflow - jjackson - {last=last : 'Jackson' , userName=userName : 'jjackson' , first=first : 'Jennifer' , username=username : 'jjackson' } / []
[2026-01-19 16:10:46,637][XNIO-1 task-2] INFO  ProvisioningEngineImpl - target=custom-target entry=true Delete user=jjackson workflow=delete-from-target approval=0 username='jjackson'

You now have a functioning gateway that can be plugged into your remote identity management system!

Running Multiple Gateways

Now that you've deployed your first gateway, you don't need to redeploy OpenUnison for each additional gateway! Create and deploy an appropriate Target, create and deploy the correct Workflow objects, then deploy a new gateway Application with a new name and