Skip to main content

Migrating Existing Nexus Repository User Tokens from LDAP to SAML

This topic provides scripts and guidance to facilitate migrating existing Nexus Repository user tokens from Lightweight Directory Access Protocol (LDAP) to Security Assertion Markup Language (SAML).

For PostgreSQL/H2 Deployments on Nexus Version 3.70.0+

  1. Upgrade your Sonatype Nexus Repository instance to the latest version.

  2. Set up your SAML configuration in Nexus Repository, and enable the SAML realm.

  3. Ensure that your SAML users have the same username as your LDAP users.

  4. In your field mapping, set up roles/groups that map to the same roles/groups as exist in LDAP.

    1. If role names are different, you must create different external role mappings in Nexus Repository.

    2. If using external role mapping for LDAP, you will need to recreate them for SAML.

  5. Enable scripting in $data-dir/etc/nexus.properties by adding nexus.scripts.allowCreation=true.

  6. Restart your Nexus Repository instance for the new setting to take effect.

  7. Back up your database immediately before running the script.

Caution

At this point, users can log in with SAML Auth or LDAP credentials. If you are using user tokens to authenticate, Nexus Repository will only use the LDAP realm tokens. However, if a SAML user uses the UI to access their token, this would create a new user token with new name and pass codes that will no longer match the LDAP side. A user could then use either LDAP or SAML user token to authenticate.

The script will move LDAP user tokens to SAML user tokens with identically named userid. If a SAML user accessed and created a new user token that is different from LDAP, the script will remove the LDAP token, and the existing SAML token will remain in place (ignored by the script).

Now, you can run the script provided below. You can do this either through the REST API or by adding a script task through the user interface. If using a script task, put the code provided below as the script, set the schedule to manual, then run it and watch the logs.

Once finished, disable scripting again by removing nexus.scripts.allowCreation=true or setting this to false in the $data-dir/etc/nexus.properties.

You can test that the migration has worked by disabling the LDAP realm and confirming that a user token still works by trying a known user token.

/**
 * Copyright (c) 2023-present Sonatype, Inc. All rights reserved.
 * Sonatype" is a trademark of Sonatype, Inc.
 * Provided "as is" with no implied official support.
 */

/**
 * IMPORTANT: requires nexus 3.70.0 or greater
 *
 * Scenario: Nexus 3 running with following configurations
 * 1) LDAP Realm enabled
 * 2) SAML Realm enabled
 * 3) User Tokens enabled
 * 4) Configured UserTokens are associated with the LDAP realm
 *
 * Objective: Update LDAP user tokens to now be associated with the SAML realm
 *
 * Notable Restrictions:
 * 1) The LDAP userId is expected to match the SAML userId exactly (case sensitivity counts)
 *
 * Process:
 * 1) Inspect every userToken, looking for those that are associated with the LdapRealm
 * 2) Update these tokens, replacing the LdapRealm association with the SamlRealm
 * 3) For each token updated, also create a local SamlUser from the associated LdapUser so that the tokens can be
 *    utilized without first requiring every user to authenticate with SAML
 *
 */

import com.sonatype.nexus.usertoken.plugin.apikey.store.UserTokenStore
import org.sonatype.nexus.security.user.User
import org.sonatype.nexus.security.user.UserManager

import org.apache.shiro.subject.SimplePrincipalCollection

UserTokenStore userTokenStore = container.lookup(UserTokenStore) as UserTokenStore
UserManager ldapUserManager = container.lookup(UserManager, "LDAP")
UserManager samlUserManager = container.lookup(UserManager, "SAML")

