Groovy-Connector
Groovy-Connectors allow creating connectors by writing Groovy-code directly in the configuration. This allows
Synchronizing users from backends where no Connector is available
Implementing business-logic for the user-synchronization
User Sync has two modes of operation: "Sync" and "Single User Sync"
Sync is an asynchronous process updating all users or a limited subset of users from the backend system (e.g. Azure-AD) in a specific directory of the Atlassian-application- similar to LDAP or Crowd-directories.
Single User Sync creates or updates one specific user. This us usually triggered when a user logs in using SAML and "update user from Usersync-connector" is enabled.
Single User Sync
Sync
Examples
Pass SAML-Attributes to Connector and uses Advanced Transformations
Attribute-transformations in UserSync are more powerful as in SAML JIT. They allow taking the existing user into account.
To allow this with SAML-Attributes, a Groovy-Connector can be used.
Create a Groovy-connector with this code:
- class ConnectorCode extends GroovyConnectorCode {
- public FindUserResult findUser(SyncSingleUserWrapper syncSingleUserWrapper) {
- // This just passes the additionalData (in this case the SAML-attributes) so
- // they can be used in the Attribute-transformations
- return FindUserResult.found(syncSingleUserWrapper.additionalData);
- }
- }
In the Provisioning-tab, configure transformations for all required attributes, (similar to what to configure in the SAML-config for JIT). The main-difference is that the attributes are now available under the con-prefix.
So when using a Groovy-Transformation for groups, this may look like this example merging existing groups with the new ones:
- def newGroups = existing?.ATTR_GROUPS ?: [] // Existing groups or an empty list if there is no existing user
- newGroups.addAll(con.groups) // add all groups from the SAML-Response (passed in here by the Groovy-connector)
- return newGroups.toUnique() // toUnique() removes duplicate entries
Set the user-update-method to "Update from UserSync-connector" and select this connector.
Update user via Jira REST-API
- class ConnectorCode extends GroovyConnectorCode {
- // This is the base url for the Jira REST-API (put in your base-url),
- // see https://docs.atlassian.com/software/jira/docs/api/REST/8.18.0/
- def restBase = "https://<Base-URL>/rest/api/2"
- void init() {
- // Add the Authorization-header to all further requests
- http.addDefaultHeader("Authorization",Credentials.basic("<username>","<password>"))
- }
- // Adds a user to a group
- void addUserToGroup(username, groupName) {
- debug("Adding $username to $groupName")
- def jsonToSend = """{ "name": "$username" }"""
- ResponseWrapper response = http.postJson(
- "$restBase/group/user?groupname=$groupName",
- jsonToSend)
- if(response.code != 201 && response.code != 400) {
- fail("Unexpected response adding $username to group $groupName: ${response.code}: ${response.body}")
- }
- }
- // Checks if a user exists
- boolean userExists(username) {
- debug("Checking if $username exists already")
- def response = http.get("$restBase/user?username=$username")
- int responseCode = response.code
- if(responseCode == 404) {
- return false
- } else if(responseCode == 200) {
- return true
- } else {
- fail("REST-call to check user existence returned unexpected result $responseCode: ${response.body}")
- }
- }
- // Creates a new user
- void createUser(username, email, displayName) {
- debug("Creating $username with email $email and display name $displayName")
- def jsonToSend = """{
- "name": "$username",
- "emailAddress": "$email",
- "displayName": "$displayName"
- }"""
- ResponseWrapper response = http.postJson("$restBase/user",jsonToSend)
- if(response.code != 201) {
- fail("Unexpected response creating user: ${response.code} ${response.body}")
- }
- }
- // This method is called for a single-user sync
- @Override
- public SyncSingleUserResult syncSingleUser(SyncSingleUserWrapper wrapper) {
- if(!userExists(wrapper.identifier)) {
- // In this example, we assume that the SAML-attributes for email and displayname
- // are 'email' and 'displayName'
- // The SAML-attributes come as lists, os we need to pick the first element
- def email = wrapper.additionalData.email?.first()
- def displayName = wrapper.additionalData.displayName?.first()
- // Just fail if the necessary attributes are not present
- if( !email || !displayName) {
- fail("email and displayname must be present to create a user")
- }
- createUser(wrapper.identifier,email,displayName)
- }
- def groupsToAdd = wrapper.additionalData.groups?.asList() ?: []
- groupsToAdd.forEach{
- group -> addUserToGroup(wrapper.identifier,group)
- }
- // Create SingleUSerResult of type OTHER because we don't return a user here
- return SyncSingleUserResult.createOther("Update triggered");
- }
- }
Update/Create user with SAML-Attributes
and some logic
- class ConnectorCode extends GroovyConnectorCode {
- @Override
- public SyncSingleUserResult syncSingleUser(SyncSingleUserWrapper wrapper) {
- // Messages from debug() are visible in the log file when enabling
- // debug-logging for de.resolution.usersync.builtin.groovyconnector.GroovyConnectorCode
- debug("Single User Sync started for $wrapper.identifier")
- // Read out the required data from the SAML-response
- // The attributes come as lists, so we take the first list-entry using Groovy's safe dereference-operator
- def username = wrapper.additionalData.get("ATTR_NAMEID")?.first()
- if(!username) {
- return SyncSingleUserResult.createFailure("no username in SAML-response")
- }
- def email = wrapper.additionalData.get("email")?.first()
- if(!email) {
- return SyncSingleUserResult.createFailure("no email in SAML-response")
- }
- def fullname = wrapper.additionalData.get("fullname")?.first()
- if(!fullname) {
- return SyncSingleUserResult.createFailure("no fullname in SAML-response")
- }
- def groups = wrapper.additionalData.get("groups") // no .first() groups is really a list
- if(groups == null) {
- return SyncSingleUserResult.createFailure("no groups in SAML-response")
- }
- // Search for an existing user with the SAML-NameID as username
- AtlasUserResult findExistingResult = wrapper.findByUsername(username)
- // Fail here if the search-result is unexpected (anything other than successful or not found)
- if(!findExistingResult.notFound && !findExistingResult.success) {
- return SyncSingleUserResult.createFailure("Unexpected result searching existing user by username $username : ${Utils.asJson(findExistingUserResult)}")
- }
- // Search the user by the email address (as username) if not found by the userid
- if(findExistingResult.notFound) {
- debug("User with username username not found, searching user by email $email")
- findExistingResult = wrapper.findByUsername(email)
- // Fail here if the search-result is unexpected (anything other than successful or not found)
- if(!findExistingResult.notFound && !findExistingResult.success) {
- return SyncSingleUserResult.createFailure("Unexpected result searching existing user by email address $email : ${Utils.asJson(findExistingUserResult)}")
- }
- }
- // Create a new user if also not found by email address
- if(findExistingResult.notFound) {
- debug("No user found with username username or email $email, creating a new user.")
- // Create new users in the configured directory
- long directoryId = connector.configuration.directoryId
- AtlasUser userToCreate = AtlasUser.builder()
- .findBy(AtlasUserKeys.ATTRIBUTE_USERNAME,username)
- .in(directoryId)
- .with(AtlasUserKeys.ATTRIBUTE_USERNAME, username)
- .with(AtlasUserKeys.ATTRIBUTE_EMAIL,email)
- .with(AtlasUserKeys.ATTRIBUTE_FULLNAME,fullname)
- .with(AtlasUserKeys.ATTRIBUTE_GROUPS,groups)
- .with("CREATED_BY_SAML",true) // Set this attribute so the user is considered as created by SAML SSO
- .build()
- AtlasUserResult createResult = wrapper.create(userToCreate)
- return SyncSingleUserResult.createFound(createResult)
- }
- AtlasUser existingUser = findExistingResult.resultingUser.get() // resultingUser is a Java-Optional, so we need to call .get()
- // If a user is found and the user has the CREATED_BY_SAML-attribute, update the user
- // with the data from the SAML-response
- if(!existingUser.containsKey("CREATED_BY_SAML")) {
- debug("Existing user found has the CREATED_BY_SAML-attribute, updating attributes")
- AtlasUser userToUpdate = existingUser.newBuilder()
- .with(AtlasUserKeys.ATTRIBUTE_USERNAME,username)
- .with(AtlasUserKeys.ATTRIBUTE_EMAIL,email)
- .with(AtlasUserKeys.ATTRIBUTE_FULLNAME,fullname)
- .with(AtlasUserKeys.ATTRIBUTE_GROUPS,groups)
- .build()
- AtlasUserResult updateResult = wrapper.update(userToUpdate)
- return SyncSingleUserResult.createFound(updateResult)
- } else {
- // Don't update users without the CREATED_BY_SAML-attribute, just return
- // success with the found user
- debug("Existing user found has no CREATED_BY_SAML-attribute, NOT updating attributes")
- return SyncSingleUserResult.createFound(findExistingResult)
- }
- }
- }
Create from JSON
- // The class must extend GroovyConnectorCode
- class ConnectorCode extends GroovyConnectorCode {
- StructuredData testData
- @Override
- void init() {
- testData = StructuredData.parseJson(dataJson)
- }
- /*
- * This method is run when a sync is started.
- * It should call syncFunction.apply() with all users.
- * Remove this method if the Connector should not be able to run full syncs.
- */
- @Override
- void sync(SyncWrapper sync) {
- testData.forEach {
- sync.syncUser(it)
- }
- }
- /*
- * This method is run for a single user sync.
- * Remove this method if the Connector should not be able to run single user syncs.
- */
- @Override
- FindUserResult findUser(SyncSingleUserWrapper wrapper) {
- StructuredData foundData = testData.find { it.USERID == wrapper.identifier}
- if(foundData == null) {
- return notFound();
- } else {
- return found(foundData)
- }
- }
- def dataJson = """
- [
- {
- "EMAIL": "devin.bashirian@example.com",
- "FULLNAME": "Devin Bashirian",
- "GROUPS": [
- "Aerosmith",
- "Blondie"
- ],
- "USERID": "devin.bashirian",
- "Unique_ID": "83d992e7-d326-4f54-9e07-1980497849f6"
- },
- {
- "EMAIL": "ambrose.nader@example.com",
- "FULLNAME": "Ambrose Naderx",
- "GROUPS": [
- "Aerosmith",
- "Blondie",
- "U2",
- "Genesis"
- ],
- "USERID": "ambrose.nader",
- "Unique_ID": "50930ca4-f7e9-4a57-911c-369fd90ef736"
- },
- {
- "EMAIL": "gabriela.price@example.com",
- "FULLNAME": "Gabriela Price",
- "GROUPS": [
- "Aerosmith",
- "Genesis",
- "Blondie"
- ],
- "USERID": "gabriela.price",
- "Unique_ID": "ed5698ad-f5ae-414a-a257-3b719cb86322"
- },
- {
- "EMAIL": "joseph.crona@example.com",
- "FULLNAME": "Joseph Crona",
- "GROUPS": [
- "Aerosmith",
- "Blondie",
- "U2"
- ],
- "USERID": "joseph.crona",
- "Unique_ID": "f717f7d1-5cff-46a0-8b08-ce1a5a11b2b9"
- },
- {
- "EMAIL": "jamee.breitenberg@example.com",
- "FULLNAME": "Jamee Breitenberg",
- "GROUPS": [
- "U2",
- "Aerosmith",
- "U2",
- "Genesis",
- "Blondie"
- ],
- "USERID": "jamee.breitenberg",
- "Unique_ID": "5446e3d2-5686-4b9b-a595-82c3ba83378f"
- }
- ]
- """
- }
Trigger single user sync on other connector
- class ConnectorCode extends GroovyConnectorCode {
- //
- // After the sync, all users in the directory configured for this connector are disabled
- // during the cleanup-phase. So make sure to configure an separate, empty directory
- // for this connector!
- /*
- * This method is called when a sync is started.
- */
- @Override
- void sync(SyncWrapper sync) {
- // Unique id of the connector to call
- def otherConnectorId = "4264572f-e481-4ee2-914c-7774a4c9b80d"
- // This will throw an Exception if the connector is not found and the sync
- // will stop as failed
- Connector otherConnector = connectorService.getConnectorByUniqueId(otherConnectorId)
- // We need to work on the other connector's directory
- def directory = otherConnector.configuration.directoryId
- SyncStatus syncStatus = sync.getSyncStatus()
- // With this method, apply() from CallSyncSingleUserFunction is called for
- // each user in the directory, so see below for what's happening there
- atlasUserAdapter.applyToAll(
- directory,
- new CallSyncSingleUserFunction(otherConnector,sync),
- syncStatus)
- }
- class CallSyncSingleUserFunction implements AtlasUserFunction {
- private final Connector connectorToCall
- private final SyncWrapper syncWrapper
- CallSyncSingleUserFunction(Connector con, SyncWrapper wrap) {
- connectorToCall = con
- syncWrapper = wrap
- }
- // This is called by atlasUserAdapter.applyToAll() above
- Optional<AtlasUserResult> apply(AtlasUser atlasUser, AtlasUserStatusObject status) {
- def username = atlasUser.get("ATTR_NAME").get()
- // Trigger the single user sync for the user
- def syncSingleResult = connectorToCall.syncSingleUser(username,null,null,null)
- // If the user was found in the connector's backend, there should be
- // an AtlasUserResult indicating the update.result
- if(syncSingleResult.status == SyncSingleUserResult.Status.FOUND) {
- AtlasUserResult updateResult = syncSingleResult.getUpdateUserResult()
- status.add(updateResult)
- return Optional.ofNullable(updateResult)
- } else if(syncSingleResult.status == SyncSingleUserResult.Status.NOT_FOUND) {
- syncWrapper.log("Sync single user for $username returned NOT_FOUND, disabling user")
- AtlasUser userToDisable = new AtlasUserBuilder()
- .findBy(atlasUser.getReference())
- .active(false)
- .build()
- AtlasUserResult disableResult = atlasUserAdapter.update(userToDisable)
- status.add(disableResult)
- return Optional.of(disableResult)
- } else {
- syncWrapper.log("Sync single user for $username returned unexpected result: ${syncSingleResult.status}")
- return Optional.empty()
- }
- }
- }
- }
Uni
- package groovy
- class ConnectorCode extends GroovyConnectorCode {
- private static final String KEY_ACCESS_TOKEN = "accessToken";
- private static final String KEY_EXPIRE_DATE = "expireDate";
- //
- // Diese Werte müssen entsprechend angepasst werden
- //
- private static final String userUrl = "<https://.../api/v1.0/">
- private static final String tokenUrl = "<https://...">
- private static final String clientId = "removed"
- private static final String clientSecret = "removed"
- private static final String scope = "uaccount_jira_api"
- private static final String audience = null
- //
- // Hier muss das von Shibboleth gesendete Gruppenattribut eingetragen werden
- //
- private static final String GROUP_ATTR = "group"
- // Identifiers for testing:
- // student1@univie.ac.at -> mailForwardingAddress
- // test1@univie.ac.at -> primary email
- // test3@univie.ac.at -> no email
- @Override
- public SyncSingleUserResult syncSingleUser( String identifier,
- Map<String, Collection<String>> additionalData,
- Map<String, Set<String>> attributesToOverride) {
- def userFromBackend = findUserInBackend(identifier,false)
- // For debugging, user attributes can be specified here instead of loading them from the backend
- /*
- def userFromBackend = [
- uid : "test2",
- displayName : "Test User 2 ABC x",
- mail : "test2.user@univie.ac.at",
- eduPersonPrincipalName : "test2@univie.ac.at",
- mailForwardingAddress : ["test2.user@univie.ac.at", "test2.user@gmail.com", "test2.user@web.de"]
- ]
- */
- if(userFromBackend == null) {
- return SyncSingleUserResult.createNotFound()
- }
- // 1. Ein Benutzer mit aktiver primärer e-mail Adresse (Uni Personal oder Studierende) loggt mittels Shibboleth ins Servicedesk ein.
- if(userFromBackend.mail) {
- // Falls bereits ein Jira Account mit der verwendeten UserID vorhanden ist dann soll nur eine Aktualisierung erfolgen falls diese primäre e-mail Adresse
- // von der im Jira Directory vorhandenen e-mail Adresse abweicht, und/oder der Realname ein anderer ist. Die UserID bleibt unverändert.
- AtlasUserResult resultByName = findExistingUser(userFromBackend.uid)
- if (resultByName.isSuccess()) {
- return SyncSingleUserResult.createFound(
- updateUser(resultByName.getResultingUser().get(),userFromBackend,additionalData))
- // Ist aber kein Jira Account mit der verwendeten UserID vorhanden
- // dann soll nach einem Jira Account gesucht werden dessen UserID der primären e-mail Adresse entspricht.
- } else if (resultByName.notFound) {
- AtlasUserResult resultByEmail = findExistingUser(userFromBackend.mail)
- if (resultByEmail.isSuccess()) {
- return SyncSingleUserResult.createFound(
- updateUser(resultByEmail.getResultingUser().get(),userFromBackend,additionalData))
- } else if (resultByEmail.notFound) {
- return SyncSingleUserResult.createFound(
- createNewUser(userFromBackend,additionalData))
- } else {
- return SyncSingleUserResult.createFailure("Unexpected result : ${Utils.asJson(resultByEmail)}")
- }
- }
- }
- /* 2. Ein Benutzer ohne primäre e-mail Adresse (vorläufiger Account, Rolle PROSPECTIVE) loggt mittels Shibboleth ins Servicedesk ein. */
- if(userFromBackend.mailForwardingAddress) {
- AtlasUserResult resultByName = findExistingUser(userFromBackend.uid)
- // Falls bereits ein Jira Account mit der verwendeten UserID vorhanden ist dann soll nur eine Aktualisierung erfolgen falls diese primäre e-mail Adresse
- // von der im Jira Directory vorhandenen e-mail Adresse abweicht, und/oder der Realname ein anderer ist. Die UserID bleibt unverändert.
- if (resultByName.isSuccess()) {
- return SyncSingleUserResult.createFound(
- updateUser(resultByName.getResultingUser().get(),userFromBackend,additionalData))
- // Ist aber kein Jira Account mit der verwendeten UserID vorhanden dann soll nach einem Jira Account gesucht werden
- // dessen UserID der Weiterleitungsadresse entspricht.
- // Ist so ein Jira Account vorhanden so soll dessen UserID aktualisiert werden (und abweichende Daten auch).
- } else if (resultByName.notFound) {
- if(!userFromBackend.mailForwardingAddress) {
- return SyncSingleUserResult.createFailure("No mailForwardingAddress is set for this user")
- }
- for(forwardingAddress in userFromBackend.mailForwardingAddress) {
- AtlasUserResult resultByForwardingAddress = findExistingUser(forwardingAddress)
- if(resultByForwardingAddress.isSuccess()) {
- return SyncSingleUserResult.createFound(
- updateUser(resultByForwardingAddress.getResultingUser().get(),userFromBackend,additionalData))
- } else if(!resultByForwardingAddress.notFound) {
- return SyncSingleUserResult.createFailure("Unexpected result searching by forwardingAddress : ${Utils.asJson(resultByForwardingAddress)}")
- }
- }
- return SyncSingleUserResult.createFound(
- createNewUser(userFromBackend,additionalData))
- }
- }
- return SyncSingleUserResult.createFailure("User has neither a primary email nor a mailForwardingAddress")
- }
- /*
- * Finds a user by the username
- */
- AtlasUserResult findExistingUser(String username) {
- return atlasUserAdapter.readFirstUniqueUser(
- AtlasUserReference.create(
- AtlasUserKeys.ATTRIBUTE_USERNAME,
- username,
- AtlasUserKeys.ANY_DIRECTORY));
- }
- /*
- * Updates an existing user with the attributes from the backend
- */
- AtlasUserResult updateUser(AtlasUser existingUser, attributesFromBackend, additionalData) {
- AtlasUserBuilder userToUpdate = existingUser.newBuilder();
- if(attributesFromBackend.uid) {
- userToUpdate.with(AtlasUserKeys.ATTRIBUTE_USERNAME, attributesFromBackend.uid)
- }
- if(attributesFromBackend.mail) {
- userToUpdate.with(AtlasUserKeys.ATTRIBUTE_EMAIL,attributesFromBackend.mail)
- }
- if(attributesFromBackend.displayName) {
- userToUpdate.with(AtlasUserKeys.ATTRIBUTE_FULLNAME,attributesFromBackend.displayName)
- }
- if(attributesFromBackend.uid) {
- userToUpdate.with("uid",attributesFromBackend.uid)
- }
- if(additionalData != null && additionalData.get(GROUP_ATTR) != null) {
- // use getAttributeValues here, get() will return the first group only
- def existingGroups = existingUser.getAttributeValues('ATTR_GROUPS')
- def newGroups = additionalData.get(GROUP_ATTR)
- def effectiveGroups = existingGroups + newGroups
- userToUpdate.with(AtlasUserKeys.ATTRIBUTE_GROUPS,effectiveGroups)
- }
- return atlasUserAdapter.update(userToUpdate.build())
- }
- /*
- * Creates a new user from the attributeMap
- */
- AtlasUserResult createNewUser(attributeMap,additionalData) {
- logger.warn(attributeMap as String)
- ConnectorConfiguration cfg = connector.getConfiguration();
- long directory = cfg.getDirectoryId();
- AtlasUserBuilder userBuilder = AtlasUser.builder()
- .findBy(AtlasUserKeys.ATTRIBUTE_USERNAME,attributeMap.uid)
- .in(directory)
- .with(AtlasUserKeys.ATTRIBUTE_USERNAME, attributeMap.uid)
- .with(AtlasUserKeys.ATTRIBUTE_EMAIL,attributeMap.mail ?: attributeMap.mailForwardingAddress)
- .with(AtlasUserKeys.ATTRIBUTE_FULLNAME,attributeMap.displayName)
- .with("uid",attributeMap.uid)
- if(additionalData != null && additionalData.get(GROUP_ATTR) != null) {
- userBuilder.with(AtlasUserKeys.ATTRIBUTE_GROUPS,additionalData.get(GROUP_ATTR))
- }
- AtlasUser userToCreate = userBuilder.build()
- return atlasUserAdapter.create(userToCreate);
- }
- /*
- * Searches the user in the backend
- */
- def findUserInBackend(String identifier, boolean isRetry) {
- if(!read(KEY_ACCESS_TOKEN) || (new Date().getTime() > Long.valueOf(read(KEY_EXPIRE_DATE) ?: "0"))) {
- requestAccessToken();
- }
- def accessToken = read(KEY_ACCESS_TOKEN)
- if(!accessToken) {
- fail("No Access-Token present")
- }
- def resp = http.get("${userUrl}${identifier}",["Authorization": "Bearer " + accessToken])
- if(resp.code == 401) {
- if(isRetry) {
- fail("Could not load user, unauthorized after retry")
- } else {
- return findUserInBackend(identifer,true)
- }
- }
- if(resp.code == 404) {
- return null
- }
- if(!(resp.code in 200..299)) {
- fail("Unexpected response ${resp.code}")
- }
- return resp.parsedJson
- }
- /*
- * Requests a new OAUTH-Token using the client_credentials flow
- */
- void requestAccessToken() {
- def formData = [
- "grant_type" : "client_credentials",
- "client_id" : clientId,
- "client_secret": clientSecret ]
- if(scope) {
- formData.put("scope",scope)
- }
- if(audience) {
- formData.put("audience",audience)
- }
- def resp = http.postAsForm(tokenUrl,formData,[:])
- if(resp.code in (200..299)) {
- def parsed = resp.parsedJson
- def accessToken = parsed.access_token
- def expires = parsed.expires_in
- def expireDate = new Date().getTime() + ((expires - 1) * 1000)
- write(KEY_ACCESS_TOKEN,accessToken)
- write(KEY_EXPIRE_DATE,expireDate)
- } else {
- fail("Failed to load access token: $resp.code")
- }
- }