The Attributes coming from the SAML Identity Provider can be transformed using Groovy-scripts.

The same transformations also work in UserSync-connector. There may be slight differences between SAML-attributes and Connector-attributes that are described below. 

Migration

If you upgraded from SAML SSO version 4.x and already used Groovy-transformation, check to migrate

General Information

General information about Groovy can be found here: https://groovy-lang.org/documentation.html


In the IdP's Attribute Mapping configuration, select Groovy Code as the Source type and you can enter the script code:

h


Logging

There is an SLF4j-logger available as logger:

logger.warn("Hello World!")
CODE

will occur in the logs log this:

2021-05-18 16:00:56,774+0000 ForkJoinPool.commonPool-worker-3 WARN      [d.r.retransform.impl.GroovyAttributeTransformerScript] Hello World!
CODE


StructuredData

The incoming data is represented as StructuredData (see ReTransform APIDocs). 

StructuredData allows representing hierarchical data structures in a simplified way.

A StructuredData-object is either MapStructuredData, ListStructuredData or StringStructuredData

MapStructuredData behaves like a Map with Strings as keys and StructuredData as values (in fact, it implements Map<String,StructuredData>).

{
 "username" : "Gonzo",
 "fullname" : "Gonzo the Great"
}
CODE


ListStructuredData implements List<StructuredData>.

{
 "groups" : [
 "users",
 "administrators"
]
}
CODE


StringStructuredData behaves like a String (at least mostly) and encapsulates a String-value.

{
 "profile" : {
	"phone" : "123456",
	"department" : "Finance",
	"coworkers" : ["Kermit","Piggy"]
  }
}
CODE


When comparing another value to a StringStructuredData, this other value is converted to a String (using Java's String.valueOf()-method) and compared to the encapsulated value.

This has the effect that when comparing it to a numeric value, a lexicographical comparison happens.


When StructuredData is created from JSON using StructuredData.parseJson(jsonString), the jsonString is parsed using Groovy's JsonSluper (see https://docs.groovy-lang.org/latest/html/gapi/groovy/json/JsonSlurper.html) and all values are converted to Strings using String.valueOf()

Comparing StructuredData with Strings

With this data:

{
	"con": { "name" : "Erwin" } 			
}
CODE

the value "Erwin" is available under con.name. But as this Object is if type StringStructuredData and not String, comparing it to a String could produce unexpected results:

name = con.name // Name is of type StructuredData

name.equals("Erwin")            // true  because equals() in StringStructuredData compares the String-value with the other String
"Erwin".equals(name)            // false because equals() in String returns false if the types are different
"Erwin".equals(name.asString()) // true because name is explicitly turned into a String before the comparison, this is the recommended way.

GROOVY

Access attribute values

SAML

The Groovy Script's binding (in a Groovy-script, the Binding is the set of the Script's variables) contains a MapStructuredData-object mapping which is containing the attributes.

When transforming attributes from the SAML Identity Provider in the SAML SingleSignOn-configuration, the attributes are ListStructuredData-Objects (because the SAML-assertions contain a value list for each attribute).

One way to find out what data is contained is to add the logging statement logger.warn(asJson(mapping)) and log in again with SAML. This will write a JSON representation of the data into the log file.

This should be avoided on a production instance as it clutters the log.

Here you can see an example. 

After a successful login, the following info is sent in the SAML response.

<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Destination="https://daniel-jira-sd.lab.resolution.de/plugins/servlet/samlsso" ID="_3de5c781535665584082b183ba1bd179" InResponseTo="RESOLUTION_855253a1-c43b-4029-9573-33987bb26c8c" IssueInstant="2022-03-25T09:50:50.773Z" Version="2.0">
  <saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://testidp.klab.resolution.de/daniel-jira-sd/</saml2:Issuer>
  <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">...</ds:Signature>
  <saml2p:Status>
    <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </saml2p:Status>
  <saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_593d78530a592540fb97ccaed14179bf" IssueInstant="2022-03-25T09:50:50.773Z" Version="2.0">
    <saml2:Issuer>https://testidp.klab.resolution.de/daniel-jira-sd/</saml2:Issuer>
    <saml2:Subject>
      <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">user1@example.com</saml2:NameID>
      <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml2:SubjectConfirmationData InResponseTo="RESOLUTION_855253a1-c43b-4029-9573-33987bb26c8c" NotBefore="2022-03-25T09:48:17.070Z" NotOnOrAfter="2022-03-25T10:48:17.070Z" Recipient="https://daniel-jira-sd.lab.resolution.de/plugins/servlet/samlsso"/>
      </saml2:SubjectConfirmation>
    </saml2:Subject>
    <saml2:Conditions NotBefore="2022-03-25T09:48:17.070Z" NotOnOrAfter="2022-03-25T10:48:17.070Z"/>
    <saml2:AuthnStatement AuthnInstant="2022-03-25T09:50:50.774Z" SessionIndex="_896152c080647c2e259ed06cf7cb4ebf">
      <saml2:AuthnContext>
        <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
      </saml2:AuthnContext>
    </saml2:AuthnStatement>
    <saml2:AttributeStatement>
      <saml2:Attribute Name="att1">
        <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">gonzo</saml2:AttributeValue>
        <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">kermit</saml2:AttributeValue>
        <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">piggy</saml2:AttributeValue>
      </saml2:Attribute>
      <saml2:Attribute Name="att2">
        <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">piggy</saml2:AttributeValue>
      </saml2:Attribute>
      <saml2:Attribute Name="att3">
        <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">kermit</saml2:AttributeValue>
      </saml2:Attribute>
    </saml2:AttributeStatement>
  </saml2:Assertion>
