You are viewing an old version of this page. View the current version.

Compare with Current View Page History

« Previous Version 2 Next »

It is often desirable to automatically deactivate Jira users who haven't logged in within a certain period, say 6 months. Here we review the options, and provide a ScriptRunner script that does the job.

ScriptRunner solution

Cutting to the chase: the following ScriptRunner for Jira Groovy script does what we want. Note, this script is for a Jira instance where users are all stored in an Internal directory (id = 1). It may also work for a read-write external (LDAP/AD) directory – you will need to customize the directory ID for that. 

/**
 * Script that deactivates users who have not logged in within the last 6 months.
 * See https://www.redradishtech.com/pages/viewpage.action?pageId=11796495 
 * Loosely based on Adaptavist's sample at https://www.adaptavist.com/doco/display/SFJ/Automatically+deactivate+inactive+JIRA+users
 * Adaptavist's script has a bug where if a user has *never* logged in, they will never be deactivated. We fix this by checking the user creation date too.
 *
 * jeff@redradishtech.com, 5/Jun/19
 * v1.0
*/
import com.atlassian.crowd.embedded.api.User
import com.atlassian.crowd.embedded.api.CrowdService
import com.atlassian.crowd.embedded.api.UserWithAttributes
import com.atlassian.crowd.embedded.impl.ImmutableUser
import com.atlassian.crowd.embedded.api.SearchRestriction
import com.atlassian.jira.bc.user.UserService
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.user.ApplicationUsers
import com.atlassian.crowd.search.query.entity.restriction.constants.UserTermKeys
import com.atlassian.crowd.search.query.entity.restriction.constants.DirectoryTermKeys
import com.atlassian.crowd.search.builder.Restriction
import com.atlassian.crowd.search.builder.QueryBuilder
import com.atlassian.crowd.search.query.entity.EntityQuery
import com.atlassian.crowd.search.EntityDescriptor

import org.joda.time.DateTime;
import org.joda.time.Period;

CrowdService crowdService = ComponentAccessor.crowdService

// In a perfect world Jira would let us find exactly the users we want to deactivate with CQL expression 'lastLogin > -6m OR (!lastLogin AND createdDate<-6m)'. Sadly 'lastLogin.lastLoginMillis' is considered a 'secondary' property which Crowd CQL doesn't support (https://developer.atlassian.com/server/crowd/crowd-query-language/). Crowd CQL also doesn't support relative dates like '-6m'. Nor does it support finding users from a particular directory (some of ours may be read-only).
// 
// So instead we search for all active users, and manually check the lastLogin/create date.
// 
// First we search for active users. We don't use UserUtil.getUsers() (unlike every other example on the web), as that returns ApplicationUsers for which it is impossible to get the underlying Ofbiz object, which we need to get the created_date. Instead we use CrowdService.search(), which returns OfBizUsers (https://docs.atlassian.com/software/jira/docs/api/7.2.0/com/atlassian/jira/crowd/embedded/ofbiz/OfBizUser.html).
// QueryBuilder has excellent Javadocs at https://docs.atlassian.com/atlassian-crowd/3.2.3/com/atlassian/crowd/search/builder/QueryBuilder.html
// This returns an iterable of OfBizUsers (https://docs.atlassian.com/software/jira/docs/api/7.2.0/com/atlassian/jira/crowd/embedded/ofbiz/OfBizUser.html) actually
def SearchRestriction active = Restriction.on(UserTermKeys.ACTIVE).exactlyMatching(Boolean.TRUE)
def foundUsers = crowdService.search(
        QueryBuilder.queryFor(User.class, EntityDescriptor.user()).with(active).returningAtMost(EntityQuery.ALL_RESULTS)
        );

log.info "Checking ${foundUsers.size()} active users for possible deactivation-due-to-inactivity"

def shouldDeactivate(User user, DateTime lastUsed) {
        def INACTIVITY_PERIOD = Period.parse("P1Y") // Period of inactivity after which user is deactivated. The format is https://en.wikipedia.org/wiki/ISO_8601#Durations
        // JodaTime 'time ago' calculation: https://stackoverflow.com/a/3859313/7538322
        def expiryDate = lastUsed.plus(INACTIVITY_PERIOD);
        log.info "User ${user.name} will be deactivated after ${expiryDate}";
        return expiryDate.isBeforeNow();
}

def deactivate(User user) {
        UserService userService = ComponentAccessor.getComponent(UserService)
        ApplicationUser updateUser = ApplicationUsers.from(ImmutableUser.newUser(user).active(false).toUser());
        UserService.UpdateUserValidationResult updateUserValidationResult = userService.validateUpdateUser(updateUser);
        if (updateUserValidationResult.isValid()) {
                // Comment out this line to do a dry run:
                userService.updateUser(updateUserValidationResult)
                return true
        } else {
                log.error "Update of ${user.name} failed: ${updateUserValidationResult.getErrorCollection().getErrors().entrySet().join(',')}";
                return false
        }
}