log.info('starting user token migration from LDAP to SAML')
userTokenStore.records().each { userTokenRecord ->
  log.info("Found token principal ${userTokenRecord.principals}")
  SimplePrincipalCollection oldPrincipals = new SimplePrincipalCollection(userTokenRecord.principals)
  if (!oldPrincipals.fromRealm('SamlRealm').empty) {
    log.info('skipping token as it already has SAML realm')
  }
  else if (!oldPrincipals.fromRealm('LdapRealm').empty) {
    SimplePrincipalCollection newPrincipals = new SimplePrincipalCollection()
    def userId = oldPrincipals.primaryPrincipal
    newPrincipals.add(userId, 'SamlRealm')
    userTokenStore.remove(userTokenRecord.principals)
    try {
      def newUserTokenRecord = userTokenStore.newUserTokenRecord(newPrincipals, userTokenRecord.userToken, userTokenRecord.created.toInstant().atOffset(java.time.ZoneOffset.UTC))
      log.info('Adding SAML realm principal to token')
      log.debug('{}', newUserTokenRecord)
      userTokenStore.add(newUserTokenRecord)
      addCachedSamlUser(userId, ldapUserManager, samlUserManager)
    }
    catch (com.sonatype.nexus.usertoken.plugin.store.DuplicateUserTokenException e) {
      log.info('skipping token as it already has SAML realm')
    }
    catch (Exception e) {
      try {
        log.warn('Attempting to add back the original UserTokenRecord')
        def originalUserTokenRecord = userTokenStore.newUserTokenRecord(userTokenRecord.principals, userTokenRecord.userToken, userTokenRecord.created.toInstant().atOffset(java.time.ZoneOffset.UTC))
        log.debug('{}', originalUserTokenRecord)
        userTokenStore.add(originalUserTokenRecord)
      }
      catch (Exception e1) {
        log.error('Unable to add back the original UserTokenRecord', e1)
      }
      throw e
    }
  }
  else {
    log.info('Skipping non-LDAP token')
  }
}

log.info('completed user token migration to SAML')

def addCachedSamlUser(userId, ldapUserManager, samlUserManager) {
  try {
    User ldapUser = ldapUserManager.getUser(userId)
    if (!ldapUser) {
      log.error("Unable to retrieve user ${userId} from ldap, skipping creation of local SAML user")
      return
    }

    samlUserManager.addUser(ldapUser, null)
  }
  catch (Exception e) {
    log.error("Failed to retrieve user ${userId} from ldap, skipping creation of local SAML user", e)
  }
}

For All OrientDB Deployments or PostgreSQL/H2 Deployments on Versions Prior to 3.70.0

Before running the script, you must meet the following prerequisites:

  1. Ensure that your SAML users have the same username as your LDAP users.

  2. In your field mapping, set up roles/groups that map to the same roles/groups as exist in LDAP.

    1. If role names are different, you must create different external role mappings in Nexus Repository.

    2. If using external role mapping for LDAP, you will need to recreate them for SAML.

  3. Enable scripting in $data-dir/etc/nexus.properties by adding nexus.scripts.allowCreation=true.

  4. Restart your Nexus Repository instance for the new setting to take effect.

Now, you can run the script provided below. You can do this either through the REST API or by adding a script task through the user interface. If using a script task, put the code provided below as the script, set the schedule to manual, then run it and watch the logs.

Once finished, disable scripting again by removing nexus.scripts.allowCreation=true or setting this to false in the $data-dir/etc/nexus.properties.

You can test that the migration has worked by disabling the LDAP realm and confirming that a user token still works by trying a known user token.

import com.sonatype.nexus.usertoken.plugin.store.UserTokenStore
import org.apache.shiro.subject.SimplePrincipalCollection

UserTokenStore userTokenStore = container.lookup(UserTokenStore) as UserTokenStore
log.info('starting user token migration from LDAP to SAML')
userTokenStore.records().each { userTokenRecord ->
    log.info("Found token principal ${userTokenRecord.principals}")
    SimplePrincipalCollection newPrincipals = new SimplePrincipalCollection(userTokenRecord.principals)
    if (!newPrincipals.fromRealm('SamlRealm').empty) {
        log.info('skipping token as it already has SAML realm')
    }
    else if (!newPrincipals.fromRealm('LdapRealm').empty) {
      newPrincipals.add(newPrincipals.primaryPrincipal, 'SamlRealm')
        userTokenStore.remove(userTokenRecord.nameCode)
      try {
           def newUserTokenRecord = userTokenRecord.getClass().newInstance(newPrincipals, userTokenRecord.userToken, userTokenRecord.created)
           log.info('Adding SAML realm principal to token')
           log.debug("{}", newUserTokenRecord)
         userTokenStore.add(newUserTokenRecord)
      } catch (Exception e) {
         try {
            log.warn("Attempting to add back the original UserTokenRecord")
            def originalUserTokenRecord = userTokenRecord.getClass().newInstance(userTokenRecord.principals, userTokenRecord.userToken, userTokenRecord.created)
            log.debug("{}", originalUserTokenRecord)
            userTokenStore.add(originalUserTokenRecord)
         } catch (Exception e1) {
            log.error("Unable to add back the original UserTokenRecord", e1)
         }
         
         throw e;
      }
    }
    else {
        log.info('Skipping non-LDAP token')
    }
}

log.info('completed user token migration to SAML')