Permission Set Expiration
This skill activates when access has an end date: contractor and vendor grants, on-call elevation, audit-season read access, a migration cutover role, or any permission a security review has agreed must not become standing privilege. It covers where the expiration is stored, what the platform does and does not take away when the date passes, which assignment types cannot carry an expiry at all, and how to audit the population before it silently drifts back to permanent.
Before Starting
Gather this context before working on anything in this domain:
- Is the expiration UI turned on in this org?
UserManagementSettings.psaExpirationUIEnabled is documented as "Indicates if admins can use an updated user interface that includes an assignment expiration for permission sets and permission set groups (true) or not (false). The default value is false." It is available in API version 52.0 and later. If nobody has turned it on, an admin clicking through Setup will not see an expiration control and will conclude the feature does not exist.
- Is the permission granted anywhere else? The Salesforce Security Guide is blunt about this: "To revoke a permission, you must remove all instances of the permission from the user." An expiring assignment closes one grant path. If the profile or a second permission set also enables the permission, the user keeps it after the date passes and the elevation was never time-boxed at all.
- Which object holds the grant?
PermissionSetAssignment carries ExpirationDate; PermissionSetLicenseAssign does not, and does not even support update(). If the elevation depends on a permission set license, no expiry exists for it.
- Are user access policies enabled? With them on,
IsRevoked, LastCreatedByChangeId, and LastDeletedByChangeId appear on the assignment (API version 57.0 and later) and a policy-driven revocation leaves the assignment row in place rather than deleting it. Audit queries written for a plain org return the wrong population in a policy org.
Core Concepts
The expiry is a field on the assignment row
PermissionSetAssignment "represents a user's assignment to a permission set or permission set group" and is available in API version 22.0 and later. Its supported calls are create(), delete(), describeSObjects(), query(), retrieve(), and update(). Access to the object requires View Setup and Configuration, Assign Permission Sets, or Manage User.
| Field |
Type — Properties |
What it means for time-boxing |
ExpirationDate |
dateTime — Create, Filter, Nillable, Sort, Update |
"The date that the assignment of the permission set or permission set group expires for the specified user." API version 52.0 and later. Nillable, so an assignment with no end date is the same row with this field null. |
IsActive |
boolean — Defaulted on create, Filter, Group, Sort |
"Indicates whether the assignment is active (true) or not (false). Defaults to false." API version 52.0 and later. Not createable and not updateable — the platform sets it. Filter on it; never try to write it. |
IsRevoked |
boolean — Defaulted on create, Filter, Group, Sort, Update |
"Indicates whether the assignment was revoked (true) or not (false)." Present only when user access policies are enabled. API version 57.0 and later. |
AssigneeId |
reference (User) — Create, Filter, Group, Sort |
No Update. Retargeting an assignment to a different user is a delete plus an insert. |
PermissionSetId |
reference — Create, Filter, Group, Nillable, Sort |
No Update. Swapping which permission set is granted is a delete plus an insert. |
PermissionSetGroupId |
reference — Create, Filter, Group, Nillable, Sort |
No Update. API version 45.0 and later. One row grants the whole group. |
LastCreatedByChangeId / LastDeletedByChangeId |
reference (UserAccessChange) |
Populated only when user access policies are enabled. API version 57.0 and later. UserAccessChange.Source carries, "for example, UserAccessPolicyId." |
The Object Reference's blanket instruction — "To update an assignment, delete an existing assignment and insert a new one" — is about the Create-only fields above. ExpirationDate is the exception: it is explicitly updateable, which is why extending an expiry is a one-field update, not a re-assignment. See references/gotchas.md Gotcha 2 for what the delete-and-insert route costs you.
Expiry closes one door, not the room
Expiry ends this assignment. It does not audit the rest of the user's access. A permission that is also on the profile, on a second permission set, or inside a permission set group the user is still assigned to survives the date unchanged. Time-boxing therefore only works when the elevated permission lives in exactly one place — which is the whole argument for a narrow, single-purpose elevation permission set rather than hanging an expiry on a broad job-function group.
What can and cannot carry an expiration date
| Grant mechanism |
Expiry supported? |
Evidence |
| Permission set assignment |
Yes |
PermissionSetAssignment.ExpirationDate, API version 52.0 and later |
| Permission set group assignment |
Yes |
Same field, same row — the description names "the permission set or permission set group" |
| Permission set license assignment |
No |
PermissionSetLicenseAssign has no ExpirationDate, and its supported calls are create(), delete(), describeSObjects(), query(), retrieve() — no update() |
| Profile |
No |
A profile is a lookup on the User record; there is no assignment object to hang a date on |
| Muting permission set |
No |
MutingPermissionSet "is used in conjunction with PermissionSetGroup" — it is a component of a group, never assigned to a user, so there is no assignment row |
| Public group or queue membership |
No |
GroupMember has exactly two fields, GroupId and UserOrGroupId, and does not support update() |
| Package license |
No |
No expiration field on the assignment; user access policies can Grant or Revoke a PackageLicense, but with no date attached |
Turning on the Setup control
The toggle lives on the User Management Settings Setup page, and it is deployable. UserManagementSettings values are stored in UserManagement.settings; in the package manifest all org settings types are referenced by the name Settings.
<!-- force-app/main/default/settings/UserManagement.settings-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<UserManagementSettings xmlns="http://soap.sforce.com/2006/04/metadata">
<psaExpirationUIEnabled>true</psaExpirationUIEnabled>
<userAccessPoliciesEnabled>true</userAccessPoliciesEnabled>
</UserManagementSettings>
<!-- manifest/package.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>UserManagement</members>
<name>Settings</name>
</types>
<version>67.0</version>
</Package>
There is only one settings file per settings component, so retrieve the org's existing UserManagement.settings and add the element rather than deploying the two-line file above over the top of whatever else is configured there. userAccessPoliciesEnabled is available in API version 58.0 and later and also defaults to false; include it only if the org has actually adopted policies.
User Access Policies assign; they do not hold the clock
A user access policy is the supported no-code way to make assignments from user criteria — UserAccessPolicy is available in API version 57.0 and later and requires the Manage User Access Policies permission. But the action shape is deliberately small. UserAccessPolicyAction has exactly three fields: action (Grant or Revoke), target, and type (Group, PackageLicense, PermissionSet, PermissionSetGroup, PermissionSetLicense, Queue). There is no expiration attribute anywhere in the policy metadata.
The consequence is architectural: a policy can express "everyone in this role gets this group" and "everyone who leaves this role loses it," but it cannot express "for ninety days." Criteria-driven revocation and date-driven expiry are two different controls. Use admin/user-access-policies for the first; use ExpirationDate for the second. A policy-driven revoke sets IsRevoked = true and leaves the row behind rather than deleting it, so ALL ROWS is required to see the revoked population — a date expiry and a policy revocation are two different terminal states and one audit query will not find both.
Common Patterns
Pattern: Single-Purpose Elevation Permission Set
When to use: A named individual needs one dangerous capability — Manage Users, an integration-user permission, an object's Delete — for a bounded period, and the org's job-function permission sets are too broad to expire safely.
How it works:
- Build a permission set that contains only the elevated capability. Name it for the elevation, not for the person.
- Assign it to the individual with
ExpirationDate set to the agreed end instant.
- Verify the capability is not simultaneously granted by the assignee's profile or by any other assigned permission set — if it is, the expiry buys nothing.
- Record the approver and the business justification alongside the assignment; the platform stores the date, not the reason.
- Let the date pass. Do not build a job to remove the assignment.
Why not hang the expiry on the job-function group: the group carries the user's normal, permanent access as well. When it expires, the user loses their day job, not just the elevation. Expiring a group assignment is correct only when the entire group is the temporary grant — a project role, a migration cutover role, an audit-window reader.
Pattern: Renewal by Update, Not Re-Assignment
When to use: A contractor's engagement is extended and the assignment is close to expiring, or already inactive.
How it works: ExpirationDate is updateable. Query the assignment by AssigneeId and PermissionSetId, then update the single field with the new instant.
// Extend one contractor's elevation by another quarter.
PermissionSetAssignment psa = [
SELECT Id, ExpirationDate, IsActive
FROM PermissionSetAssignment
WHERE AssigneeId = :contractorId
AND PermissionSet.Name = 'PS_Temp_ManageUsers'
LIMIT 1
];
psa.ExpirationDate = newEndInstant;
update psa;
Why not delete and re-insert: a new row resets the assignment's creation audit and breaks any reporting keyed on the original grant. It also risks a window where the user has no assignment at all if the insert fails. Re-insert only when the target changes — a different user, a different permission set, a different group — because those fields are Create-only.
Pattern: Expiry-Aware Access Review
When to use: A quarterly or SOX-style access review must show which elevations are live, which are about to lapse, and which already have.
How it works: three queries, run together, because no single one covers the population.
-- 1. Live elevations with a scheduled end, next 30 days first.
-- IsActive = true is load-bearing: without it, NEXT_N_DAYS:30 also
-- returns every assignment that already lapsed.
SELECT Id, Assignee.Name, Assignee.Username, PermissionSet.Name,
PermissionSetGroup.DeveloperName, ExpirationDate, IsActive
FROM PermissionSetAssignment
WHERE IsActive = true
AND ExpirationDate != NULL
AND ExpirationDate <= NEXT_N_DAYS:30
ORDER BY ExpirationDate ASC
-- 2. Standing privilege: elevation permission sets assigned with NO end date.
SELECT Id, Assignee.Name, PermissionSet.Name, ExpirationDate
FROM PermissionSetAssignment
WHERE ExpirationDate = NULL
AND PermissionSet.Name LIKE 'PS_Temp_%'
-- 3. Policy-revoked rows, which are NOT deleted and NOT returned without ALL ROWS.
SELECT Id, ExpirationDate, Assignee.Name, PermissionSet.Name
FROM PermissionSetAssignment
WHERE IsRevoked = true ALL ROWS
Query 2 is the one that finds the failure. Every org that adopts time-boxing accumulates elevation permission sets assigned without a date, because the API path makes ExpirationDate optional and nillable and nothing rejects the insert.
Decision Guidance
| Situation |
Recommended approach |
Reason |
| One person needs one capability for a fixed window |
Single-purpose permission set + ExpirationDate |
Narrow grant means expiry actually revokes something |
| A whole project role is temporary |
Permission set group assignment + ExpirationDate |
One row expires the entire aggregate grant |
| Access should end when a user attribute changes, not on a date |
User access policy with a Revoke action |
Policy actions have no date; criteria are the trigger. See admin/user-access-policies |
| Access must end the moment the session does |
Session-based permission set |
Tighter than any wall-clock date. Note: session-based permission sets included in a permission set group do not require activation for users assigned to the group |
| The elevation depends on a permission set license |
No expiry exists — build a manual removal step |
PermissionSetLicenseAssign has no ExpirationDate and no update() |
| The permission is also on the profile |
Fix the profile first, then time-box |
Expiry cannot remove a grant it does not own |
| An engagement is extended |
update the ExpirationDate field |
The field is updateable; delete-and-insert is only needed for Create-only fields |
| Reporting must show elevations that already ended |
Filter IsActive = false and add ALL ROWS |
The state, not the row's existence, is what changes. ALL ROWS is correct whether or not lapsed rows are hidden — see references/gotchas.md Gotcha 3 |
| You are tempted to write a nightly job that unassigns |
Use ExpirationDate |
A scheduled job fails silently and leaves standing privilege behind — the exact outcome the control exists to prevent |
Recommended Workflow
Step-by-step instructions for an AI agent or practitioner working on this task:
- Confirm the org can express an expiry. Check
UserManagementSettings for psaExpirationUIEnabled. If it is false and the admin will work through Setup, turn it on first — the metadata snippet above is deployable. Also record whether userAccessPoliciesEnabled is on, because it changes which fields exist on the assignment.
- Locate every grant of the target permission. Enumerate the assignee's profile, all assigned permission sets, and all assigned permission set groups. If the elevated permission appears more than once, either narrow the grant or accept that the expiry is decorative and say so out loud.
- Choose the unit of expiry. A single-purpose permission set for an individual capability; a permission set group only when the entire group is the temporary grant. Never attach an expiry to a permission set that also carries the user's day-to-day access.
- Set
ExpirationDate as an explicit instant. The field is a dateTime. Through the API, write a full timestamp so the cutoff is unambiguous; through Setup, confirm what clock time and time zone the org's assignment screen applies before you promise an auditor a cutoff time. Do not assume the two paths resolve to the same moment.
- Record the decision outside the platform record. The assignment stores a date, not an approver or a justification. Capture both in the request ticket or in the workbook produced by
templates/permission-set-expiration-template.md.
- Verify the expiry landed and schedule the review. Re-query the assignment and confirm
ExpirationDate is populated. Run the three access-review queries above and confirm the new row appears in query 1 and not in query 2. Read references/gotchas.md before writing any reporting that assumes an expired assignment disappears.
- Run the checker script —
python3 skills/admin/permission-set-expiration/scripts/check_permission_set_expiration.py --plan <plan.json> to validate the assignment plan's field shape before anyone loads it.
Review Checklist
Run through these before marking a time-boxed elevation complete:
Salesforce-Specific Gotchas
Non-obvious platform behaviours that cause real production problems. references/gotchas.md carries ten of these in full, with the mechanism and the recovery; items 1-4 and 6 below summarise five of them, and item 5 is stated in full here.
- The Setup control is off by default —
psaExpirationUIEnabled defaults to false, so an admin can work through the assignment screens and never see an expiration option while the API field has existed since API version 52.0.
- The two Setup paths are not equivalent — the Security Guide's expiration step appears in the permission-set-side Manage Assignments → Add Assignments flow, not in the user-record-side Edit Assignments procedure. Admins who always start from the user record will not find it.
- Liveness is a state, not the row's existence —
IsActive is the platform-owned, read-only flag that carries it. Any report or integration that treats "an assignment row exists" as "the user has access" reads a lapsed elevation as live.
- Setup Audit Trail still labels the expiration-date entry beta — in the Summer '26 Security Guide the tracked change reads "Permission set (or group) changes to the assignment expiration date (beta)". Confirm the entry appears in your org before relying on it as audit evidence.
- The bulk Setup flow caps removal at 1,000 users at a time — relevant when an expiry experiment has to be unwound by hand across a large population.
- Guest, integration, and system users are not in the Manage Assignments UI — "Certain types of users, such as guest, Self-Service, integration, and system users, aren't available in the Manage Assignments page. To view or manage these users, use the
PermissionSetAssignment API object." Time-boxing an integration user's elevation is an API-only operation.
Output Artifacts
| Artifact |
Description |
| Time-boxed assignment plan |
Permission set or group, assignee, ExpirationDate instant, approver, justification, review date |
| Access-review query set |
The three SOQL queries covering expiring-soon, no-end-date, and policy-revoked populations |
UserManagement.settings-meta.xml |
Deployable settings file enabling psaExpirationUIEnabled |
| Renewal procedure |
Single-field update on ExpirationDate, with the delete-and-insert cases called out |
| Checker script output |
Field-shape validation of the assignment plan before it is loaded |
Related Skills
admin/permission-set-group-composition — decides what goes inside a permission set group; read it before choosing a group as the unit of expiry
security/permission-set-groups-and-muting — muting mechanics; a muting permission set is a group component and can never carry an expiry
admin/user-access-policies — criteria-driven grant and revoke; the automated assignment path that has no date attribute
admin/permission-set-architecture — how to shape a single-purpose elevation permission set narrow enough that expiring it means something
security/privileged-access-management — the surrounding control: approval, break-glass, session-based alternatives, and the audit trail
admin/user-management — deactivation and offboarding, which is the other half of not leaving standing access behind
1---2name: permission-set-expiration3description: Use when an assignment must expire on a date, not stand forever. Trigger keywords: permission set expiration date, permission set group expiry, temporary access, time-boxed elevation, contractor access, ExpirationDate. NOT for PSG composition - use admin/permission-set-group-composition. NOT for muting mechanics - use security/permission-set-groups-and-muting. NOT for criteria-based auto-assignment - use admin/user-access-policies.4---56# Permission Set Expiration78This skill activates when access has an end date: contractor and vendor grants, on-call elevation, audit-season read access, a migration cutover role, or any permission a security review has agreed must not become standing privilege. It covers where the expiration is stored, what the platform does and does not take away when the date passes, which assignment types cannot carry an expiry at all, and how to audit the population before it silently drifts back to permanent.910---1112## Before Starting1314Gather this context before working on anything in this domain:1516- **Is the expiration UI turned on in this org?** `UserManagementSettings.psaExpirationUIEnabled` is documented as "Indicates if admins can use an updated user interface that includes an assignment expiration for permission sets and permission set groups (true) or not (false). The default value is `false`." It is available in API version 52.0 and later. If nobody has turned it on, an admin clicking through Setup will not see an expiration control and will conclude the feature does not exist.17- **Is the permission granted anywhere else?** The Salesforce Security Guide is blunt about this: "To revoke a permission, you must remove all instances of the permission from the user." An expiring assignment closes one grant path. If the profile or a second permission set also enables the permission, the user keeps it after the date passes and the elevation was never time-boxed at all.18- **Which object holds the grant?** `PermissionSetAssignment` carries `ExpirationDate`; `PermissionSetLicenseAssign` does not, and does not even support `update()`. If the elevation depends on a permission set *license*, no expiry exists for it.19- **Are user access policies enabled?** With them on, `IsRevoked`, `LastCreatedByChangeId`, and `LastDeletedByChangeId` appear on the assignment (API version 57.0 and later) and a policy-driven revocation leaves the assignment row in place rather than deleting it. Audit queries written for a plain org return the wrong population in a policy org.2021---2223## Core Concepts2425### The expiry is a field on the assignment row2627`PermissionSetAssignment` "represents a user's assignment to a permission set or permission set group" and is available in API version 22.0 and later. Its supported calls are `create()`, `delete()`, `describeSObjects()`, `query()`, `retrieve()`, and `update()`. Access to the object requires View Setup and Configuration, Assign Permission Sets, or Manage User.2829| Field | Type — Properties | What it means for time-boxing |30|---|---|---|31| `ExpirationDate` | `dateTime` — Create, Filter, Nillable, Sort, **Update** | "The date that the assignment of the permission set or permission set group expires for the specified user." API version 52.0 and later. Nillable, so an assignment with no end date is the same row with this field null. |32| `IsActive` | `boolean` — Defaulted on create, Filter, Group, Sort | "Indicates whether the assignment is active (`true`) or not (`false`). Defaults to `false`." API version 52.0 and later. Not createable and not updateable — the platform sets it. Filter on it; never try to write it. |33| `IsRevoked` | `boolean` — Defaulted on create, Filter, Group, Sort, Update | "Indicates whether the assignment was revoked (`true`) or not (`false`)." Present only when user access policies are enabled. API version 57.0 and later. |34| `AssigneeId` | `reference` (User) — Create, Filter, Group, Sort | No Update. Retargeting an assignment to a different user is a delete plus an insert. |35| `PermissionSetId` | `reference` — Create, Filter, Group, Nillable, Sort | No Update. Swapping which permission set is granted is a delete plus an insert. |36| `PermissionSetGroupId` | `reference` — Create, Filter, Group, Nillable, Sort | No Update. API version 45.0 and later. One row grants the whole group. |37| `LastCreatedByChangeId` / `LastDeletedByChangeId` | `reference` (UserAccessChange) | Populated only when user access policies are enabled. API version 57.0 and later. `UserAccessChange.Source` carries, "for example, `UserAccessPolicyId`." |3839The Object Reference's blanket instruction — "To update an assignment, delete an existing assignment and insert a new one" — is about the Create-only fields above. `ExpirationDate` is the exception: it is explicitly updateable, which is why extending an expiry is a one-field update, not a re-assignment. See `references/gotchas.md` Gotcha 2 for what the delete-and-insert route costs you.4041### Expiry closes one door, not the room4243Expiry ends *this assignment*. It does not audit the rest of the user's access. A permission that is also on the profile, on a second permission set, or inside a permission set group the user is still assigned to survives the date unchanged. Time-boxing therefore only works when the elevated permission lives in exactly one place — which is the whole argument for a narrow, single-purpose elevation permission set rather than hanging an expiry on a broad job-function group.4445### What can and cannot carry an expiration date4647| Grant mechanism | Expiry supported? | Evidence |48|---|---|---|49| Permission set assignment | **Yes** | `PermissionSetAssignment.ExpirationDate`, API version 52.0 and later |50| Permission set group assignment | **Yes** | Same field, same row — the description names "the permission set or permission set group" |51| Permission set **license** assignment | **No** | `PermissionSetLicenseAssign` has no `ExpirationDate`, and its supported calls are `create()`, `delete()`, `describeSObjects()`, `query()`, `retrieve()` — no `update()` |52| Profile | **No** | A profile is a lookup on the User record; there is no assignment object to hang a date on |53| Muting permission set | **No** | `MutingPermissionSet` "is used in conjunction with `PermissionSetGroup`" — it is a component of a group, never assigned to a user, so there is no assignment row |54| Public group or queue membership | **No** | `GroupMember` has exactly two fields, `GroupId` and `UserOrGroupId`, and does not support `update()` |55| Package license | **No** | No expiration field on the assignment; user access policies can Grant or Revoke a `PackageLicense`, but with no date attached |5657### Turning on the Setup control5859The toggle lives on the **User Management Settings** Setup page, and it is deployable. `UserManagementSettings` values are stored in `UserManagement.settings`; in the package manifest all org settings types are referenced by the name `Settings`.6061```xml62<!-- force-app/main/default/settings/UserManagement.settings-meta.xml -->63<?xml version="1.0" encoding="UTF-8"?>64<UserManagementSettings xmlns="http://soap.sforce.com/2006/04/metadata">65 <psaExpirationUIEnabled>true</psaExpirationUIEnabled>66 <userAccessPoliciesEnabled>true</userAccessPoliciesEnabled>67</UserManagementSettings>68```6970```xml71<!-- manifest/package.xml -->72<?xml version="1.0" encoding="UTF-8"?>73<Package xmlns="http://soap.sforce.com/2006/04/metadata">74 <types>75 <members>UserManagement</members>76 <name>Settings</name>77 </types>78 <version>67.0</version>79</Package>80```8182There is only one settings file per settings component, so retrieve the org's existing `UserManagement.settings` and add the element rather than deploying the two-line file above over the top of whatever else is configured there. `userAccessPoliciesEnabled` is available in API version 58.0 and later and also defaults to `false`; include it only if the org has actually adopted policies.8384### User Access Policies assign; they do not hold the clock8586A user access policy is the supported no-code way to *make* assignments from user criteria — `UserAccessPolicy` is available in API version 57.0 and later and requires the **Manage User Access Policies** permission. But the action shape is deliberately small. `UserAccessPolicyAction` has exactly three fields: `action` (`Grant` or `Revoke`), `target`, and `type` (`Group`, `PackageLicense`, `PermissionSet`, `PermissionSetGroup`, `PermissionSetLicense`, `Queue`). **There is no expiration attribute anywhere in the policy metadata.**8788The consequence is architectural: a policy can express "everyone in this role gets this group" and "everyone who leaves this role loses it," but it cannot express "for ninety days." Criteria-driven revocation and date-driven expiry are two different controls. Use `admin/user-access-policies` for the first; use `ExpirationDate` for the second. A policy-driven revoke sets `IsRevoked = true` and leaves the row behind rather than deleting it, so `ALL ROWS` is required to see the revoked population — a date expiry and a policy revocation are two different terminal states and one audit query will not find both.8990---9192## Common Patterns9394### Pattern: Single-Purpose Elevation Permission Set9596**When to use:** A named individual needs one dangerous capability — Manage Users, an integration-user permission, an object's Delete — for a bounded period, and the org's job-function permission sets are too broad to expire safely.9798**How it works:**991. Build a permission set that contains only the elevated capability. Name it for the elevation, not for the person.1002. Assign it to the individual with `ExpirationDate` set to the agreed end instant.1013. Verify the capability is not simultaneously granted by the assignee's profile or by any other assigned permission set — if it is, the expiry buys nothing.1024. Record the approver and the business justification alongside the assignment; the platform stores the date, not the reason.1035. Let the date pass. Do not build a job to remove the assignment.104105**Why not hang the expiry on the job-function group:** the group carries the user's normal, permanent access as well. When it expires, the user loses their day job, not just the elevation. Expiring a group assignment is correct only when the entire group is the temporary grant — a project role, a migration cutover role, an audit-window reader.106107### Pattern: Renewal by Update, Not Re-Assignment108109**When to use:** A contractor's engagement is extended and the assignment is close to expiring, or already inactive.110111**How it works:** `ExpirationDate` is updateable. Query the assignment by `AssigneeId` and `PermissionSetId`, then update the single field with the new instant.112113```apex114// Extend one contractor's elevation by another quarter.115PermissionSetAssignment psa = [116 SELECT Id, ExpirationDate, IsActive117 FROM PermissionSetAssignment118 WHERE AssigneeId = :contractorId119 AND PermissionSet.Name = 'PS_Temp_ManageUsers'120 LIMIT 1121];122psa.ExpirationDate = newEndInstant;123update psa;124```125126**Why not delete and re-insert:** a new row resets the assignment's creation audit and breaks any reporting keyed on the original grant. It also risks a window where the user has no assignment at all if the insert fails. Re-insert only when the *target* changes — a different user, a different permission set, a different group — because those fields are Create-only.127128### Pattern: Expiry-Aware Access Review129130**When to use:** A quarterly or SOX-style access review must show which elevations are live, which are about to lapse, and which already have.131132**How it works:** three queries, run together, because no single one covers the population.133134```soql135-- 1. Live elevations with a scheduled end, next 30 days first.136-- IsActive = true is load-bearing: without it, NEXT_N_DAYS:30 also137-- returns every assignment that already lapsed.138SELECT Id, Assignee.Name, Assignee.Username, PermissionSet.Name,139 PermissionSetGroup.DeveloperName, ExpirationDate, IsActive140FROM PermissionSetAssignment141WHERE IsActive = true142 AND ExpirationDate != NULL143 AND ExpirationDate <= NEXT_N_DAYS:30144ORDER BY ExpirationDate ASC145146-- 2. Standing privilege: elevation permission sets assigned with NO end date.147SELECT Id, Assignee.Name, PermissionSet.Name, ExpirationDate148FROM PermissionSetAssignment149WHERE ExpirationDate = NULL150 AND PermissionSet.Name LIKE 'PS_Temp_%'151152-- 3. Policy-revoked rows, which are NOT deleted and NOT returned without ALL ROWS.153SELECT Id, ExpirationDate, Assignee.Name, PermissionSet.Name154FROM PermissionSetAssignment155WHERE IsRevoked = true ALL ROWS156```157158Query 2 is the one that finds the failure. Every org that adopts time-boxing accumulates elevation permission sets assigned without a date, because the API path makes `ExpirationDate` optional and nillable and nothing rejects the insert.159160---161162## Decision Guidance163164| Situation | Recommended approach | Reason |165|---|---|---|166| One person needs one capability for a fixed window | Single-purpose permission set + `ExpirationDate` | Narrow grant means expiry actually revokes something |167| A whole project role is temporary | Permission set group assignment + `ExpirationDate` | One row expires the entire aggregate grant |168| Access should end when a user attribute changes, not on a date | User access policy with a `Revoke` action | Policy actions have no date; criteria are the trigger. See `admin/user-access-policies` |169| Access must end the moment the session does | Session-based permission set | Tighter than any wall-clock date. Note: session-based permission sets included in a permission set group do not require activation for users assigned to the group |170| The elevation depends on a permission set *license* | No expiry exists — build a manual removal step | `PermissionSetLicenseAssign` has no `ExpirationDate` and no `update()` |171| The permission is also on the profile | Fix the profile first, then time-box | Expiry cannot remove a grant it does not own |172| An engagement is extended | `update` the `ExpirationDate` field | The field is updateable; delete-and-insert is only needed for Create-only fields |173| Reporting must show elevations that already ended | Filter `IsActive = false` and add `ALL ROWS` | The state, not the row's existence, is what changes. `ALL ROWS` is correct whether or not lapsed rows are hidden — see `references/gotchas.md` Gotcha 3 |174| You are tempted to write a nightly job that unassigns | Use `ExpirationDate` | A scheduled job fails silently and leaves standing privilege behind — the exact outcome the control exists to prevent |175176---177178## Recommended Workflow179180Step-by-step instructions for an AI agent or practitioner working on this task:1811821. **Confirm the org can express an expiry.** Check `UserManagementSettings` for `psaExpirationUIEnabled`. If it is `false` and the admin will work through Setup, turn it on first — the metadata snippet above is deployable. Also record whether `userAccessPoliciesEnabled` is on, because it changes which fields exist on the assignment.1832. **Locate every grant of the target permission.** Enumerate the assignee's profile, all assigned permission sets, and all assigned permission set groups. If the elevated permission appears more than once, either narrow the grant or accept that the expiry is decorative and say so out loud.1843. **Choose the unit of expiry.** A single-purpose permission set for an individual capability; a permission set group only when the entire group is the temporary grant. Never attach an expiry to a permission set that also carries the user's day-to-day access.1854. **Set `ExpirationDate` as an explicit instant.** The field is a `dateTime`. Through the API, write a full timestamp so the cutoff is unambiguous; through Setup, confirm what clock time and time zone the org's assignment screen applies before you promise an auditor a cutoff time. Do not assume the two paths resolve to the same moment.1865. **Record the decision outside the platform record.** The assignment stores a date, not an approver or a justification. Capture both in the request ticket or in the workbook produced by `templates/permission-set-expiration-template.md`.1876. **Verify the expiry landed and schedule the review.** Re-query the assignment and confirm `ExpirationDate` is populated. Run the three access-review queries above and confirm the new row appears in query 1 and not in query 2. Read `references/gotchas.md` before writing any reporting that assumes an expired assignment disappears.1887. **Run the checker script** — `python3 skills/admin/permission-set-expiration/scripts/check_permission_set_expiration.py --plan <plan.json>` to validate the assignment plan's field shape before anyone loads it.189190---191192## Review Checklist193194Run through these before marking a time-boxed elevation complete:195196- [ ] `psaExpirationUIEnabled` state confirmed in the target org and recorded in the change ticket197- [ ] The elevated permission is granted by exactly one assignment — profile and all other permission sets checked198- [ ] The expiring assignment does not also carry the user's normal job access199- [ ] `ExpirationDate` is populated, and the instant is written down in a stated time zone200- [ ] The approver and business justification are recorded outside the assignment row201- [ ] The elevation permission set is named for the elevation, not for the individual202- [ ] The access-review queries return the new assignment in the "expiring soon" set and nothing unexpected in the "no end date" set203- [ ] Reporting that counts assignments filters on `IsActive`, uses `ALL ROWS` when hunting lapsed rows, and covers `IsRevoked` separately if user access policies are enabled204- [ ] No scheduled Flow or Apex job was created to duplicate what `ExpirationDate` already does205- [ ] Setup Audit Trail is being exported on a cadence shorter than 180 days, since setup entity records are deleted after that206207---208209## Salesforce-Specific Gotchas210211Non-obvious platform behaviours that cause real production problems. `references/gotchas.md` carries ten of these in full, with the mechanism and the recovery; items 1-4 and 6 below summarise five of them, and item 5 is stated in full here.2122131. **The Setup control is off by default** — `psaExpirationUIEnabled` defaults to `false`, so an admin can work through the assignment screens and never see an expiration option while the API field has existed since API version 52.0.2142. **The two Setup paths are not equivalent** — the Security Guide's expiration step appears in the permission-set-side *Manage Assignments → Add Assignments* flow, not in the user-record-side *Edit Assignments* procedure. Admins who always start from the user record will not find it.2153. **Liveness is a state, not the row's existence** — `IsActive` is the platform-owned, read-only flag that carries it. Any report or integration that treats "an assignment row exists" as "the user has access" reads a lapsed elevation as live.2164. **Setup Audit Trail still labels the expiration-date entry beta** — in the Summer '26 Security Guide the tracked change reads "Permission set (or group) changes to the assignment expiration date (beta)". Confirm the entry appears in your org before relying on it as audit evidence.2175. **The bulk Setup flow caps removal at 1,000 users at a time** — relevant when an expiry experiment has to be unwound by hand across a large population.2186. **Guest, integration, and system users are not in the Manage Assignments UI** — "Certain types of users, such as guest, Self-Service, integration, and system users, aren't available in the Manage Assignments page. To view or manage these users, use the `PermissionSetAssignment` API object." Time-boxing an integration user's elevation is an API-only operation.219220---221222## Output Artifacts223224| Artifact | Description |225|---|---|226| Time-boxed assignment plan | Permission set or group, assignee, `ExpirationDate` instant, approver, justification, review date |227| Access-review query set | The three SOQL queries covering expiring-soon, no-end-date, and policy-revoked populations |228| `UserManagement.settings-meta.xml` | Deployable settings file enabling `psaExpirationUIEnabled` |229| Renewal procedure | Single-field `update` on `ExpirationDate`, with the delete-and-insert cases called out |230| Checker script output | Field-shape validation of the assignment plan before it is loaded |231232---233234## Related Skills235236- `admin/permission-set-group-composition` — decides what goes *inside* a permission set group; read it before choosing a group as the unit of expiry237- `security/permission-set-groups-and-muting` — muting mechanics; a muting permission set is a group component and can never carry an expiry238- `admin/user-access-policies` — criteria-driven grant and revoke; the automated assignment path that has no date attribute239- `admin/permission-set-architecture` — how to shape a single-purpose elevation permission set narrow enough that expiring it means something240- `security/privileged-access-management` — the surrounding control: approval, break-glass, session-based alternatives, and the audit trail241- `admin/user-management` — deactivation and offboarding, which is the other half of not leaving standing access behind