• Skip to main content
  • Skip to primary sidebar

Technical Notes Of
Ehi Kioya

Technical Notes Of Ehi Kioya

  • Forums
  • About
  • Contact
MENUMENU
  • Blog Home
  • AWS, Azure, Cloud
  • Backend (Server-Side)
  • Frontend (Client-Side)
  • SharePoint
  • Tools & Resources
    • CM/IN Ruler
    • URL Decoder
    • Text Hasher
    • Word Count
    • IP Lookup
  • Linux & Servers
  • Zero Code Tech
  • WordPress
  • Musings
  • More
    Categories
    • Cloud
    • Server-Side
    • Front-End
    • SharePoint
    • Tools
    • Linux
    • Zero Code
    • WordPress
    • Musings
Home » SharePoint » Timer job to delete SharePoint orphaned users

Timer job to delete SharePoint orphaned users

By Ehi Kioya Leave a Comment

In my previous article, I explained the concept of orphaned users and how they appear in SharePoint. I also provided a detailed PowerShell script to help you delete all orphaned users across your SharePoint environment.

While a PowerShell script is a great solution for the problem of orphaned users, it may not be the most elegant solution in scenarios where orphaned users are a recurring problem (for example, as a result of continuous employee on-boarding and off-boarding).

It would be better to implement a solution that runs at specified intervals (say, every night) and automatically deletes SharePoint users that have been deleted (or disabled) in active directory without needing to run a script each time. A timer job is better suited for this purpose than a PowerShell script.

Note: This article ignores the My Site cleanup timer job and user profile synchronization filters and behavior. I plan to write subsequent articles that cover those topics later.

The rest of this article will focus on explaining how to create a timer job for deleting orphaned users using Visual Studio. I have tested this solution on both SharePoint 2016 and SharePoint 2010. So, it would of course also work for SharePoint 2013.

Screenshots are from Visual Studio 2017 or SharePoint 2016 as the case may be.

Since this isn’t exactly beginner-level stuff, I will be skipping some details that I don’t consider very relevant (certain screenshots, etc).

SharePoint log list for orphaned users

First we will set up a SharePoint list where we will log all the users removed by our timer job. This purpose of this log would be:

  • To store information about the groups the orphaned user belonged to – When an employee is replaced, organizations often need to copy over the SharePoint permissions associated with the previous employee’s role to the newly on-boarded employee. If the previous employee is deleted from SharePoint, we won’t be able to see the groups they belonged to thereafter. To prevent this problem, we will log the information about groups that an orphaned user belongs to before we delete them.
  • For administrative and/or audit purposes
  • To log any error(s) that occur during the process of user deletion

Here are the details (columns, etc) I used for setting up this log list

  • List internal name: deletedorphanedusers
  • List title: Deleted Orphaned Users
  • Fields/columns (internal name and field type)
    1. displayname, single line of text
    2. loginname, single line of text
    3. groups, multiple lines of text
    4. orphantype, single line of text
    5. sharepointdomain, single line of text
    6. website, single line of text
    7. error, multiple lines of text

The orphaned users timer job definition class

Open Visual Studio and create an empty SharePoint 2016 project. Let’s call the project OrphanedUserCleanup. We will be deploying this as a farm solution.

Now we need to add a class that inherits from the SPJobDefinition class. Right click on your project and add a class. Let’s call our class CleanupJobDefinition.cs

Add the following using statements to the CleanupJobDefinition class:

using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;

Derive the CleanupJobDefinition class from the SPJobDefinition class. Like this:

class CleanupJobDefinition : SPJobDefinition
{
}

And then add the following three required constructors:

public CleanupJobDefinition() : base()
{
}

public CleanupJobDefinition(string jobName, SPService service) : base(jobName, service, null, SPJobLockType.None)
{
	this.Title = "Orphaned User Cleanup Job Definition";
}


public CleanupJobDefinition(string jobName, SPWebApplication webapp) : base(jobName, webapp, null, SPJobLockType.ContentDatabase)
{
	this.Title = "Orphaned User Cleanup Job Definition";
}

Now, we need to override the Execute() method of the CleanupJobDefinition class. The method should accept an argument of type Guid. In this case, the GUID represents the target Web application.

The empty Execute() method will look like this:

public override void Execute(Guid targetInstanceId)
{
}