</saml2p:Response>
XML


Above you see the SAML Assertion which contains the attributes att1, att2, and att3 as well as the NameID value. These attributes are transformed into ListStructuredData-Objects as shown below. This is a log excerpt that came from the logger.warn(asJson(mapping)) statement explained earlier.


/plugins/servlet/samlsso [d.r.retransform.impl.GroovyAttributeTransformerScript] {
      "att1" : [ "gonzo", "kermit", "piggy" ],
      "att2" : [ "piggy" ],
      "att3" : [ "kermit" ],
      "ATTR_NAMEID" : "user1@example.com",
      "ATTR_SD_CUSTOMER" : [ "false" ]
    }
CODE


The SAML NameID-value can be accessed under the special attribute ATTR_NAMEID.

This expression returns the SAML NameID:

mapping.ATTR_NAMEID
CODE

In case the attribute contains special characters it needs to be enclosed in '':

mapping.'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
CODE


Returning data

The value returned from the script is used as the value for the mapped attribute. Some conversion automatically happens:

In a Groovy-Script, the last statement's result is returned automatically, so 

return someValue
CODE

can just be written as

someValue
CODE


In our example above we returned the attribute att1 for the Jira user property attribute demo.


There are some special values that can be returned. DROP, EMPTY, and DROP_ALL

Remove the attribute

If the script returns null or the special value EMPTY, the attribute on an existing user is removed.

Taking the example above the IdP now sends the following attributes in the SAML response. 


Att1 is not present anymore but it is still mapped to the Jira attribute Demo.



When the user now logs in via SAML with the attributes att12, att2, and att3 being sent by the IdP an empty list will be returned for the Jira Attribute Demo. If the script returns null, the attribute on an existing user is removed.


You can also return the special value EMPTY which has the same effect.

The attribute is removed from the user's profile page.

Ignore an attribute

If the script returns the special value DROP, the mapped attribute is not used to create or update the user. If an existing user already has this attribute, it is kept as it is.

We are doing the same test as in the previous section. Att1 was there before and updated the user's profile page.


The user logs in again via SAML but this time the attribute att1 is again not present.


When you now check the attribute transformations in the Tracker you see the "drop" is now true for this Jira attribute. 

This means the attribute remains unchanged for the user. 

Filter the user

If a user should be filtered, return the special value DROP_ALL.

UserSync

When transforming attributes in a UserSync-connector, mapping contains three MapStructuredData-Objects:

  • The data delivered from the Connector is available under con. The structure depends on the Connector.
  • If there is an existing user to be updated, this user's attributes are available under existing
  • If the connector is called during a SAML-authentication (because the update-method "update from Usersync-Connector" is configured for the IdP), the data from the SAML assertion is available under saml

One way to find out what data is contained is to add this logging-statement:

logger.warn(asJson(mapping))

And trigger a single-user-sync. This will write a JSON-representation of the date to the log file. This should be avoided on a production-instance as it clutters the log.




Load another user

It's possible to load data from another user during the transformation. A possible use case for this is to store relationships between users, e.G. who the manager of the synced user is.


There are some helper method to be used in the script:

These methods return the user-key as String or null of no user is found:

findUserKeyByUsername(username) loads the user-key by the given username. This returns null if there is no user with that username. This may happen if the user to be found is not synced yet.

findUserKeyByEmail(email)  loads the user-key by the given email-address. This returns null if there is no user with that username. This may happen if the user to be found is not synced yet. The transformation will fail if the email-address is not unique.

findUserKey(attribute_name,attributeValue) loads the user-key by another attribute

These method return a Map<String,Set<String>> with all attributes:

findUser(attribute_name,attributeValue)


Special Implementation - Distinguish whether a user has logged into the JSM portal or into Jira

In SAML 5 we have implemented an additional attribute ATTR_SD_CUSTOMER. This attribute is not coming from the IdP but is set to "true" or "false" depending on the relative URL used by the user to sign into Jira.

To use the attribute you first need to add a new User Property in the Attribute mapping table of the Identity Provider tab.


Set the Jira attribute name and click Next

Enter the name of the attribute in the SAML response and click Apply


When you now log in you will see this value being returned in the SAML response based on whether the user logs in as a service desk customer or a regular user. In the example below the user logged in as a regular user. 

{
  "nameId": "testuser",
  "attributes": {
    "groups": [
      "testgroup"
    ],
    "ATTR_SD_CUSTOMER": [
      "false"
    ],
    "ATTR_NAMEID": [
      "testuser"
    ]
  }
CODE


Migrate Groovy-Transformations from version 4.x

The way to write transformation-scripts has changed with SAML 5.0. "old-style"-script should still work, but we strongly recommend to update them to the new way.

Attributes are available without mapping-prefix

The attributes are accessible directly in the script's Binding, so e.g. instead of mapping.ATTR_NAMEID you can write ATTR_NAMEID

Return value instead of setting mapping.groovyResult

Instead of setting mapping.groovyResult = <valueToSet>, just return the value. It is no longer necessary to create a list for this value. 

Return DROP_ALL instead of setting mapping.drop = true

When a user should be dropped, return the predefined variable DROP_ALL instead of setting mapping.drop = true