long count = 0
// Restrict to our Internal directory, with ID 1, otherwise we'll get errors trying to modify read-only LDAP users.
foundUsers.findAll { it.directoryId == 1 }.each {
        def ofbizUser = it as com.atlassian.jira.crowd.embedded.ofbiz.OfBizUser;
        def UserWithAttributes user = crowdService.getUserWithAttributes(ofbizUser.getName());
        String lastLoginMillis = user.getValue('login.lastLoginMillis');
        if (lastLoginMillis?.isNumber()) {
                DateTime lastLogin = new DateTime(Long.parseLong(lastLoginMillis));
                if (shouldDeactivate(user, lastLogin) && deactivate(user)) {
                        log.warn "Deactivated ${user.name}, who was last active on ${lastLogin}";
                        count++
                }
        } else if (!lastLoginMillis) {
                DateTime created = new DateTime(ofbizUser.getCreatedDate());
                if (shouldDeactivate(user, created) && deactivate(user)) {
                        log.warn "Deactivated ${user.name}, who has never logged in and was created on ${created}";
                        count++;
                }
        }
}

"${count} inactive users automatically deactivated.\n"

To put this script into production:

  • Save the contents to $JIRAHOME/scripts/deactivate_inactive_users.groovy, owned by root but readable by group jira.
  • Go to the Scriptrunner Script Console and test it:
  • If all looks good, go to Jira's Services  admin page, and add a service of type com.onresolve.jira.groovy.GroovyService 

Other options

Aside from the ScriptRunner script above, I considered (and discarded) a few other options.

Plugins

As of , the only relevant plugin is Manage Inactive Users. This also supports deactivating users in external user bases like Okta and Google Apps.

I am waiting on feedback from the author before passing judgement.

REST Script

Without any plugins, the cleanest solution would be a script utilitizing Jira's REST interface. The script would search for inactive users with Crowd CQL, then deactivate them.

As a preliminary experiment, here is a demonstration of running Crowd Query Language against Jira:

# curl --silent --get -u cli:cli http://jira.localhost/rest/usermanagement/1/search -d 'entity-type=user' --data-urlencode 'restriction=active=true and email=jeff@redradishtech.com and createdDate>2013-09-02'   --header 'Accept: application/json'  | jq .
{
  "expand": "user",
  "users": [
    {
      "link": {
        "href": "http://jira.localhost/rest/usermanagement/1/user?username=jturner",
        "rel": "self"
      },
      "name": "jturner"
    }
  ]
}

(create the 'cli' username/password in JIra's "User Server" admin page)

In a perfect world Jira would let us find exactly the users we want to deactivate with Crowd Query Language expression lastLogin > -6m OR (!lastLogin AND createdDate<-6m). Sadly 'lastLogin.lastLoginMillis' is considered a 'secondary' property which Crowd CQL doesn't support. Crowd CQL also doesn't support relative dates like '-6m'.

Without decent CQL support, our REST script would need to retrieve every active user, iterate through them, and check each user's last login date / created date. This may be slow and memory-intensive. 

Another spanner in the works: Jira only gained a user deactivate REST method in  JIRA 8.3+. See  JRASERVER-44801 - Getting issue details... STATUS .  Users of earlier releases would have to write their own REST endpoint using ScriptRunner: https://www.mos-eisley.dk/display/ATLASSIAN/Deactivate+a+User+via+REST

Given the potential slowness, and lack of REST support, I didn't pursue this route too far.

Direct database hackery

The following SQL (Postgres dialect) prints a nice list of all users who haven't logged in within the last 6 months, or have never logged in:

WITH userlogins AS (
        SELECT
        user_name
        , email_address
        , cwd_user.created_date
        , timestamp with time zone 'epoch'+attribute_value::numeric/1000 * INTERVAL '1 second' AS lastlogin
        , cwd_user.directory_id
        FROM
        cwd_user
        JOIN (select * from cwd_directory WHERE active=1) as CWD_directory ON cwd_user.directory_id = cwd_directory.id
        JOIN cwd_membership ON cwd_membership.child_name=cwd_user.lower_user_name
        JOIN (
                select * from globalpermissionentry WHERE permission IN ('USE', 'ADMINISTER')
             ) AS globalpermissionentry ON cwd_membership.lower_parent_name=globalpermissionentry.group_id
             LEFT JOIN (select * from cwd_user_attributes WHERE attribute_name in ('lastAuthenticated', 'login.lastLoginMillis')) cwd_user_attributes ON user_id=cwd_user.id
        WHERE cwd_user.active=1
)
SELECT distinct user_name
, email_address
, to_char(created_date, 'YYYY-MM-DD') AS created
, to_char(lastlogin, 'YYYY-MM-DD') AS lastlogin
FROM userlogins
 WHERE (lastlogin < now() - '6 months'::interval OR lastlogin is null)  ORDER BY lastlogin desc ;

Couldn't we just change the SELECT to an UPDATE that sets active=0 , and do the deactivation directly in the database?

Sadly I don't think Crowd will appreciate us tinkering with its database directly. I'm pretty sure the Crowd Query Language (CQL) is implemented with Lucene, and would have stale results.

  • No labels