All of the code logic that we will use to clean up SharePoint orphaned users will be contained within this Execute() method. Let’s write the code and add it now.

At this point, the full code for the CleanupJobDefinition class should look like:

using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.Administration.Claims;
using System;
using System.Collections.Generic;
using System.DirectoryServices;
using System.DirectoryServices.ActiveDirectory;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace OrphanedUserCleanup
{
    class CleanupJobDefinition : SPJobDefinition
    {
        public CleanupJobDefinition() : base()
        {
        }

        public CleanupJobDefinition(string jobName, SPService service) : base(jobName, service, null, SPJobLockType.None)
        {
            this.Title = "Orphaned User Cleanup Job Definition";
        }

        public CleanupJobDefinition(string jobName, SPWebApplication webapp) : base(jobName, webapp, null, SPJobLockType.ContentDatabase)
        {
            this.Title = "Orphaned User Cleanup Job Definition";
        }

        public override void Execute(Guid targetInstanceId)
        {
            string logSiteUrl = "http://spvm";
            string logListTitle = "Deleted Orphaned Users";
            try
            {
                // This dictionary will hold the usernames and website urls of all orphaned users we find
                // Not exactly intuitive, I know... but the key is the login name and the value is the website
                Dictionary<string, string> orphanedUsers = new Dictionary<string, string>();

                // List to hold already processed users
                // For an organization with lots of sites and lots of users, this helps speed up script execution somewhat
                // The logic below includes a condition to only process users who have NOT been added to this list
                List<string> processedUsers = new List<string>();

                SPServiceCollection services = SPFarm.Local.Services;
                foreach (SPService service in services)
                {
                    if (service is SPWebService)
                    {
                        SPWebService webService = (SPWebService)service;
                        foreach (SPWebApplication webApp in webService.WebApplications)
                        {
                            foreach (SPSite site in webApp.Sites)
                            {
                                foreach (SPWeb web in site.AllWebs)
                                {
                                    // Only search SharePoint websites that have unique role assignments
                                    if (web.HasUniqueRoleAssignments)
                                    {
                                        foreach (SPUser user in web.SiteUsers)
                                        {
                                            if (!processedUsers.Contains(user.LoginName.ToLower()))
                                            {
                                                processedUsers.Add(user.LoginName.ToLower());

                                                // Don't search security groups
                                                // Don't search built-in user accounts
                                                if (!user.IsDomainGroup &&
                                                    !user.LoginName.ToLower().Contains("nt authority\\authenticated users") &&
                                                    !user.LoginName.ToLower().Contains("sharepoint\\system") &&
                                                    !user.LoginName.ToLower().Contains("nt authority\\system") &&
                                                    !user.LoginName.ToLower().Contains("nt authority\\local service"))
                                                {
                                                    // SharePoint to LDAP mapping dictionary
                                                    // Write an LDAP path/query for each SharePoint domain (or NetBIOS name) that you are working with
                                                    // Put all keys in lowercase
                                                    Dictionary<string, string> spToADMapping = new Dictionary<string, string>();
                                                    spToADMapping.Add("domainone", "LDAP://DC=domainone,DC=com");
                                                    spToADMapping.Add("domaintwo", "LDAP://DC=domaintwo,DC=local");
                                                    spToADMapping.Add("domain-three", "LDAP://DC=domainthree,DC=ca");

                                                    // Check if login name is claims encoded
                                                    string loginNameDecoded = "";
                                                    if (user.LoginName.Contains("|"))
                                                    {
                                                        SPClaimProviderManager mgr = SPClaimProviderManager.Local;
                                                        if (mgr != null)
                                                        {
                                                            loginNameDecoded = mgr.DecodeClaim(user.LoginName).Value;
                                                        }
                                                    }
                                                    else
                                                    {
                                                        loginNameDecoded = user.LoginName;
                                                    }

                                                    string[] loginNameArray = loginNameDecoded.Split('\\');

                                                    // Username without the domain part
                                                    string account = loginNameArray[1];

                                                    // Domain name in SharePoint which could also be the NetBIOS name depending on configuration
                                                    string domainInSharePoint = loginNameArray[0];

                                                    if (spToADMapping.ContainsKey(domainInSharePoint.ToLower()))
                                                    {
                                                        DirectoryEntry root = new DirectoryEntry(spToADMapping[domainInSharePoint.ToLower()]);

                                                        // This filter will check if the specified account exists in Active Directory
                                                        string filterForAnADUser = "(&(objectCategory=person)(objectClass=user)(samAccountName=" + account + "))";

                                                        // This filter will check if the specified account has been disabled in Active Directory
                                                        string filterForADisabledADUser = "(&(objectCategory=person)(objectClass=user)(samAccountName=" + account + ")(userAccountControl:1.2.840.113556.1.4.803:=2))";

                                                        DirectorySearcher activeUserSearcher = new DirectorySearcher(root, filterForAnADUser);
                                                        SearchResult activeUserResult = activeUserSearcher.FindOne();

                                                        DirectorySearcher disabledUserSearcher = new DirectorySearcher(root, filterForADisabledADUser);
                                                        SearchResult disabledUserResult = disabledUserSearcher.FindOne();

                                                        bool toDelete = false;
                                                        string orphanType = "";

                                                        if (activeUserResult == null)
                                                        {
                                                            // The user does not exist in AD. Let's mark it for deletion from SharePoint.
                                                            toDelete = true;
                                                            orphanType = "Deleted";
                                                        }
                                                        if (disabledUserResult != null)
                                                        {
                                                            // The user has been disabled in AD. Let's mark it for deletion from SharePoint.
                                                            toDelete = true;
                                                            orphanType = "Disabled";
                                                        }

                                                        if (toDelete)
                                                        {
                                                            if (!orphanedUsers.ContainsKey(user.LoginName.ToLower()))
                                                            {
                                                                orphanedUsers.Add(user.LoginName.ToLower(), web.Url);

                                                                // When an employee is replaced, organizations often need to copy over the SharePoint permissions associated with the previous employee's role to the newly on-boarded employee
                                                                // If the previous employee is deleted from SharePoint, we won't be able to see the groups they belonged to thereafter
                                                                // To prevent this problem, we will log the information about groups that an orphaned user belongs to before we delete them
                                                                // This log can also be used for other purposes (audit, etc)
                                                                string userGroups = "";
                                                                foreach (SPGroup group in user.Groups)
                                                                {
                                                                    userGroups += group.Name + "<br/>";
                                                                }
                                                                userGroups = userGroups.Trim();
                                                                using (SPSite logSite = new SPSite(logSiteUrl))
                                                                {
                                                                    using (SPWeb logWeb = logSite.OpenWeb())
                                                                    {
                                                                        SPList logList = logWeb.Lists[logListTitle];
                                                                        SPListItem logListItem = logList.AddItem();
                                                                        logListItem["displayname"] = user.Name;
                                                                        logListItem["loginname"] = loginNameDecoded;
                                                                        logListItem["groups"] = userGroups;
                                                                        logListItem["orphantype"] = orphanType;
                                                                        logListItem["sharepointdomain"] = domainInSharePoint;
                                                                        logListItem["website"] = web.Url;
                                                                        logListItem.Update();
                                                                    }
                                                                }
                                                            }
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                // Delete the orphaned users from SharePoint
                // Proceed with caution when executing this section
                foreach (string orphan in orphanedUsers.Keys)
                {
                    using (SPSite site = new SPSite(orphanedUsers[orphan]))
                    {
                        using (SPWeb web = site.OpenWeb())
                        {
                            SPUserCollection userCol = web.SiteUsers;
                            SPUser user = web.EnsureUser(orphan);
                            if (user.IsSiteAdmin)
                            {
                                // Cannot remove site collection administrators
                                // Log a message instead
                                using (SPSite logSite = new SPSite(logSiteUrl))
                                {
                                    using (SPWeb logWeb = logSite.OpenWeb())
                                    {
                                        SPList logList = logWeb.Lists[logListTitle];
                                        SPListItem logListItem = logList.AddItem();
                                        logListItem["error"] = "[Not an exception. Just a message]: Cannot delete user " + orphan + " because the user is a site collection administrator. Web: " + web.Url;
                                        logListItem.Update();
                                    }
                                }
                            }
                            else
                            {
                                userCol.Remove(orphan);
                            }
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                // Save error messages to SharePoint as well. For debugging.
                using (SPSite logSite = new SPSite(logSiteUrl))
                {
                    using (SPWeb logWeb = logSite.OpenWeb())
                    {
                        SPList logList = logWeb.Lists[logListTitle];
                        SPListItem logListItem = logList.AddItem();
                        logListItem["error"] = ex.ToString();
                        logListItem.Update();
                    }
                }
            }
        }
    }
}

Create a feature to register the timer job definition

Add a new feature to your project. By default, the feature will be named Feature1. We will rename this feature as OrphanedUserCleanupFeature.

The default scope of your feature will probably be Web. Change it to WebApplication.

Your feature settings should now look something like this:

Orphaned Users Timer Job Cleanup Feature

In the feature properties, set Activate On Default to False. We do this because we don’t want the feature to be automatically activated after installation.
 
We will manually activate it after installation on the web application where we want our timer job to run.

Also set the Always Force Install property to True.

Add a feature event receiver

Right-click on the OrphanedUserCleanupFeature and select Add Event Receiver.

In the new class that is automatically added, uncomment the FeatureActivated method and the FeatureDeactivating method. And then write the code for this class like this:

using System;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;

namespace OrphanedUserCleanup.Features.OrphanedUsersCleanupFeature
{
    /// <summary>
    /// This class handles events raised during feature activation, deactivation, installation, uninstallation, and upgrade.
    /// </summary>
    /// <remarks>
    /// The GUID attached to this class may be used during packaging and should not be modified.
    /// </remarks>

    [Guid("6f707f2e-21de-4e18-91e0-b0ecbd054516")]
    public class OrphanedUsersCleanupFeatureEventReceiver : SPFeatureReceiver
    {
        const string JobName = "Orphaned User Cleanup Job Definition";

        // Uncomment the method below to handle the event raised after a feature has been activated.

        public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            try
            {
                SPSecurity.RunWithElevatedPrivileges(delegate ()
                {
                    SPWebApplication parentWebApp = (SPWebApplication)properties.Feature.Parent;
                    DeleteExistingJob(JobName, parentWebApp);
                    CreateJob(parentWebApp);
                });
            }
            catch (Exception ex)
            {
                throw ex;
            }
        }


        // Uncomment the method below to handle the event raised before a feature is deactivated.

        public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
        {
            lock (this)
            {
                try
                {
                    SPSecurity.RunWithElevatedPrivileges(delegate ()
                    {
                        SPWebApplication parentWebApp = (SPWebApplication)properties.Feature.Parent;
                        DeleteExistingJob(JobName, parentWebApp);
                    });
                }
                catch (Exception ex)
                {
                    throw ex;
                }
            }
        }

        private bool CreateJob(SPWebApplication webApp)
        {
            bool jobCreated = false;
            try
            {
                // Create the schedule so that the job runs daily, sometime between midnight and 4 A.M.
                CleanupJobDefinition job = new CleanupJobDefinition(JobName, webApp);
                SPDailySchedule schedule = new SPDailySchedule();
                schedule.BeginHour = 0;
                schedule.BeginMinute = 0;
                schedule.BeginSecond = 0;
                schedule.EndHour = 3;
                schedule.EndMinute = 59;
                schedule.EndSecond = 59;
                job.Schedule = schedule;
                job.Update();
            }
            catch (Exception)
            {
                return jobCreated;
            }
            return jobCreated;
        }

        public bool DeleteExistingJob(string jobName, SPWebApplication webApp)
        {
            bool jobDeleted = false;
            try
            {
                foreach (SPJobDefinition job in webApp.JobDefinitions)
                {
                    if (job.Name == jobName)
                    {
                        job.Delete();
                        jobDeleted = true;
                    }
                }
            }
            catch (Exception)
            {
                return jobDeleted;
            }
            return jobDeleted;
        }


        // Uncomment the method below to handle the event raised after a feature has been installed.

        //public override void FeatureInstalled(SPFeatureReceiverProperties properties)
        //{
        //}


        // Uncomment the method below to handle the event raised before a feature is uninstalled.

        //public override void FeatureUninstalling(SPFeatureReceiverProperties properties)
        //{
        //}

        // Uncomment the method below to handle the event raised when a feature is upgrading.

        //public override void FeatureUpgrading(SPFeatureReceiverProperties properties, string upgradeActionName, System.Collections.Generic.IDictionary<string, string> parameters)
        //{
        //}
    }
}

Deploying our solution

You can deploy this solution the same way that you would deploy any regular SharePoint WSP. Either directly using Visual Studio or using PowerShell.

If deploying from Visual Studio, just right-click on the project name in solution explorer and select Deploy.

After successfully deploying the solution, you need to activate the feature. To do this, open Central Admin and select Manage web applications.

Manage Web Application SharePoint 2016 Central Administration

Select your web application and click on Manage Features on the ribbon.

Manage Web Application Features SharePoint 2016 Central Administration

The pop up window will display all your web application scoped features. Find the feature we just created and activate it.

Orphaned Users Timer Job Cleanup Web Application Feature

After activating the feature, the timer job will be registered. To see a list of all the registered timer jobs, from the Central Admin homepage, go to Monitoring > Review job definitions.

You may need to select the appropriate web application before you will find the job.

Debugging the Execute() method

Debugging the Execute() method of our timer job is a little more interesting because the regular method of pressing F5 or manually attaching to the w3wp.exe process will not work.

Timer jobs are executed by a special Windows service that is set up on the server when you install SharePoint. This service triggers the executable Owstimer.exe (SharePoint timer service). So, to debug the Execute() method, you must attach to the Owstimer.exe process.

After setting a breakpoint inside your Execute() method and attaching to the Owstimer.exe process, you probably wouldn’t want to wait for the timer job to be executed according to it’s schedule (every night in our case). So, for the purpose of testing, you can run the timer job immediately from Central Administration. To do this, select your timer job from the job definitions list and you will see the option to run or disable the job. You will also have to option to set a different time schedule.

Note that time schedules and other settings you set in Central Admin will be overwritten if/when you redeploy your timer job and reactivate the feature.

Another important note is that whenever you change the logic or code inside the Execute() method, you must restart the SharePoint timer service from Services.msc. If you just deploy without restarting the service, your latest updates will not be applied. This can be very frustrating if you don’t know what is going on.

Found this article valuable? Want to show your appreciation? Here are some options:

  1. Spread the word! Use these buttons to share this link on your favorite social media sites.
  2. Help me share this on . . .

    • Facebook
    • Twitter
    • LinkedIn
    • Reddit
    • Tumblr
    • Pinterest
    • Pocket
    • Telegram
    • WhatsApp
    • Skype
  3. Sign up to join my audience and receive email notifications when I publish new content.
  4. Contribute by adding a comment using the comments section below.
  5. Follow me on Twitter, LinkedIn, and Facebook.

Related

Filed Under: Backend (Server-Side), C#, Programming, SharePoint Tagged With: CSharp, Programming, SharePoint

About Ehi Kioya

I am a Toronto-based Software Engineer. I run this website as part hobby and part business.

To share your thoughts or get help with any of my posts, please drop a comment at the appropriate link.

You can contact me using the form on this page. I'm also on Twitter, LinkedIn, and Facebook.

Reader Interactions

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Primary Sidebar

26,168
Followers
Follow
30,000
Connections
Connect
14,642
Page Fans
Like

POPULAR   FORUM   TOPICS

  • How to find the title of a song without knowing the lyrics
  • Welcome Message
  • How To Change Or Remove The WordPress Login Error Message
  • The Art of Exploratory Data Analysis (Part 1)
  • Getting Started with SQL: A Beginners Guide to Databases
  • Replacing The Default SQLite Database With PostgreSQL In Django
  • How to Implement Local SEO On Your Business Website And Drive Traffic
  • 100% Disk Usage In Windows 10 Task Manager – How To Fix
  • Setting up an Ecommerce page: WordPress (WooCommerce) vs Shopify
  • What Is HTTPS? And Why Should You Care?
  • Recently   Popular   Posts   &   Pages
  • Actual Size Online Ruler Actual Size Online Ruler
    I created this page to measure your screen resolution and produce an online ruler of actual size. It's powered with JavaScript and HTML5.
  • Allowing Multiple RDP Sessions In Windows 10 Using The RDP Wrapper Library Allowing Multiple RDP Sessions In Windows 10 Using The RDP Wrapper Library
    This article explains how to bypass the single user remote desktop connection restriction on Windows 10 by using the RDP wrapper library.
  • WordPress Password Hash Generator WordPress Password Hash Generator
    With this WordPress Password Hash Generator, you can convert a password to its hash, and then set a new password directly in the database.
  • Forums
  • About
  • Contact

© 2021   ·   Ehi Kioya   ·   All Rights Reserved
Privacy Policy