Expire member role assignments
Xperience by Kentico provides member roles for restricting access to secured content, but member role assignments don’t include a built-in expiration mechanism. If your application requires time-limited access – for example, trial memberships, subscription tiers, or seasonal content – you need to implement a custom solution.
Key points
- A custom object type tracks role assignment date and time.
- A scheduled task periodically removes expired member role bindings.
- You can use the same approach to manipulate member roles based on other data.
Define a custom expiration object type
To add expiration support, define a custom object type with the following fields:
|
Field |
Type |
Description |
|
|
Integer |
Primary key |
|
|
Integer |
Foreign key to |
|
|
Integer |
Foreign key to |
|
|
DateTime |
Date and time when the member role assignment expires |
This object type is separate from the MemberRoleMemberInfo binding – it doesn’t replace the member role assignment but records when the assignment should be revoked.
Create the object type in the admin UI
First, create the corresponding object type definition in the Xperience administration. This registers the object type in the system and creates its database table.
- Open the Modules application in the Xperience administration.
- Create or select a custom module for your implementation.
- Switch to the Classes tab.
- Create a new class, for example
MemberRoleExpiration. - Switch to the Database columns tab and add all required columns.
- Save your changes.
Xperience creates the database table and registers the object type.
Generate the class code file
Generate a code file for the created class.
dotnet run -- --kxp-codegen --type "Classes" --include "MyProject.MemberRoleExpiration"
Consider placing your custom code in a dedicated assembly following our customization best practices.
Implement the expiration scheduled task
Scheduled tasks are classes that implement the IScheduledTask interface.
The role expiration task queries MemberRoleExpirationInfo for entries past their expiration date, removes the corresponding MemberRoleMemberInfo bindings, and cleans up the processed expiration records.
The task’s Execute method receives a ScheduledTaskConfigurationInfo parameter with task metadata. It processes each expired entry individually to ensure proper cache invalidation and event handling.
/// <summary>
/// Removes expired member role assignments.
/// Queries <see cref="MemberRoleExpirationInfo"/> for entries past their expiration
/// date, deletes the corresponding <see cref="MemberRoleMemberInfo"/> bindings,
/// and cleans up the processed expiration records.
/// </summary>
public class RoleExpirationTask : IScheduledTask
{
/// <summary>
/// Unique identifier for the scheduled task registration.
/// </summary>
public const string IDENTIFIER = "Codesamples.ScheduledTask.RoleExpiration";
private readonly IInfoProvider<MemberRoleExpirationInfo> expirationProvider;
private readonly IInfoProvider<MemberRoleMemberInfo> memberRoleMemberProvider;
/// <summary>
/// Initializes a new instance of the <see cref="RoleExpirationTask"/> class.
/// </summary>
/// <param name="expirationProvider">Provider for <see cref="MemberRoleExpirationInfo"/> management.</param>
/// <param name="memberRoleMemberProvider">Provider for <see cref="MemberRoleMemberInfo"/> management.</param>
public RoleExpirationTask(
IInfoProvider<MemberRoleExpirationInfo> expirationProvider,
IInfoProvider<MemberRoleMemberInfo> memberRoleMemberProvider)
{
this.expirationProvider = expirationProvider;
this.memberRoleMemberProvider = memberRoleMemberProvider;
}
/// <summary>
/// Finds expired role assignments and removes the corresponding member-role bindings.
/// </summary>
/// <param name="task">Container with task information.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ScheduledTaskExecutionResult> Execute(
ScheduledTaskConfigurationInfo task,
CancellationToken cancellationToken)
{
try
{
var expiredEntries = (await expirationProvider.Get()
.WhereLessThan(nameof(MemberRoleExpirationInfo.ExpiresAt), DateTime.UtcNow)
.GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
.ToList();
foreach (var expiration in expiredEntries)
{
// Finds the member-role binding matching the expiration record
var binding = (await memberRoleMemberProvider.Get()
.WhereEquals(
nameof(MemberRoleMemberInfo.MemberRoleMemberMemberID),
expiration.MemberId)
.WhereEquals(
nameof(MemberRoleMemberInfo.MemberRoleMemberMemberRoleID),
expiration.MemberRoleId)
.TopN(1)
.GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
.FirstOrDefault();
if (binding is not null)
{
// Deletes the binding individually to ensure cache invalidation and event handling
await memberRoleMemberProvider.DeleteAsync(binding, cancellationToken);
}
// Removes the processed expiration tracking record
await expirationProvider.DeleteAsync(expiration, cancellationToken);
}
return ScheduledTaskExecutionResult.Success;
}
catch (Exception ex)
{
return new ScheduledTaskExecutionResult(ex.Message);
}
}
}
DateTime handling
Xperience schedules tasks use server-local time (DateTime.Now) to determine when tasks execute. Your task logic can use any time representation. The example above stores and compares ExpiresAt against DateTime.UtcNow.
Whichever convention you choose, be consistent. If you store ExpiresAt in UTC but compare it against local time (or vice versa), the offset between the server’s time zone and UTC shifts expiration by up to several hours – roles may expire too early or linger beyond their intended duration.
Register and configure the scheduled task
Register the task using the RegisterScheduledTask assembly attribute with a unique identifier matching the IDENTIFIER constant defined in your task class.
[assembly: RegisterScheduledTask(
RoleExpirationTask.IDENTIFIER,
typeof(RoleExpirationTask))]
And configure the task in the Xperience administration:
- Open the Scheduled tasks application.
- Select New scheduled task configuration.
- Set the Task implementation to match the
IDENTIFIERconstant in your task class (e.g.,MyProject.ScheduledTask.RoleExpiration). - Configure the execution interval based on how frequently you want expired member roles to be cleaned up (e.g., daily).
- Select Save.
The scheduled task is now registered and runs at the configured interval. For more details on creating and configuring scheduled tasks, see Scheduled tasks.
Assign member roles with expiration
When you assign a member role with an expiration date, you need to create two records: the standard MemberRoleMemberInfo binding (which grants the member role) and a MemberRoleExpirationInfo record (which tracks when to revoke it).
The following example creates both records in a single operation:
/// <summary>
/// Assigns a member to a role and creates an expiration tracking record.
/// </summary>
/// <param name="memberId">The ID of the member to assign.</param>
/// <param name="roleId">The ID of the member role.</param>
/// <param name="expiresAt">The date and time when the role assignment expires.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AssignRoleWithExpirationAsync(
int memberId,
int roleId,
DateTime expiresAt,
CancellationToken cancellationToken = default)
{
// Wrap both writes in a transaction so that a failure creating the expiration
// record does not leave behind a role assignment with no expiration tracking.
using var transaction = new CMSTransactionScope();
// Creates the member-role binding
var binding = new MemberRoleMemberInfo
{
MemberRoleMemberMemberID = memberId,
MemberRoleMemberMemberRoleID = roleId
};
await memberRoleMemberProvider.SetAsync(binding, cancellationToken);
// Creates a separate expiration tracking record
// Store ExpiresAt in UTC -- the scheduled task compares against DateTime.UtcNow
var expiration = new MemberRoleExpirationInfo
{
MemberId = memberId,
MemberRoleId = roleId,
ExpiresAt = expiresAt
};
await expirationProvider.SetAsync(expiration, cancellationToken);
transaction.Commit();
}
The member now holds the member role and has a corresponding expiration record. When the ExpiresAt date passes, the scheduled task automatically removes the role binding.
Renew member role expiration
To extend an existing member role assignment, update the ExpiresAt value on the corresponding MemberRoleExpirationInfo record – for example, adding 30 days when a subscription renews:
/// <summary>
/// Extends an existing role expiration by updating the <see cref="MemberRoleExpirationInfo.ExpiresAt"/> value.
/// </summary>
/// <param name="memberId">The ID of the member whose role to renew.</param>
/// <param name="roleId">The ID of the member role to renew.</param>
/// <param name="newExpiresAt">The new expiration date and time.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task RenewRoleExpirationAsync(
int memberId,
int roleId,
DateTime newExpiresAt,
CancellationToken cancellationToken = default)
{
var expiration = (await expirationProvider.Get()
.WhereEquals(nameof(MemberRoleExpirationInfo.MemberId), memberId)
.WhereEquals(nameof(MemberRoleExpirationInfo.MemberRoleId), roleId)
.TopN(1)
.GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
.FirstOrDefault();
if (expiration is not null)
{
expiration.ExpiresAt = newExpiresAt;
await expirationProvider.SetAsync(expiration, cancellationToken);
}
}
The method queries for the existing expiration record by MemberId and MemberRoleId, then updates the expiration date. The member role assignment remains active until the new ExpiresAt date passes.
Enforce content access
Since the scheduled task runs at configured intervals (e.g., once per day), there’s a delay between when a member role actually expires and when the binding is removed. Additionally, active sessions retain cached role claims in the ClaimsPrincipal until the member re-authenticates. This means a member can continue accessing secured content for a period after their member role expires.
Depending on how strict you want to be when revoking access, use one of the following approaches when enforcing content access.
Basic approach
In the basic approach, only the scheduled task is responsible for removing expired member role assignments. The built-in HasAccess method checks whether the content is secured and whether the current user holds one of the required member roles. Once the scheduled task removes the expired MemberRoleMemberInfo binding, subsequent access checks deny the member.
This approach doesn’t require any additional code beyond the scheduled task and works well when a delay of up to one task execution interval (e.g., 24 hours) is acceptable.
Precise approach
When precise expiration is important, add a query-time check against MemberRoleExpirationInfo alongside the scheduled task. This verifies the role assignment at the time of each content access request.
The following method checks whether a member’s role assignment has expired by querying the custom object type:
/// <summary>
/// Checks whether a member's role assignment has expired by querying
/// the <see cref="MemberRoleExpirationInfo"/> custom object.
/// </summary>
/// <param name="memberId">The ID of the member.</param>
/// <param name="roleId">The ID of the member role.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><c>true</c> if the role assignment has expired; otherwise, <c>false</c>.</returns>
public async Task<bool> IsRoleExpiredForMemberAsync(
int memberId,
int roleId,
CancellationToken cancellationToken = default)
{
// Queries for expiration records that have passed their ExpiresAt date
int expiredCount = await expirationProvider.Get()
.WhereEquals(nameof(MemberRoleExpirationInfo.MemberId), memberId)
.WhereEquals(nameof(MemberRoleExpirationInfo.MemberRoleId), roleId)
.WhereLessThan(nameof(MemberRoleExpirationInfo.ExpiresAt), DateTime.UtcNow)
.GetCountAsync(cancellationToken);
return expiredCount > 0;
}
Combine this check with the built-in HasAccess method to enforce both member role membership and expiration in a single access decision:
/// <summary>
/// Determines whether a member can access a secured content item by combining
/// the built-in role-based access check with precise expiration enforcement.
/// </summary>
/// <param name="contentItem">The content item to check access for.</param>
/// <param name="user">The current user's claims principal.</param>
/// <param name="memberId">The ID of the member.</param>
/// <param name="roleId">The ID of the member role to validate expiration for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><c>true</c> if the member has valid, non-expired access; otherwise, <c>false</c>.</returns>
public async Task<bool> HasNonExpiredAccessAsync(
IContentItemFieldsSource contentItem,
ClaimsPrincipal user,
int memberId,
int roleId,
CancellationToken cancellationToken = default)
{
// Checks the built-in role-based access using system fields
bool hasRoleAccess = contentItem.HasAccess(user);
if (!hasRoleAccess)
{
return false;
}
// Validates that the role assignment has not expired
bool isExpired = await IsRoleExpiredForMemberAsync(
memberId, roleId, cancellationToken);
return !isExpired;
}
The scheduled task still runs alongside this approach to clean up expired records and remove stale member role bindings from the database.