Sailpoint ISC – Lifecycle deactivation at local midnight based on user time zone

We are using SailPoint ISC lifecycle states and transforms to deactivate terminated users based on LastDayOfWork from our HR source.

Current behavior:

Terminated employees are deactivated at tenant midnight (CEST).
Our lifecycle logic uses dateCompare with now / now-1d.
This causes all users globally to lose access at the same time, regardless of their local time zone.

Business requirement:

Employees must retain access until end of day in their own time zone.
System access should stop at midnight of the user’s local time zone on their last working day.

Question:

Is there any supported way in Sailpoint ISC to make lifecycle state transitions or dateCompare transforms time‑zone aware per identity?
Specifically, can ISC evaluate now or date comparisons based on an identity attribute (e.g., user time zone) rather than the tenant time zone?
If not, what is the recommended SailPoint approach to handle time‑zone‑based terminations?

We are currently using lifecycle transforms like isJustEnded based on LastDayOfWork, but these evaluate using the tenant time zone only.
Any guidance or best practices from similar implementations would be appreciated.

ADD on this post .. We are using this to find cloudlifecycle attribute

{
    "id": "",
    "name": "Determine LCS Master",
    "type": "static",
    "attributes": {
        "requiresPeriodicRefresh": true,
        "isActive": {
            "attributes": {
                "id": "LCS - isActive"
            },
            "type": "reference"
        },
        "isDeleted": {
            "attributes": {
                "id": "LCS - isDeleted"
            },
            "type": "reference"
        },
        "isNoShow": {
            "attributes": {
                "id": "LCS - isNoShow"
            },
            "type": "reference"
        },
        "isEnded": {
            "attributes": {
                "id": "LCS - isEnded"
            },
            "type": "reference"
        },
        "isJustEnded": {
            "attributes": {
                "id": "LCS - isJustEnded"
            },
            "type": "reference"
        },
        "isInactivePending": {
            "attributes": {
                "id": "LCS - isInactivePending"
            },
            "type": "reference"
        },
        "isNearActive": {
            "attributes": {
                "id": "LCS - isNearActive"
            },
            "type": "reference"
        },
        "value": "#if($isNoShow=='true')noShow#{elseif}($isDeleted=='true')deleted#{elseif}($isEnded=='true')ended#{elseif}($isJustEnded=='true')justEnded#{elseif}($isInactivePending=='true')inactivePending#{elseif}($isActive=='true')active#{elseif}($isNearActive=='true')nearActive#{else}prehire#end"
    },
    "internal": false
}

and this is for LCS - isJustEnded

{
    "id": "",
    "name": "LCS - isJustEnded",
    "type": "dateCompare",
    "attributes": {
        "firstDate": {
            "attributes": {
                "input": {
                    "attributes": {
                        "values": [
                            {
                                "attributes": {
                                    "attributeName": "LastDayOfWork",
                                    "sourceName": "Boomi HR Data"
                                },
                                "type": "accountAttribute"
                            },
                            {
                                "attributes": {
                                    "value": "2999-12-31"
                                },
                                "type": "static"
                            }
                        ]
                    },
                    "type": "firstValid"
                },
                "inputFormat": "yyyy-MM-dd",
                "outputFormat": "ISO8601"
            },
            "type": "dateFormat"
        },
        "secondDate": {
            "attributes": {
                "input": "",
                "expression": "now-1d"
            },
            "type": "dateMath"
        },
        "negativeCondition": "false",
        "operator": "LT",
        "positiveCondition": "true"
    },
    "internal": false
}

Hi @ROHPU - do you have access to the user’s timezone or timezone offset in your HR Data? Are you addressing one timezone or multiple? It is possible to use a transform on the nextProcessing attribute if you have that data. It would be conditional based on the users terminationDate and timezone offset, then you can adjust the processing time with some Date Math to better align with your requirements. Transform would also need to return null for users without a term date so they are processed at the normal 8am and 8pm times.

Hi Pucha,

I’d recommend pulling the Timezone transform from the CoLab: colab-transforms/transforms/Timezone/Timezone.json at 943a283ef9d409d6c71229806072c150b6b39139 · sailpoint-oss/colab-transforms · GitHub
It, and the account expires transform: colab-transforms/transforms/AccountExpires/AccountExpires.json at 943a283ef9d409d6c71229806072c150b6b39139 · sailpoint-oss/colab-transforms · GitHub are doing something very similiar to what you’re trying to do and should be easy enough to extend to your lifecycle transform. To ensure it’s processed at the right time, apply something to the Next Processing Date Processing Identity Data - SailPoint Identity Services

Nice, I did not know there was one in colab.

We’ve created a generic / flexible transform that takes region-specific paramters to also handle the DST offset (the colab version doesn’t do that), because depending on where you are, DST is observed the region’s position on the globe:

Thanks for the suggestion. In our HR data, we currently do not have a timezone or timezone offset attribute available. We only have a country code, and the challenge is that a single country can span multiple time zones.

Because of this, it’s difficult to reliably derive the correct timezone for users, which makes applying a transform on nextProcessing challenging without introducing inaccuracies. If there are any recommended best practices for handling this scenario with limited HR data, I’d appreciate your guidance.

Thanks for sharing this. I’ll review the Timezone and AccountExpires transforms from the CoLab and see how they can be extended for our lifecycle use case. I’ll also look into applying the logic using the Next Processing Date. I’ll update here if I have any questions.

Thanks for sharing that insight—this sounds very useful, especially with handling DST variations by region.

Could you please walk through a step‑by‑step approach on how to implement a generic, region‑aware transform like this?

