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.
Tip
You do not have to write this from a blank page. Generated projects ship a
secure-a-tableskill: ask your coding agent to secure a table and it works through which table, who it is for, which rows, which operations and which columns, then writes the registration and makes the matching changes in Dataverse through the Power Portals Pro MCP server — creating the portal role if the access is role-bound, and granting the application user the privileges the answers imply. Afterwards,check_setupreports any table your pages bind to that nothing grants access for, and any registration that opts out of column security. See Getting Started → MCP Server.
Everyone, or Only Some People
Every registration says who it is for, and there are two answers. Register it against the portal and it applies to an audience your code names — everybody, or every signed-in user. Register it inside AddPortalRole and it applies to the holders of that role, which is a data decision an administrator makes in Dataverse. The access itself is described the same way in both.
| Registration | Applies to | To change who gets it |
|---|---|---|
AddTableAccess("account", t => t.ForSignedInUsers()…) |
everyone the audience covers, on every request | edit the code and redeploy |
AddPortalRole("Credit Manager", role => role.WithTableAccess("account", …)) |
contacts holding that Portal Role, and nobody else | assign or unassign the role in Dataverse — no deployment |
A table can be registered once for each portal-wide audience — ForEveryone() for what anybody may reach, ForSignedInUsers() for what signing in adds — and the two add up. A visitor gets the first; a signed-in user gets both, with each operation confined to the rows of the registration that grants it. With the two registrations below, everybody reads every article, and a signed-in author can also write one and edit their own:
builder.Services.AddTableAccess("ppp_article", t => t
.ForEveryone()
.AllRows()
.Allow(TableSecurityPermission.Read)
.Reading("ppp_title", "ppp_body"));
builder.Services.AddTableAccess("ppp_article", t => t
.ForSignedInUsers()
.RowsOwnedByContact("ppp_authorid")
.Allow(TableSecurityPermission.Create | TableSecurityPermission.Write)
.Writing("ppp_title", "ppp_body"));
Roles add; they never subtract. A user's access is the portal-wide registrations plus every role they hold, unioned — so the portal-wide one is the floor everybody stands on, and a role can only raise it. A role cannot take back a column or a row that the portal-wide registration granted; to withhold something from most people, grant it in a role rather than portal-wide. (Set SecurityOptions.Roles.CompositionBehavior to Restrictive if you need every group to agree instead.)
So: if every user of that kind should have the access, register it against the portal. If only some should — and which people is a question whose answer changes without a release — put it in a role. A role registration grants nothing until somebody creates a Portal Role record of that name and assigns it, which is why one can sit in a fresh project harmlessly.
Describing a Table's Access
Most tables want the same few rules: who the table is for, which of its rows they reach, what they may do, and which columns they see. AddTableAccess says all four in one registration and produces an ordinary ITablePermissionProvider — nothing downstream knows the difference. What it buys is that the three decisions which have to agree (the filter, the create, and the writable columns) are made once instead of three times.
builder.Services.AddTableAccess("incident", t => t
.ForSignedInUsers()
.RowsOwnedByContact("customerid")
.Allow(TableSecurityPermission.Read | TableSecurityPermission.Create)
.Reading("ticketnumber", "statuscode", "createdon")
.Writing("title", "description"));
An audience, a row scope and Allow(…) are all required. None of them has a default, because every plausible default is a way to publish data by leaving something out — and a registration that gets one wrong fails at startup rather than in production.
Row Scopes
The scope filters every query for the table — grids, lookups, subgrids, charts, and the fetch before a save or a delete. The named scopes cover the shapes portals keep needing:
| Which rows | Scope | On create |
|---|---|---|
| Every row | AllRows() |
nothing set |
| The user's own records | RowsOwnedByContact("customerid") |
the lookup is set to them |
| Their colleagues' records, at the same account | RowsOfTheirAccount("customerid") |
the lookup is set to their account |
| Records hanging off a parent they can reach | RowsViaParent("salesorderid", "salesorder", p => …) |
nothing set — the parent's own access decides |
| Records that point at them in more than one way | RowsMatchingAny(r => r.RowsOwnedByContact("customerid"), r => r.RowsOfTheirAccount("customerid")) |
what the first ownership scope listed sets |
| Their own contact row | OwnRecordOnly() |
no create |
The ownership scopes set the column themselves when a record is created, after the permission checks: the user needs no write access to it and no Append To on the table it points at, the value comes from the session rather than the request, and a later save that would change it is refused. That is what keeps a record somebody creates inside the filter they are subject to. Use OwnedBy('column') when the owning column is not the one the rows are filtered by, and WithoutStamping() when a plugin already sets it.
When Reading and Changing Differ
One scope answers every operation unless you say otherwise. RowsForWriting narrows the rows a user may change without narrowing the rows they may see — “everyone reads every contact, each edits only their own” is that distinction and nothing else — and RowsForDeleting narrows destroying further still, because being allowed to correct a record is not the same as being allowed to destroy it. Each falls back to the one above it, so a registration that draws no distinction draws none. Creates are unaffected: there is no row yet to filter.
builder.Services.AddTableAccess("contact", t => t
.ForSignedInUsers()
.AllRows() // reads: every contact
.RowsForWriting(r => r.OwnRecordOnly()) // updates and deletes: their own row
.Allow(TableSecurityPermission.Read | TableSecurityPermission.Write)
.ReadingAllColumns());
When the Named Scopes Don't Fit
RowsMatching takes a filter of your own without leaving the registration. Give it FetchXML — a <filter> element or a whole <fetch> whose filters and joins are copied, with {currentUserId} for the signed-in contact — or a callback that runs per request and returns either the FetchXML, composed from whatever your services answer, or a filter it built. Both receive the same TableQueryContext a provider gets, so they know who is asking and whether the rows are about to be read, updated or deleted. Returning nothing grants no rows: TableQueryFilter.AllRows is how you say every row.
.RowsMatching("<filter><condition attribute='ppp_owner' operator='eq' value='{currentUserId}' /></filter>")
.RowsMatching(async (context, services) =>
{
var territories = services.GetRequiredService<ITerritoryService>();
var filter = context.CreateFilter();
filter.AddCondition("ppp_territory", ConditionOperator.In, await territories.ForContactAsync(context.User.Id));
return filter;
})
Describing a Role's Access
Inside AddPortalRole the role is the audience, so the registration names no other. Roles add and never subtract: a user's access is the portal-wide registrations plus every role they hold, unioned. Portal roles themselves — what they are, how they are assigned, and how they compose — are the next section.
builder.Services.AddPortalRole("Case Manager", role => role
.WithTableAccess("incident", t => t
.RowsOfTheirAccount("customerid")
.Allow(TableSecurityPermission.Read | TableSecurityPermission.Write)
.Reading("ticketnumber", "statuscode")
.Writing("title", "description")));
Note
A provider class remains the way to express anything a description cannot, and it composes identically —
AddTablePermissionProvider<T>andWithPermissions<T>are unchanged. The generated projects no longer ship one: every table they register is described, includingcontactand the Credit Manager role.
Portal Roles
A portal role is the preferred way to secure a portal. It binds permission providers to a name that administrators assign to contacts in Dataverse, so the providers apply only to the people holding that role — and who holds it is data, not code. A provider registered directly in Program.cs applies to every user by comparison.
The split is deliberate. Dataverse owns the names and the assignments, which an administrator manages in the model-driven app. Your code owns what each name means. Granting or revoking someone's access is then a data change: no rebuild, no redeploy.
1. Create the role and assign it
Create a Portal Role record and assign it to the contacts who should have it. The role record holds nothing but a name and a description — no permission data lives in Dataverse.
2. Bind the role to its providers
Match the role record's name and list what it grants. The provider types are registered for you, so you don't register them yourself.
builder.Services.AddPortalRole("Dealer", role => role
.WithPermissions<DealerAccountPermissionProvider>()
.WithPermissions<DealerOpportunityPermissionProvider>());
That last point matters: never also register a role's provider with AddTablePermissionProvider<T>(). That puts it in the global set and grants it to everybody, silently defeating the role. Reserve AddTablePermissionProvider<T>() for rules that genuinely apply to every user.
How roles combine
A user's permissions come from the globally registered providers plus each role they hold, and those groups fold together like this:
- Across roles — additive. If any role grants an operation, the user has it. Holding more roles can only ever give more access, so a role can never take something away. Set
SecurityOptions.Roles.CompositionBehaviortoRestrictiveif you want roles to narrow each other instead. - Within one role — unchanged. Several providers in the same role fold under
SecurityOptions.MultiplePermissionHandlersBehavior, exactly as globally registered providers always have. - Columns add up too. Because column access is opt-in, a role that never mentions a column cannot widen what another role published — but a role that names a column does grant it, because naming it was a deliberate act. Switch
RoleCompositionBehaviortoRestrictiveand both operations and columns intersect instead.
Tip
Under additive composition nothing denies — something stays closed only while no group opens it. A globally registered provider is the right home for a rule that applies to every user, because it is always in the fold, but it is not a veto: a role that grants the operation, or names the column, reopens it. To keep something closed whatever roles a user holds, grant it in no role at all — or set
SecurityOptions.Roles.CompositionBehaviortoRestrictive, where every group has to agree.
Roles in pages and navigation
Role names reach the browser as standard ASP.NET role claims, so page authorization and conditional UI work without anything extra:
@attribute [Authorize(Roles = "Dealer")]
<AuthorizeView Roles="Dealer">
<p>Dealer-only content</p>
</AuthorizeView>
The React stack reads the same names from auth.user.roles, and <AuthorizedView roles="Dealer"> gates content the same way. Internal (system user) sign-ins carry their Dataverse security-role names here instead, so one check covers both kinds of user.
Authorizing on the right kind of role
A portal role and a Dataverse security role can share a name, and [Authorize(Roles = "…")] cannot tell them apart — it matches the untyped role claim, which carries both. Two attributes ask the narrower question:
@attribute [AuthorizePortalRole("Dealer")]
// Either role gets in.
[AuthorizePortalRole("Dealer", "Distributor")]
public class PricingController : Controller { }
// A portal role named "System Administrator" does not satisfy this.
[AuthorizeSecurityRole("System Administrator")]
public class ImportController : Controller { }
Role names go in verbatim: commas, colons and quotes are all fine, because the name is never parsed out of a list. Stacking two attributes requires both, the way stacked [Authorize] attributes always have; listing two names in one attribute requires either. The same check is available in code as User.IsInPortalRole("Dealer") and User.IsInSecurityRole("Sales Manager").
The same distinction in Blazor markup. <AuthorizeView Roles="…"> reads the untyped role claim and so carries the same ambiguity as the plain attribute; <PortalRoleView> asks for one kind. Give it PortalRole / SecurityRole for a single name, or PortalRoles / SecurityRoles for several — a collection rather than a comma-separated string, because Dataverse names routinely contain commas:
<PortalRoleView PortalRole="Dealer">
<a href="/pricing">Dealer pricing</a>
</PortalRoleView>
@* A portal role named "System Administrator" will not open this. *@
<PortalRoleView SecurityRole="System Administrator">
<BulkImport />
</PortalRoleView>
React's <AuthorizedView> takes the same two gates, alongside the existing roles one. Supplying several names in a gate means any of them; supplying two gates means both. The predicates behind them — hasPortalRole, hasSecurityRole, hasRole — are exported for imperative checks:
<AuthorizedView portalRoles="Dealer">
<Link to="/pricing">Dealer pricing</Link>
</AuthorizedView>
<AuthorizedView securityRoles="System Administrator">
<BulkImport />
</AuthorizedView>
Best practice — filter at the source
Reach for these whenever the thing being protected is genuinely administrative. Creating a portal role is a deliberately lower bar than being granted a Dataverse security role, so a check that accepts either lets an administrator hand out access the Dataverse role was guarding simply by naming a portal role after it.
Note
Roles are not free. Each one a user holds is written into the auth cookie twice — once as the standard role claim, so
[Authorize(Roles)]works, and once as the kind-specific claim that makes the checks above possible — which measures at about 160 bytes per role. Around fifteen roles per user still fit a single cookie; beyond that the cookie splits into chunks, and the first thing to give way is usually a reverse proxy's header limit, which reports an opaque 400 rather than anything mentioning roles. Prefer a few broad roles over many narrow ones.
Giving new users a starting role
Name the roles every new account should begin with, and the role that applies to signed-out visitors:
builder.Services.Configure<SecurityOptions>(options =>
{
options.Roles.DefaultRoleNames = ["Customer"];
options.Roles.AnonymousRoleName = "Anonymous";
});
Or decide it in Dataverse, without a release. The Portal Role Configuration table pairs a role with an assignment mode, and an administrator can change who gets what from the model-driven app:
- None — configured but dormant. The row grants nothing until somebody changes the mode.
- Assign To New Users Automatically — every account gets this role as it is created, provided both the configuration row and the role it names are active. Deactivating either stops it: a configuration row is a standing instruction to hand out a role, and a deactivated role grants nothing, so an active row naming an inactive role is an instruction with nothing behind it. Reactivating resumes it.
- Assign To New Users From Criteria — reserved for matching on the new user's own details. The criteria are not evaluated yet, so a row in this mode assigns the role to nobody rather than to everybody, and the portal logs a warning naming it so it doesn't read as a rule that ran and matched no one.
When the roles depend on the person — their email domain, the provider they signed in with, an invitation — register an IPortalRoleAssignmentProvider instead. Every registered provider is consulted, so yours adds to the configured list rather than replacing it. Either way assignment runs once, when the account is created; it is never re-applied at sign-in, so a role an administrator removes stays removed.
Note
A user picks up a newly assigned role by signing out and back in — signing in re-reads their roles. Role assignments are otherwise cached per user for 24 hours, so to apply a change to someone who is already signed in, clear the Portal Roles cache from the site administration area: their session picks it up when it next refreshes, within ten minutes, and a removed role stops working then just as an added one starts. The Portal Role Configuration table is cached separately for fifteen minutes, so a row added there reaches the next registration on its own — clear the "PortalRoleConfiguration" cache to make it immediate. Deactivating a configuration row stops it handing its role out, the same way deactivating a role revokes it.
Writing a provider instead
Reach for a class when a description cannot say what you mean — a rule that depends on something only your code knows, or one that has to inspect the record in flight. ITablePermissionProvider is what every registration becomes either way: one provider governs one table, answering what a user may do with it, which of its columns they may read and write, and — through the query filter below — which of its rows they can see at all. A described registration and a written one compose identically, so a portal can use both, and a table can be granted by one and widened by the other.
The interface has one required member. GetPermissionsAsync receives a UserInfo carrying the user's id, their portal roles and their Dataverse security roles, and returns a TablePermissions describing everything that user gets:
public class AccountPermissionProvider : ITablePermissionProvider
{
public string Table => "account";
public Task<TablePermissions> GetPermissionsAsync(UserInfo user)
{
if (!user.IsAuthenticated)
return Task.FromResult(TablePermissions.None);
// Columns are opt-in: only what is named here comes back.
return Task.FromResult(TablePermissions
.Allowing(TableSecurityPermission.Read | TableSecurityPermission.Write)
.Reading("name", "telephone1", "websiteurl", "creditlimit")
.Writing("telephone1", "websiteurl"));
}
}
Registering a provider
A provider registered this way applies to every user, signed in or not:
builder.Services.AddTablePermissionProvider<AccountPermissionProvider>();
This is also the only way to take something away from everybody: roles compose additively by default, so a role can add access but never remove it. Portal-wide restrictions belong in a globally registered provider, which is always part of the fold.
Operations
TablePermissions.Operations is a TableSecurityPermission flags value — Read, Create, Write, Delete, Append and AppendTo. Two things are worth knowing about it:
- It is a table-level gate, not a row filter. Grant
Readif the user may see any of the table's rows, then narrow which ones inGetQueryFilterAsync. - A table with no provider is unreachable. That is the fail-closed default, and it applies to columns as well: a provider that grants
Readbut names no columns returns records carrying nothing but their id.
Note
There is deliberately no "inspect each row after loading it" hook. Filtering that way breaks paging — a page of 50 comes back with fewer — makes total counts wrong, and pays Dataverse to retrieve rows it then throws away. Scoping the query instead keeps paging and counts correct by construction.
Best practice — filter at the source
Scope data in the query, not after it. The framework deliberately offers no hook that inspects each row once it has been fetched: filtering that way breaks paging (a page of 50 arrives short), makes the total count wrong, and pays Dataverse to retrieve rows it then discards.
GetQueryFilterAsyncadds the conditions before the query runs, so paging and counts stay correct by construction and the rows a user may not see are never retrieved at all.
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.
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 ContactPermissionProvider : ITablePermissionProvider
{
public string Table => "contact";
public Task<TablePermissions> GetPermissionsAsync(UserInfo user)
=> Task.FromResult(TablePermissions
.Allowing(TableSecurityPermission.Read | TableSecurityPermission.Write)
.ReadingAndWriting("firstname", "lastname", "telephone1"));
public Task<TableQueryFilter?> GetQueryFilterAsync(TableQueryContext context)
{
var filter = context.CreateFilter();
filter.AddCondition("contactid", ConditionOperator.Equal, context.CurrentUser.Id);
return Task.FromResult<TableQueryFilter?>(filter);
}
}
Reading, updating and deleting
TableQueryContext.Operation says why the rows are being fetched, so one provider can scope them differently for each:
Read— a grid, lookup, subgrid, chart or record load. The rows left are the rows the user may see.Update— the pre-flight fetch before a save. The rows left are the rows the user may change, which is usually a narrower set. "See every account, edit only mine" is this distinction and nothing else.Delete— the pre-flight fetch before a delete. Narrower again in most portals: being allowed to correct a record is not the same as being allowed to destroy it.
public Task<TableQueryFilter?> GetQueryFilterAsync(TableQueryContext context)
{
// Everyone reads everything; only writes are scoped to the owner.
if (context.Operation == TableQueryOperation.Read)
return Task.FromResult<TableQueryFilter?>(TableQueryFilter.AllRows);
var filter = context.CreateFilter();
filter.AddCondition("ownerid", ConditionOperator.Equal, context.CurrentUser.Id);
return Task.FromResult<TableQueryFilter?>(filter);
}
How several filters combine
A table can have more than one provider — a globally registered one, plus one from each role the user holds. The framework decides how their filters combine, so a provider never has to know what the others did:
- Within one group they intersect. Two providers registered globally, or two bound to the same role, are meant to agree, so a row has to satisfy both.
- Across roles they union. Holding another role widens the rows you see, matching how the operations compose. Switch
RoleCompositionBehaviortoRestrictiveand they intersect instead. - Joins are looked after. An alias that collides with one the query already uses is renamed and the filter's references follow it; when filters are being unioned, an inner join is relaxed to outer so one role's join can't exclude the rows another role allows; and a join that can return the same record more than once has those duplicates collapsed.
Note
Rows fail closed, like columns: a provider that returns
null— or a filter with no conditions — grants no rows, and within a group that empties the group. Every row has to be asked for withTableQueryFilter.AllRows. That is where the one asymmetry lies: the union of every row with anything is every row, so if any group that grants the table answersAllRows, the table comes back unfiltered however carefully the other roles scoped it. That is what additive composition means, but it is the one way a role's filter can look like it is being ignored.
Filtering through a related table
AddLink joins a table so the filter can test its columns. Put the test in the filter rather than on the link — conditions inside the filter keep working when the framework relaxes the join to outer, and conditions on the link would not:
public Task<TableQueryFilter?> GetQueryFilterAsync(TableQueryContext context)
{
var filter = context.CreateFilter(LogicalOperator.Or);
// The alias is made unique if the query already uses it.
filter.AddLink("ppp_membership", from: "ppp_accountid", to: "accountid", alias: "m");
filter.AddCondition("primarycontactid", ConditionOperator.Equal, context.CurrentUser.Id);
filter.AddCondition("m", "ppp_contactid", ConditionOperator.Equal, context.CurrentUser.Id);
return Task.FromResult<TableQueryFilter?>(filter);
}
Note
The join above reaches down to many rows, so Dataverse hands back the account once per membership — a grid repeating rows and a total that counts them. The framework recognises that shape and marks the query
distinct, projecting the table’s primary key so the de-duplication can only remove rows the join added. A join that reaches up instead, joining to the related table’s own primary key, matches at most one row and is left exactly as it was. Either way you write the filter and nothing else — but it is worth knowing a join is not free, and that a rule you can express as flat conditions on the table’s own columns is the cheaper shape.
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.
Who owns a new record
A row filter scopes reads, updates and deletes, but a create has no row to filter. So a table scoped to "rows I own" would still let a user create a row owned by somebody else — or by nobody, which its creator then cannot see. Declare the column that says who owns a record, and the framework sets it:
// An account belongs to the contact who creates it.
builder.Services.AddContactOwnership("account", "primarycontactid");
- On create the column is set to the signed-in contact after the permission checks, and any value the request sent is discarded. The user needs no write access to the column and no
AppendTooncontact: linking a record to yourself is not something they are being trusted with. - On update a request that would change the owner is refused, and one that repeats the stored owner is ignored.
- Internal users (systemuser sign-ins) are exempt: Dataverse enforces their security, and reassigning a record is part of administering it.
Pair it with a row filter on the same column, so the records a user creates are the records they can see. For ownership that isn't simply the creating contact — the creator's account, say — implement ITableRecordOwnershipProvider and register it with AddTableRecordOwnershipProvider<T>(). Don't set an owner in an ITableRecordInterceptor instead: interceptors run before the permission checks, so what they set is judged as the user's own write.
public sealed class CaseOwnershipProvider(IOrganizationServiceProvider organizationServiceProvider)
: ITableRecordOwnershipProvider
{
public string Table => "incident";
public IReadOnlyList<string> Columns { get; } = ["customerid"];
public async Task<IReadOnlyDictionary<string, LookupValue>> GetOwnersAsync(TableRecordOwnershipContext context)
{
// Read as the application user: which company someone belongs to is not the request's to say.
var contact = await organizationServiceProvider.GetOrganizationService()
.RetrieveAsync("contact", context.CurrentUser.Id, new ColumnSet("parentcustomerid"), context.CancellationToken);
var company = contact.GetAttributeValue<EntityReference>("parentcustomerid");
// File the case under their company, or under them when they have none.
var owner = company?.LogicalName == "account"
? new LookupValue { TableName = "account", Value = company.Id }
: new LookupValue { TableName = "contact", Value = context.CurrentUser.Id };
return new Dictionary<string, LookupValue> { ["customerid"] = owner };
}
}
// Program.cs
builder.Services.AddTableRecordOwnershipProvider<CaseOwnershipProvider>();
The framework calls it on every create of that table, after the permission checks, and sets each column in Columns to what it returns — discarding anything the request sent, and refusing a later update that would change it, exactly as for OwnedBy. Pair it with a row filter that matches what it sets, so the people it files a record under are the people who can see it — here RowsMatchingAny(r => r.RowsOfTheirAccount("customerid"), r => r.RowsOwnedByContact("customerid")), with WithoutStamping() so the registration leaves the column to this provider: two that set one column differently fail the save.
Column-Level Security
Column security is opt-in. A provider grants the columns it means to expose, and everything else — including a column somebody adds to the table next year — stays withheld until named. The record's primary id is always readable, since a record the framework cannot identify is of no use to anybody.
Reads and writes are independent sets: a column can be readable but not writable (the "show the credit limit, don't let them change it" case), or writable but not readable. Name them with Reading, Writing and ReadingAndWriting, or opt out of a dimension entirely with ReadingAllColumns / WritingAllColumns:
public Task<TablePermissions> GetPermissionsAsync(UserInfo user)
{
// Every column readable; two of them not writable.
return Task.FromResult(TablePermissions
.Allowing(TableSecurityPermission.Read | TableSecurityPermission.Write)
.ReadingAllColumns()
.WritingAllColumnsExcept("creditlimit", "creditonhold"));
}
Note
ReadingAllColumns()is deliberately something you have to type. It fails open for anything added to the table later, which is the right trade only when the table is genuinely public — so the framework will not assume it on your behalf.
Writing can also differ between creating a record and changing one. Writing covers both. WritingOnCreate lets the user set a column when they create the record and makes it read-only from then on — a case's priority, chosen when it is logged. WritingOnUpdate is the reverse: changeable on an existing record, not settable at the start. Both make the column readable too, like Writing, and both exist on TablePermissions for a provider of your own. They mirror the separate create and update permissions Dataverse's own field security grants:
builder.Services.AddTableAccess("incident", t => t
.ForSignedInUsers()
.RowsOwnedByContact("customerid")
.Allow(TableSecurityPermission.Read | TableSecurityPermission.Create | TableSecurityPermission.Write)
.Reading("ticketnumber", "statuscode")
.Writing("title", "description") // set when created, changeable after
.WritingOnCreate("prioritycode") // set when created, read-only after
.WritingOnUpdate("resolution")); // changeable after, not set at the start
The framework enforces the result on every path data crosses the server boundary:
- Reads — a withheld column is either omitted from the record entirely or returned with its value masked, and is hidden from table metadata and grid columns, so a form never renders an editor bound to a value that will never arrive.
- Queries — a read is never rejected over column permissions. Views are routinely shared across audiences, and one user seeing a column another can't must not become an error that forces you to maintain a separate view per audience. Every reference to a withheld column is dropped instead: projections, sorts, filter conditions (along with the filter once it is empty), and aggregates or group-bys. Dropping a condition widens the result set, but only across rows the user may already read — row visibility comes from the providers' query filters, which are added after this pass and are never touched by it.
- Writes — unlike reads, a create or update that changes a column the user may not write is rejected rather than silently trimmed: a rejected save is the only way to tell the user their edit didn’t land, whereas a narrowed read is invisible and harmless. A create is judged against what may be set when creating, and an update against what may be changed — through a single save and through a batch alike.
Note
A table’s primary id column is never withheld, whatever the read set says. The id is identity rather than data — it is already in the URL the caller used to reach the record — and row keys, edit links, subgrid relationships and delete all come apart without it. An allow-list that names the columns a page needs and forgets the id would otherwise take the portal apart while protecting nothing.
How the Components Behave
The framework components honour column permissions on their own — you don’t wire anything up. A column the user may not write renders read-only and disabled wherever it appears: every editor picks it up through its shared read-only resolution, and the grid gives the cell no inline editor, so it falls back to plain display text. A consumer-supplied readOnly="false" does not override it — the server would reject the save either way.
A column the user may not read — a masked one — renders through <ColumnDisplayValue> instead of an editor, in both stacks. That is not just a nicety: a masked column has no value at all, only the mask as its formatted value, so an editor bound to it would render blank; ColumnDisplayValue reads the formatted value and so shows the placeholder whatever the column’s type. Columns withheld by omission never reach a component at all — they aren’t on the record and the table metadata doesn’t list them.
What the Client Receives
Column permissions come as a ColumnPermissions map — column logical name to a ColumnSecurityPermission flag pair — for the columns whose access is narrowed, with Write meaning the column may be changed on an existing record. Anything absent has full access, so an unrestricted table carries nothing at all. A record retrieved on its own carries the map; records retrieved together share one map, sent once on the response, since permissions belong to the user and the table rather than to a row — both stacks’ clients put it back on each record, so reading a record’s permissions works the same either way. Grid responses also report them per column on ResolvedColumn.Permissions. A record being created has no map of its own yet, so the table-permissions endpoint also returns NewRecordColumnPermissions — the same shape, judged by what may be set when creating — and both stacks' RecordContext put it on a new record for you. Columns withheld by omission are deliberately absent from every map — omitting exists so nothing in the response reveals the column, and naming it there would undo that. Read it yourself through record.CanReadColumn(column) / record.CanWriteColumn(column) in Blazor, or the matching canReadColumn / canWriteColumn helpers from @powerportalspro/core in React.
Omitting or Masking
By default a withheld column is masked: the record carries no value for it, and SecurityOptions.WithheldColumnMask (***** by default) as its formatted value, so a form or grid still renders the field and makes clear the value is withheld rather than blank. The mask is the same whether the column has a value or not, so it hides that too. Set SecurityOptions.WithheldColumnBehavior to Omit to drop the column from the record entirely instead, as though the query had never asked for it, so nothing in the response reveals that the column exists — the right choice when the column’s very existence is sensitive. Withholding a column also withholds any column that republishes it — a lookup’s name companion, a money column’s base-currency twin — so a deny list naming one column can’t be sidestepped through another. A single table can override the site-wide choice through TableColumnPermissions.WithheldColumnBehavior.
builder.Services.Configure<SecurityOptions>(options =>
{
options.WithheldColumnBehavior = WithheldColumnBehavior.Mask;
options.WithheldColumnMask = "*****";
});
Note
Pair a masked column with a write denial. A masked column has no value on the record, so a caller posting a whole loaded record back sends nothing for it — but the framework takes a save payload at face value, so a request that does set the column, the mask included, writes over the stored value if the column is writable. The write denial is what refuses it.
Columns that republish another column
Withholding a column also withholds any column that carries the same value under a second name. Dataverse creates these for you: a lookup’s …name and …yominame partners, a choice’s label, and a base-currency twin for every currency column — creditlimit_base alongside creditlimit. They are ordinary columns, so a deny-list naming creditlimit says nothing about creditlimit_base, and without this a masked amount would come straight back through its twin. The link comes from the platform’s own metadata rather than from a naming rule, so it holds for your own columns too, and it applies on reads, on a query naming the companion explicitly, in table metadata, in grid columns and on saves.
Best practice — filter at the source
One case the framework cannot see for you: a calculated or rollup column, and the platform’s composite columns —
fullname,yomifullname,address1_compositeand their siblings. Dataverse does not report which columns feed them, so withholdinglastnamedoes not withholdfullname, and a calculated column over a column you denied will still show its result. Name them in the read set yourself when their inputs are sensitive. An allow-list (ColumnPermissions.Allow(…)) avoids the problem entirely, since nothing is readable until you name it — which is the better default for a table holding anything worth withholding.
Note
When several providers cover one table, their column sets compose the same way their operations do: additively across roles, and under
MultiplePermissionHandlersBehaviorwithin one group. A provider that exists only to scope rows and says nothing about columns is left out of the column fold entirely, so it neither widens nor narrows what the others granted.
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 Power Pages web roles and table permissions, which the platform turns into Dataverse security roles that Dataverse enforces on every request. 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, or on your portal's Web Roles page from the Power Portals Pro Enhanced Authorization solution — and assign web roles to contacts (through the Management app, programmatically, or automatically for new users with rules on that same page). 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
Matching an External Sign-In to a Contact
When someone signs in with Microsoft, Google or another external provider, the portal has to work out which existing contact — if any — that sign-in belongs to. It resolves this in two steps:
- An existing link. If the account has signed in before, an
adx_externalidentityrow already ties it to a contact. That is an explicit, stored "this account is this person" statement, so it always wins and is never affected by the settings below. - The email fallback. With no link yet, the portal matches the email address the provider supplied against columns on the contact. This is the step you can configure — and the step that decides whether a returning customer joins their existing contact or gets a duplicate.
By default two columns are searched, portal username first. Contacts created through the portal carry adx_identity_username, while contacts that were imported or keyed in by staff usually have only emailaddress1 — searching both is what lets an existing customer sign in and land on their own record instead of a new one:
builder.Services.Configure<SecurityOptions>(options =>
{
// The default: portal username first, then the primary email column.
options.ContactMatching.ExternalLoginEmailColumns =
[
ContactEmailColumn.IdentityUsername,
ContactEmailColumn.EmailAddress1,
];
});
The order matters: it is the precedence, not just a list. The columns are searched together in a single query, and the first column in your list that matched anything decides the winner — so if one contact holds the address as its portal username and another holds it in emailaddress1, the username match wins under the default order.
List fewer columns to narrow matching: [ContactEmailColumn.IdentityUsername] restricts it to portal-created accounts, and an empty list turns the email fallback off entirely, leaving an existing external-identity link as the only way an account can resolve to a contact. Add ContactEmailColumn.EmailAddress2 and ContactEmailColumn.EmailAddress3 to widen it — they are left out of the default deliberately, for the reason below.
Best practice — filter at the source
Every column you list is a way for whoever controls an email address to be recognized as the contact holding it. Only list columns your data-entry process treats as belonging to the contact themselves — if
emailaddress2is where your team files an assistant's address or a shared company inbox, listing it lets whoever reads that inbox sign in as this contact.
Best practice — filter at the source
Matching makes the email address an identifier, not just a label — it decides which existing contact a sign-in becomes. So the portal only skips the confirmation email when the identity provider actually verified that exact address. If the address was typed over the one the provider supplied, or the provider never verified it, a confirmation link is sent and the external login is not associated with the contact until that link is followed.
Which sign-ins count as verified is not a property of the provider. Google verifies every address it asserts. Microsoft does not: a personal Microsoft account verified its address, but an Entra work or school account supplies whatever is in the directory’s
verified_primary_emailorverified_secondary_emailclaim lists it, orxms_edovsays its domain belongs to the user’s own tenant. Entra sends those claims only when the app registration asks for them (see Entra ID Sign-In Setup); without them, work and school sign-ins confirm by email the first time.
Note
These settings apply only to external sign-in. Local username/password sign-in, registration and password reset continue to match on
adx_identity_usernamealone, deliberately — the password-reset flow mails its link to the address that was entered, so matching secondary email columns there would let someone reset another person's password.
Letting Only Existing Contacts Sign In
By default anyone can become a portal user: registering, or signing in with an external provider under an address no contact has, creates a contact. When the portal is for people you already hold in Dataverse — customers, members, partners — turn sign-up off, and only they can sign in:
builder.Services.Configure<AuthClientOptions>(options => options.SignUpEnabled = false);
The register page and its links go, and the server refuses to create a user however it is asked. An external sign-in still finds its contact as described above and links to it; one that matches nobody is refused instead of creating a contact. Local accounts are unaffected for people who already have one.
Note
A refused sign-in is only explained on screen when the provider verified that exact address, because then the person signing in has already proven they own it. For any other address the portal answers exactly as it does for an address that has a contact — “check your email” — and mails the address a short notice that it isn't registered. Telling the visitor directly would let anyone type addresses into the form to find out who has an account.
The notice goes out through the portal's own email sender. A host that replaces it with its own email stack should also register an
ISignUpRefusalEmailSender; without one the refusal is logged and no notice is sent.
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
AuthorizePortalRoleAttribute Class
Properties
Name | Type | Default | Description |
|---|---|---|---|
AuthenticationSchemes | string? | ||
Policy | string? | ||
RoleNames | IReadOnlyList<string> | The role names this attribute requires, any one of which satisfies it. | |
Roles | string? | ||
TypeId | Object |
AuthenticationSchemesPolicyRoleNamesRolesTypeIdMethods
Name | Parameters | Type | Description |
|---|---|---|---|
IsDefaultAttribute | bool | ||
Match | Object obj | bool |
IsDefaultAttributeMatchITablePermissionProvider Interface
Properties
Name | Type | Default | Description |
|---|---|---|---|
Table | string | Logical name of the table this provider applies to. Matched exactly, so it must be the Dataverse logical name in lower case. |
TableMethods
Name | Parameters | Type | Description |
|---|---|---|---|
GetPermissionsAsync | UserInfo user | Task<TablePermissions> | What user may do with this table, and which of its columns they may read and write. Return TablePermissions.None for a user who should have no access. TableSecurityPermission.Read if the user may see any of the table's rows and scope which ones in Services.TableQueryContext). TableSecurityPermission.Read alone exposes only the record's id. Use Reading(System.String[]) and Writing(System.String[]) to name them, or TablePermissions.AllColumns to opt out of column security for this table. |
GetQueryFilterAsync | TableQueryContext context | Task<TableQueryFilter> | Scopes which rows of this table the user can act on: a filter built with Query.LogicalOperator), TableQueryFilter.AllRows for every row the operations allow, or null for no rows. null — or a filter with no conditions — grants none, so forgetting to scope a table denies it rather than publishing every row. Say TableQueryFilter.AllRows when every row is what you mean. TableQueryContext.Operation distinguishes. It also runs under both security models, so it stays in force when Dataverse-native security bypasses Services.UserInfo). PortalRoleOptions.CompositionBehavior to RoleCompositionBehavior.Restrictive to intersect across roles too. null leaves the group no rows, and TableQueryFilter.AllRows leaves the other providers' filters to decide. Across groups, a group granting TableQueryFilter.AllRows widens the default additive union to every row — so a role's filter only means something when the other groups granting that table filter as well. |
GetPermissionsAsyncuser may do with this table, and which of its columns they may read and write. Return TablePermissions.None for a user who should have no access. TableSecurityPermission.Read if the user may see any of the table's rows and scope which ones in Services.TableQueryContext). TableSecurityPermission.Read alone exposes only the record's id. Use Reading(System.String[]) and Writing(System.String[]) to name them, or TablePermissions.AllColumns to opt out of column security for this table. GetQueryFilterAsyncQuery.LogicalOperator), TableQueryFilter.AllRows for every row the operations allow, or null for no rows. null — or a filter with no conditions — grants none, so forgetting to scope a table denies it rather than publishing every row. Say TableQueryFilter.AllRows when every row is what you mean. TableQueryContext.Operation distinguishes. It also runs under both security models, so it stays in force when Dataverse-native security bypasses Services.UserInfo). PortalRoleOptions.CompositionBehavior to RoleCompositionBehavior.Restrictive to intersect across roles too. null leaves the group no rows, and TableQueryFilter.AllRows leaves the other providers' filters to decide. Across groups, a group granting TableQueryFilter.AllRows widens the default additive union to every row — so a role's filter only means something when the other groups granting that table filter as well. TablePermissions Class
Properties
Name | Type | Default | Description |
|---|---|---|---|
Columns | TableColumnPermissions | The columns the user may read and write. TableColumnPermissions.None unless something named them: column access is granted, never assumed. | |
Operations | TableSecurityPermission | The operations the user may perform on the table, as a flags value. This is a table-level gate, not a row filter — include TableSecurityPermission.Read if the user may see any of the table's rows, and scope which ones through Services.TableQueryContext). |
ColumnsTableColumnPermissions.None unless something named them: column access is granted, never assumed.OperationsTableSecurityPermission.Read if the user may see any of the table's rows, and scope which ones through Services.TableQueryContext).Methods
Name | Parameters | Type | Description |
|---|---|---|---|
AllColumns | TablePermissions | Opts out of column-level security entirely — every column readable and writable. The pre-column-security behavior, spelled out rather than inherited. | |
Allow | TableSecurityPermission operations | TablePermissions | Adds operations to what this value already grants. |
Allowing | TableSecurityPermission operations | TablePermissions | Grants operations and no columns. Chain Reading(System.String[]), Writing(System.String[]) or TablePermissions.ReadingAllColumns to open columns up. |
Reading | string[] columns | TablePermissions | Permits reading the named columns, in addition to any already permitted. Case-insensitive, so the maker portal's spelling works as well as the logical name. |
ReadingAllColumns | TablePermissions | Opts out of column-level read security for this table: every column, including ones added to the table later. Deliberately explicit — the framework will not assume it. | |
ReadingAllColumnsExcept | string[] columns | TablePermissions | Opts out of column-level read security except for the named columns — every column but these, including ones added later. Fails open for anything new, so prefer Reading(System.String[]) on tables holding sensitive data. |
ReadingAndWriting | string[] columns | TablePermissions | Permits both reading and writing the named columns. |
WithColumns | TableColumnPermissions columns | TablePermissions | Replaces the column rules wholesale, for a provider that would rather build a Services.TableColumnPermissions itself than chain the fluent methods. |
Withholding | WithheldColumnBehavior behavior | TablePermissions | How this table's withheld columns come back — omitted, or present with a masked value. Overrides the site-wide SecurityOptions.WithheldColumnBehavior for this table. |
Writing | string[] columns | TablePermissions | Permits writing the named columns — when a record is created and when it is updated — in addition to any already permitted. Writing a column does not imply reading it — name it in Reading(System.String[]) too if the user should see what they wrote. |
WritingAllColumns | TablePermissions | Opts out of column-level write security for this table: the user may write any column, when creating and when updating. Replaces both write sets, so call WritingOnCreate(System.String[]) / WritingOnUpdate(System.String[]) after it, not before. | |
WritingAllColumnsExcept | string[] columns | TablePermissions | Opts out of column-level write security except for the named columns, which stay read-only. Replaces both write sets, so call WritingOnCreate(System.String[]) / WritingOnUpdate(System.String[]) after it, not before. |
WritingOnCreate | string[] columns | TablePermissions | Permits setting the named columns when a record is created, and only then: once it exists they are read-only, even if an earlier call — Writing(System.String[]), TablePermissions.WritingAllColumns — made them writable. A case's priority, chosen when it is logged and fixed from then on. Call it after any Writing(System.String[]), it does not imply reading. |
WritingOnUpdate | string[] columns | TablePermissions | Permits changing the named columns on an existing record, but not setting them when it is created, even if an earlier call made them writable — resolution notes that only mean something once the case exists. Call it after any Writing(System.String[]), it does not imply reading. |
AllColumnsAllowoperations to what this value already grants.Allowingoperations and no columns. Chain Reading(System.String[]), Writing(System.String[]) or TablePermissions.ReadingAllColumns to open columns up.ReadingReadingAllColumnsReadingAllColumnsExceptReading(System.String[]) on tables holding sensitive data.ReadingAndWritingWithColumnsServices.TableColumnPermissions itself than chain the fluent methods.WithholdingSecurityOptions.WithheldColumnBehavior for this table.WritingReading(System.String[]) too if the user should see what they wrote.WritingAllColumnsWritingOnCreate(System.String[]) / WritingOnUpdate(System.String[]) after it, not before.WritingAllColumnsExceptWritingOnCreate(System.String[]) / WritingOnUpdate(System.String[]) after it, not before.WritingOnCreateWriting(System.String[]), TablePermissions.WritingAllColumns — made them writable. A case's priority, chosen when it is logged and fixed from then on. Call it after any Writing(System.String[]), it does not imply reading.WritingOnUpdateWriting(System.String[]), it does not imply reading.AuthClientOptions Class
Properties
Name | Type | Default | Description |
|---|---|---|---|
LocalAccountsEnabled | bool | True | Whether the portal accepts local username/password accounts. Default |
SignUpEnabled | bool | True | Whether signing in can create a portal user. Default |
LocalAccountsEnabledSignUpEnabled