Security
PowerPortalsPro provides a flexible, code-driven security model that lets you control access at both the table level and the record level. Security is implemented by creating permission handler classes and registering them in the dependency injection container.
Overview
There are two interfaces you can implement depending on the level of control you need:
ITablePermissionHandler— Controls whether a user can perform Create, Read, Update, Delete, Append, or AppendTo operations on a given table. Decisions are based solely on the user's identity.ITableRecordPermissionHandler— ExtendsITablePermissionHandlerwith record-level overloads that receive the actualTableRecordbeing operated on, enabling fine-grained rules such as "users can only edit their own records."
Tip
If you only need table-level checks (e.g. "authenticated users can read this table"), use
ITablePermissionHandler. If you need record-level logic (e.g. "users can only delete records they own"), useITableRecordPermissionHandler.
Best practice — filter at the source
Permission handlers enforce access, but they are not a substitute for filtering. Record-level handlers run after a page of records is retrieved from Dataverse, so relying on them to hide rows distorts grid paging (a page of 50 can show fewer) and the total count, and wastes queries fetching rows that are then discarded. Whenever data must be scoped to the current user, filter at the query level instead — with an
IFetchXmlQueryFilterInterceptor(see below) or a custom view that accounts for the current user — so Dataverse returns only the rows the user should see and grids page accurately. Keep the permission handlers as your enforcement guarantee.
Dataverse-Native Security (Preview)
The recommended security model going forward lets Dataverse itself enforce security for your portal users, built on Power Pages' unified Dataverse security feature. Instead of writing permission handlers in code, you manage access with standard Dataverse security roles — the same roles, privileges, and admin tooling used for internal users. The model is currently in preview — both Microsoft's feature and Power Portals Pro's support for it — and Power Portals Pro will follow Microsoft's release schedule to general availability.
When enabled, PowerPortalsPro automatically provisions a system-managed (C2) systemuser for each portal contact — at registration for new contacts, and lazily at sign-in for existing ones — together with the powerpagesusermapping record that links the contact, the system user, and your Power Pages site. Every Dataverse call for the session then impersonates that system user, so the security roles Power Pages syncs from your web roles and table permissions govern exactly what the user can see and do. The session carries a PortalUserType of ContactStubUser so your UI can always distinguish these provisioned users from real internal users.
Note
To use this security model you must create a Power Pages website in your Dataverse environment — this is what installs the Microsoft framework components and lets you enable the unified-security feature. You do not need a paid Power Pages plan, and you can delete the website itself immediately after installing and enabling the feature. The Power Pages site record (
powerpagesite) referenced byPowerPagesSiteIdmust exist in the environment, since user mappings and security-role sync resolve against it.
Enabling Dataverse-Native Security
Enable the model through SecurityOptions in your server's Program.cs, supplying the id of your Power Pages site record:
builder.Services.Configure<SecurityOptions>(options =>
{
options.DataverseSecurity.ProvisionSystemUsers = true;
options.DataverseSecurity.PowerPagesSiteId = new Guid("<your powerpagesite id>");
});
The configuration is validated at application startup: when ProvisionSystemUsers is enabled, PowerPagesSiteId must be set and must resolve to an existing powerpagesite record, otherwise the application fails to start with guidance.
Grant access exactly the way Power Pages does: configure web roles and table permissions in the Power Pages Management app, and assign web roles to contacts (through the Management app or programmatically). The platform syncs those into auto-generated security roles (named PowerPages_*) and assigns them automatically as web-role membership changes — because the configuration lives entirely in Power Pages, your portal can move to or from an actual Power Pages site without any friction. Note that role and privilege changes can take from a few seconds up to a couple of minutes to propagate through Dataverse's caches before they take effect.
Follow the step-by-step setup guide
Getting Started
1. Create a Permission Handler
Create a class that implements ITablePermissionHandler or ITableRecordPermissionHandler. Set the Table property to the logical name of the Dataverse table the handler applies to, and implement each permission method.
Here is a simple example that grants full access to all users for a specific table:
public class MyTablePermissionHandler : ITablePermissionHandler
{
public string Table => "my_customtable";
public Task<bool> CanCreateAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanReadAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanUpdateAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanDeleteAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanAppendAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanAppendToAsync(Guid? userId) => Task.FromResult(true);
}
2. Register in Dependency Injection
Register your handler in the DI container in your project's Program.cs file (or a service collection extension method):
builder.Services.AddSingleton<ITablePermissionHandler, MyTablePermissionHandler>();
Since handlers are resolved from the DI container, you can inject any required services (such as a database context or user service) through constructor injection.
Record-Level Security
For scenarios where access depends on the specific record being operated on, implement ITableRecordPermissionHandler. This interface extends ITablePermissionHandler with overloads that receive the TableRecord.
The following example allows all users to read records globally, but restricts write operations to records owned by the current user:
public class ContactPermissionHandler : ITableRecordPermissionHandler
{
public string Table => "contact";
public List<string> RequiredColumns => [];
// Table-level: allow access broadly
public Task<bool> CanReadAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanCreateAsync(Guid? userId)
=> Task.FromResult(userId != null && userId != Guid.Empty);
// Record-level: restrict writes to the record owner
public Task<bool> CanUpdateAsync(
Guid? userId, TableRecord recordWithUpdates, TableRecord currentRecord)
{
return Task.FromResult(userId == currentRecord.Id);
}
public Task<bool> CanDeleteAsync(Guid? userId, TableRecord record)
{
return Task.FromResult(userId == record.Id);
}
// ... implement remaining methods
}
Note
The
CanUpdateAsyncrecord-level overload provides both the record with the proposed updates (recordWithUpdates) and the record in its currently persisted state (currentRecord), allowing you to compare values or validate specific field changes.
Required Columns
The RequiredColumns property specifies which columns must be retrieved for the handler to evaluate record-level permissions. The framework automatically adds these columns to FetchXML queries when they are not already included, ensuring the handler always has the data it needs — even if the view or query does not include those columns.
The following example uses the ppp_owningportaluserid lookup column to restrict access to records owned by the current user:
public class RegionPermissionHandler : ITableRecordPermissionHandler
{
public string Table => "ppp_region";
public List<string> RequiredColumns => ["ppp_owningportaluserid"];
public Task<bool> CanReadAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanCreateAsync(Guid? userId) => Task.FromResult(userId != null);
public Task<bool> CanReadAsync(Guid? userId, TableRecord record)
{
return Task.FromResult(
record.GetValueOrDefault<LookupValue>("ppp_owningportaluserid")?.Value == userId);
}
// ... implement remaining methods
}
Note
If
RequiredColumnsreturns an empty list and the handler's record-level methods only userecord.Id, no additional columns are added to the query. Theownerproperty is always provided by the platform regardless ofRequiredColumns.
FetchXML Query Filter Interceptors
In addition to table and record-level security, PowerPortalsPro supports IFetchXmlQueryFilterInterceptor — an interface that lets you modify FetchXML queries before they are executed. This is useful for enforcing row-level filtering, such as ensuring a user can only see records that belong to them or their team.
IFetchXmlQueryFilterInterceptor extends ITablePermissionHandler, so each interceptor also defines table-level permissions via the Table property and the standard permission methods. The interceptor's OnQueryAsync method is only called for queries that target its specified table.
Unlike record-level permission handlers that evaluate each record after retrieval, query filter interceptors modify the query before it reaches Dataverse. This means filtered-out records are never retrieved, which improves both performance and security.
1. Create a Query Filter Interceptor
Implement IFetchXmlQueryFilterInterceptor, set the Table property to the logical name of the table, implement the permission methods, and use the FetchXMLBuilder in OnQueryAsync to add filters to the query. The FetchXmlParameters record provides the current user's EntityReference.
FetchXMLBuilder
The following example restricts queries on the contact table to only return the record matching the current user:
public class ContactQueryFilterInterceptor : IFetchXmlQueryFilterInterceptor
{
public string Table => "contact";
public Task<bool> CanCreateAsync(Guid? userId) => Task.FromResult(false);
public Task<bool> CanReadAsync(Guid? userId) => Task.FromResult(true);
public Task<bool> CanUpdateAsync(Guid? userId) => Task.FromResult(false);
public Task<bool> CanDeleteAsync(Guid? userId) => Task.FromResult(false);
public Task<bool> CanAppendAsync(Guid? userId) => Task.FromResult(false);
public Task<bool> CanAppendToAsync(Guid? userId) => Task.FromResult(false);
public Task<FetchXMLBuilder> OnQueryAsync(
FetchXMLBuilder fetchXmlBuilder, FetchXmlParameters parameters)
{
var filter = fetchXmlBuilder.Fetch.Entity.AddFilter();
var condition = filter.AddCondition();
condition.Column = "contactid";
condition.Operator = ConditionOperator.Equal;
condition.Value = parameters.CurrentUser.Id.ToString();
return Task.FromResult(fetchXmlBuilder);
}
}
2. Register in Dependency Injection
Register your interceptor as both an IFetchXmlQueryFilterInterceptor and an ITablePermissionHandler so that both the query filtering and table-level permissions are applied. Multiple interceptors can be registered and each will be executed for queries targeting its table.
builder.Services.AddTransient<ContactQueryFilterInterceptor>();
builder.Services.AddTransient<ITablePermissionHandler>(
sp => sp.GetRequiredService<ContactQueryFilterInterceptor>());
builder.Services.AddTransient<IFetchXmlQueryFilterInterceptor>(
sp => sp.GetRequiredService<ContactQueryFilterInterceptor>());
Tip
Because
IFetchXmlQueryFilterInterceptorextendsITablePermissionHandler, a single class handles both table-level permissions and query-level filtering. TheOnQueryAsyncmethod is only invoked for queries matching the interceptor'sTableproperty, so there is no need to check the table name inside the method.
Built-in Base Classes
The framework provides base classes to simplify common security patterns:
AnonymousPermissionHandler— Grants all operations to all users (including anonymous). Useful for publicly accessible tables. Simply inherit and set theTableproperty.
API Reference
ITablePermissionHandler Interface
Properties
Name | Type | Default | Description |
|---|---|---|---|
Table | string | Logical name of the table this permission handler applies to. |
TableMethods
Name | Parameters | Type | Description |
|---|---|---|---|
CanAppendAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to append a record. |
CanAppendToAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to append to a record. |
CanCreateAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to create a record. |
CanDeleteAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to delete a record. |
CanReadAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to read a record. |
CanUpdateAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to update a record. |
CanAppendAsyncCanAppendToAsyncCanCreateAsyncCanDeleteAsyncCanReadAsyncCanUpdateAsyncITableRecordPermissionHandler Interface
Properties
Name | Type | Default | Description |
|---|---|---|---|
RequiredColumns | List<string> | List of column logical names that must be retrieved for this handler to evaluate record-level permissions. | |
Table | string | Logical name of the table this permission handler applies to. |
RequiredColumnsTableMethods
Name | Parameters | Type | Description |
|---|---|---|---|
CanAppendAsync | Guid? userId TableRecord record | Task<bool> | Method to determine whether a user should be able to append the provided record. |
CanAppendToAsync | Guid? userId TableRecord record | Task<bool> | Method to determine whether a user should be able to append to the provided record. |
CanCreateAsync | Guid? userId TableRecord record | Task<bool> | Method to determine whether a user should be able to create the provided record. |
CanDeleteAsync | Guid? userId TableRecord record | Task<bool> | Method to determine whether a user should be able to delete the provided record. |
CanReadAsync | Guid? userId TableRecord record | Task<bool> | Method to determine whether a user should be able to read the provided record. |
CanUpdateAsync | Guid? userId TableRecord recordWithUpdates TableRecord currentRecord | Task<bool> | Method to determine whether a user should be able to update the provided record. |
CanAppendAsyncTableRecord record
CanAppendToAsyncTableRecord record
CanCreateAsyncTableRecord record
CanDeleteAsyncTableRecord record
CanReadAsyncTableRecord record
CanUpdateAsyncTableRecord recordWithUpdates
TableRecord currentRecord
IFetchXmlQueryFilterInterceptor Interface
Properties
Name | Type | Default | Description |
|---|---|---|---|
Table | string | Logical name of the table this permission handler applies to. |
TableMethods
Name | Parameters | Type | Description |
|---|---|---|---|
CanAppendAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to append a record. |
CanAppendToAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to append to a record. |
CanCreateAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to create a record. |
CanDeleteAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to delete a record. |
CanReadAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to read a record. |
CanUpdateAsync | Guid? userId | Task<bool> | Method to determine whether a user should be able to update a record. |
OnQueryAsync | FetchXMLBuilder fetchXmlBuilder FetchXmlParameters parameters | Task<FetchXMLBuilder> | Called to allow modification of a FetchXML query before it is executed. |
CanAppendAsyncCanAppendToAsyncCanCreateAsyncCanDeleteAsyncCanReadAsyncCanUpdateAsyncOnQueryAsyncFetchXmlParameters parameters