Here’s the transform for the offset, below. With this, you can then modify it to return now<offset> for dateMath (e.g. “now-4h”, “now-5h”). Gemini generated the code, and was smart enough to handle the southern hemisphere. We had to correct some of the syntax, of course…but the logic is sound.

You can also scale the Transform to handle multi-region with a lookup that maps the various startMonth/startSunday/…etc.

{
    "name": "Flexible DST Offset",
    "type": "static",
    "attributes": {
        "currentMonth": {
            "type": "dateFormat",
            "attributes": {
                "inputFormat": "yyyy-MM-dd'T'HH:mm",
                "outputFormat": "MM",
                "input": {
                    "type": "dateMath",
                    "attributes": {
                        "expression": "now"
                    }
                }
            }
        },
        "currentDay": {
            "type": "dateFormat",
            "attributes": {
                "inputFormat": "yyyy-MM-dd'T'HH:mm",
                "outputFormat": "dd",
                "input": {
                    "type": "dateMath",
                    "attributes": {
                        "expression": "now"
                    }
                }
            }
        },
        "currentDayOfWeek": {
            "type": "dateFormat",
            "attributes": {
                "inputFormat": "yyyy-MM-dd'T'HH:mm",
                "outputFormat": "u",
                "input": {
                    "type": "dateMath",
                    "attributes": {
                        "expression": "now"
                    }
                }
            }
        },
        "currentUtcHour": {
            "type": "dateFormat",
            "attributes": {
                "inputFormat": "yyyy-MM-dd'T'HH:mm",
                "outputFormat": "HH",
                "input": {
                    "type": "dateMath",
                    "attributes": {
                        "expression": "now"
                    }
                }
            }
        },
        "startMonth": "3",
        "startSunday": "2",
        "startUtcHour": "7",
        "endMonth": "11",
        "endSunday": "1",
        "endUtcHour": "6",
        "stdOffset": "-5",
        "dstOffset": "-4",
        "value": "#set($number = 1)#set($m = $number.parseInt($currentMonth))#set($d = $number.parseInt($currentDay))#set($dw = $number.parseInt($currentDayOfWeek))#set($uh = $number.parseInt($currentUtcHour))#set($sM = $number.parseInt($startMonth))#set($sS = $number.parseInt($startSunday))#set($sH = $number.parseInt($startUtcHour))#set($eM = $number.parseInt($endMonth))#set($eS = $number.parseInt($endSunday))#set($eH = $number.parseInt($endUtcHour))#set($lastSun = $d - $dw)#set($isDST = false)#if($sM < $eM) #if($m > $sM && $m < $eM) #set($isDST = true) #elseif($m == $sM) #set($startBound = ($sS * 7) - 6) #if($lastSun > $startBound || ($lastSun == $startBound && $uh >= $sH)) #set($isDST = true) #end #elseif($m == $eM) #set($endBound = ($eS * 7) - 6) #if($lastSun < $endBound || ($lastSun == $endBound && $uh < $eH)) #set($isDST = true) #end #end#{else} #if($m > $sM || $m < $eM) #set($isDST = true) #elseif($m == $sM) #set($startBound = ($sS * 7) - 6) #if($lastSun > $startBound || ($lastSun == $startBound && $uh >= $sH)) #set($isDST = true) #end #elseif($m == $eM) #set($endBound = ($eS * 7) - 6) #if($lastSun < $endBound || ($lastSun == $endBound && $uh < $eH)) #set($isDST = true) #end #end #end #if($isDST) $dstOffset #{else} $stdOffset #end"
    }
}

The formatted velocity code:

#set($number = 1)
#set($m = $number.parseInt($currentMonth))
#set($d = $number.parseInt($currentDay))
#set($dw = $number.parseInt($currentDayOfWeek))
#set($uh = $number.parseInt($currentUtcHour))
#set($sM = $number.parseInt($startMonth))
#set($sS = $number.parseInt($startSunday))
#set($sH = $number.parseInt($startUtcHour))
#set($eM = $number.parseInt($endMonth))
#set($eS = $number.parseInt($endSunday))
#set($eH = $number.parseInt($endUtcHour))
#set($lastSun = $d - $dw)
#set($isDST = false)
#if($sM < $eM)
 #if($m > $sM && $m < $eM)
  #set($isDST = true)
 #elseif($m == $sM)
  #set($startBound = ($sS * 7) - 6)
  #if($lastSun > $startBound || ($lastSun == $startBound && $uh >= $sH))
   #set($isDST = true)
  #end #elseif($m == $eM)
  #set($endBound = ($eS * 7) - 6)
  #if($lastSun < $endBound || ($lastSun == $endBound && $uh < $eH))
   #set($isDST = true)
  #end
 #end
#{else}
 #if($m > $sM || $m < $eM)
  #set($isDST = true)
 #elseif($m == $sM)
  #set($startBound = ($sS * 7) - 6)
  #if($lastSun > $startBound || ($lastSun == $startBound && $uh >= $sH))
   #set($isDST = true)
  #end
 #elseif($m == $eM)
  #set($endBound = ($eS * 7) - 6)
  #if($lastSun < $endBound || ($lastSun == $endBound && $uh < $eH))
   #set($isDST = true)
  #end
 #end
#end

#if($isDST)
  $dstOffset
#{else}
 $stdOffset
#end

From Gemini:

Mind you though, this definitely is, IMO, a bit of an over-engineered solution just to get dynamic timezone offset. Depending on the scale of your deployment / population, they may / may not be a suitable / efficient solution.

Thanks for the suggestion. I’ve now requested the timezoneUST attribute from the HR team, and they’ve provided it. I’m currently working on implementing the transform using this attribute. Once the transform is completed and validated, I’ll post the final solution here for reference.