Summary
This portal was built for Invoke, a Texas-based Microsoft partner. The wider engagement, covering Dynamics 365 Project Operations, Power BI reporting and QuickBooks integration, is described in our Invoke case study.
A customer portal that filters records only in page code exposes data the moment someone calls the Web API directly. We built a role-based customer portal in Microsoft Power Pages where Dataverse table permissions decide access, and Liquid with FetchXML only shapes the view. One Service Requests page now serves three user scopes.
Global users see every request, department users see their department's requests, and regular users see their own. Contacts from the same account share that account's subscriptions, contracts, projects and invoices. Dashboard tiles use aggregate queries scoped the same way as the lists, and search and paging run on the server at ten rows per request.
This Blog Explains
- How web roles and table permission access types map to global, department and personal visibility
- How one Liquid template with a FetchXML query replaces a separate page per role
- How the Account access type gives every contact in a customer organisation the same subscriptions, contracts, projects and invoices
- How aggregate FetchXML keeps dashboard counts in step with the lists behind them
- How a negative test through the Web API proves the security model before go-live
Introduction
For a portal user, the question "Which service requests can I see, and why can my colleague see more?" should have one answer, wherever they ask it. In a portal built page by page, the answer changes with the route. The list page hides a row, the details page opens it from a pasted URL, and the Web API returns it without complaint.
Power Pages gives a developer two places to apply a visibility rule. One is the page, through Liquid and JavaScript. The other is Dataverse, through web roles and table permissions. Only the second one follows the user to every list, form and API call.
This post covers how we used both, each for the job it suits, and the two points where our first design was wrong.
Table of Contents
The Business Problem
The portal had to show each signed-in contact the right slice of Dataverse without a separate page per audience and without trusting the browser. Service requests have three audiences. Subscriptions, contracts, projects and invoices belong to a customer account, and several contacts from that account need the same view of them.
When each page carries its own filtering logic, four recurring problems appear:
- Duplicated pages: A Service Requests page per role means three templates, and every new column is added three times
- Security that lives in the template: A Liquid
ifhides rows on one page and does nothing for another list, a basic form or a/_api/call from the browser console - Counts that disagree: Dashboard tiles built with their own queries show numbers the user cannot reproduce by opening the list
- Slow lists: Loading every case and filtering in JavaScript works in testing and degrades once the table holds thousands of rows
The Solution
We designed around one rule: Dataverse decides what a contact may read, and the page decides how to present it. Every visibility requirement had to be expressed as a table permission first. Liquid and FetchXML then narrow and arrange what the permission already allows.
- IdentitySigned-in contactAuthenticated through the portal identity provider
- RoleWeb roleGlobal Users, Department Users or Regular Users
- SecurityTable permissionsGlobal, Account or Contact access type per table
- QueryLiquid and FetchXMLOne query, filter chosen by role
- PageList and dashboardTen rows per request, counts from aggregates
For a business user the result is plain. Two people from the same customer open the Invoices page and see the same invoices. A person from a different customer sees none of them, whichever route they use to ask. The sections below explain how each part closes a gap in the page-by-page approach.
Technical Approach
We built in a fixed order: permissions first, queries second, interaction last. A query written against an open table hides permission gaps until late, so the table permissions were configured and tested before any web template existed.
1. Web Roles and Table Permissions as the Security Boundary
Each portal user is a contact, each contact holds one or more web roles, and each web role carries table permissions. A table permission names a table, a privilege such as Read, and an access type. Power Pages offers Global, Contact, Account and Self access types, plus child permissions that inherit from a parent table.
| Web role | Table | Access type | Record must relate to |
|---|---|---|---|
| Global Users | Case (incident) | Global | Nothing, all rows are readable |
| Department Users | Case (incident) | Account | The contact's parent account through the case customer |
| Regular Users | Case (incident) | Contact | The signed-in contact as primary contact on the case |
| All three roles | Subscription, Contract, Project, Invoice | Account | The contact's parent account |
Table 1: Read permissions configured for this portal. Create and Write were granted separately and only where a form needed them.
2. Where a Department Lives in the Data Model
Power Pages has no Department access type, so a department has to be expressed as something the permission model understands. Our first design used a custom department lookup on the case, a Global permission for department users, and a FetchXML condition to narrow the rows. It rendered correctly and failed the first negative test, because the Web API ignored the page filter and returned every case.
| Criteria | Global permission plus Liquid filter | Our choiceDepartment as an account record |
|---|---|---|
| Page shows only department rows | ✓ | ✓ |
| Web API call returns only department rows | ✗ | ✓ |
| Other lists and forms inherit the rule | ✗ | ✓ |
| No change to the Dataverse data model | partial | ✗ |
We reversed the decision and moved departments into the account hierarchy as child accounts. A department user's contact sits under the department account, cases carry that account as customer, and the Account access type enforces the rule inside Dataverse. The cost was a data migration for existing cases, which is cheaper before go-live than after.
3. One Service Requests Page for Three Scopes
The Service Requests template reads the role once, sets a scope variable, and injects a single condition into the query. user.roles is an array of role names, so contains replaces the loop most samples use.
{% if user %}
{% assign scope = 'contact' %}
{% if user.roles contains 'Global Users' %}
{% assign scope = 'global' %}
{% elsif user.roles contains 'Department Users' %}
{% assign scope = 'account' %}
{% endif %}
{% assign page = request.params['page'] | default: 1 | integer %}
{% fetchxml requests %}
<fetch count='10' page='{{ page }}' returntotalrecordcount='true'>
<entity name='incident'>
<attribute name='title' />
<attribute name='ticketnumber' />
<attribute name='statuscode' />
<attribute name='createdon' />
<filter type='and'>
<condition attribute='statecode' operator='eq' value='0' />
{% if scope == 'account' %}
<condition attribute='customerid' operator='eq' value='{{ user.parentcustomerid.id }}' />
{% elsif scope == 'contact' %}
<condition attribute='primarycontactid' operator='eq' value='{{ user.id }}' />
{% endif %}
</filter>
<order attribute='createdon' descending='true' />
</entity>
</fetch>
{% endfetchxml %}
{% for r in requests.results.entities %}
<tr>
<td>{{ r.ticketnumber }}</td>
<td>{{ r.title | escape }}</td>
<td>{{ r.createdon | date: 'dd MMM yyyy' }}</td>
</tr>
{% endfor %}
{% endif %} The fetchxml tag always applies table permissions, so the filter is there for two other reasons. It keeps the query narrow, and it settles what a contact holding two roles sees. Permissions from several roles add together, which means a contact with both Regular Users and Department Users would otherwise get the union.
4. Account-Level Subscriptions, Contracts, Projects and Invoices
All four tables follow one pattern: an Account access permission through the table's lookup to account, and a query filtered on user.parentcustomerid.id. Contact access and Account access are different rules. A subscription tied only to the person who signed the order disappears for their colleagues, so we checked each table's lookup before choosing the access type.
A contact with no parent account is the edge case to plan for. The Liquid expression renders empty, the condition compares against a blank GUID, and FetchXML throws. We guard each account page with a check on user.parentcustomerid and show a plain message instead.
5. Dashboard Counts Without Loading Rows
Each dashboard tile runs an aggregate query that returns one row holding a count, using the same scope condition as the list behind it.
{% fetchxml open_requests %}
<fetch aggregate='true'>
<entity name='incident'>
<attribute name='incidentid' alias='total' aggregate='count' />
<filter type='and'>
<condition attribute='statecode' operator='eq' value='0' />
{% if scope == 'account' %}
<condition attribute='customerid' operator='eq' value='{{ user.parentcustomerid.id }}' />
{% elsif scope == 'contact' %}
<condition attribute='primarycontactid' operator='eq' value='{{ user.id }}' />
{% endif %}
</filter>
</entity>
</fetch>
{% endfetchxml %}
<span class='tile-value'>{{ open_requests.results.entities[0].total }}</span> Two Dataverse limits shape this choice. The total record count returned by a paged query stops at 5,000, so it cannot serve as a tile value for a global user on a large table. Aggregate queries fail once they touch more than 50,000 rows, so the global tile needs a status or date condition that keeps it under that ceiling. Both limits are documented in the Microsoft Learn FetchXML reference listed below.
6. Search and Pagination on the Server
Paging uses the count and page attributes already in the list query, with the page number read from the URL. Search calls the Power Pages Web API, which applies the same table permissions as the Liquid tag.
- Enable the table for the Web APIAdd the site setting
Webapi/incident/enabledwith the valuetrue. - Whitelist the columnsAdd
Webapi/incident/fieldswithtitle,ticketnumber,createdon. Avoid the wildcard, because it exposes every column the permission allows. - Debounce the inputWait 300 ms after the last keystroke and require three characters before sending a request.
- Cap the responseSend
$top=10so a broad search term returns one page of rows.
const box = document.querySelector('#request-search')
let timer
box.addEventListener('input', () => {
clearTimeout(timer)
timer = setTimeout(async () => {
const term = box.value.trim().replace(/'/g, "''")
if (term.length < 3) return
const url = '/_api/incidents'
+ '?$select=title,ticketnumber,createdon'
+ "&$filter=contains(title,'" + encodeURIComponent(term) + "')"
+ '&$top=10'
const res = await fetch(url)
const data = await res.json()
renderRows(data.value)
}, 300)
}) 7. Are Liquid Role Checks Enough to Secure Portal Data?
No. A Liquid check controls what one template renders, and the only control that follows the user to every list, form and API call is the table permission. We proved it with a negative test pass using six test contacts: one per web role in each of two accounts.
- Sign in as each contact and compare the list row count with the dashboard tile
- Open a case from the other account by pasting its record ID into the details page URL
- Call
/_api/incidentsfrom the browser console as a regular user and confirm only that contact's cases return - Remove the web role from a contact and confirm the list and the API both return nothing
- Confirm every list has Enable Table Permissions switched on in the Portal Management app
Business Impact
- 1Service Requests template serving all three web roles, down from three in the original design
- 4Account-level tables secured with one repeated Account access pattern
- 10Rows returned per list request, paged in FetchXML on the server
- 6Test contacts in the negative test pass, one per role in each of two accounts
These figures are counts from this portal's configuration, not measured performance results. The larger change is where the rules live. Every visibility rule now sits in a table permission that an administrator can read in the Portal Management app, instead of in conditions spread across web templates.
- A separate Service Requests page per web role
- Global permission for department users, narrowed by a Liquid condition
- Dashboard tiles counting rows from full list queries
- Search filtering rows already loaded in the browser
- One template with a scope variable set from
user.roles - Account access permission with departments modelled as child accounts
- Aggregate FetchXML returning a single count row per tile
- Web API search with whitelisted columns and
$top=10
Key Benefits
- One security model: Lists, forms, dashboard tiles and Web API calls all answer to the same table permissions
- Less page maintenance: A column added to the Service Requests list is one template change instead of three
- Shared account view: Every contact under a customer account sees the same subscriptions, contracts, projects and invoices
- Counts that match: Tiles and lists include the same scope condition, so a tile number equals the rows behind it
- Predictable page weight: Each list request returns ten rows whether the case table holds two hundred records or twenty thousand
Frequently Asked Questions (FAQs)
Q11. Can one Power Pages page serve several web roles?
Yes. Give each web role its own table permission on the same table, with Global, Account or Contact access as needed. Then write one FetchXML query in a Liquid template and add a condition based on user.roles. Dataverse enforces the permission and the condition narrows the result. In this portal, one Service Requests template replaced the three role-specific pages in the original design.
Q22. Does the Liquid fetchxml tag respect table permissions?
Yes. The fetchxml Liquid tag always runs with the signed-in contact's table permissions, so a query with no filter still returns only permitted rows. Lists are different. Each list has an Enable Table Permissions setting in the Portal Management app, and a list with that setting off shows every row the view returns. We check that setting on every list as part of the release test.
Q33. Can several contacts from the same account see the same records?
Yes. Use the Account access type on the table permission and pick the relationship from the table to account. Every contact whose parent account matches then reads the same rows, subject to their web role. Contact access works differently and ties the row to one person. In this portal, subscriptions, contracts, projects and invoices all use Account access so colleagues share one view.
Q44. Is there a Department access type in Power Pages table permissions?
No. The access types are Global, Contact, Account and Self, plus child permissions that inherit from a parent table. A department rule has to be expressed through one of those. We modelled each department as a child account so the Account access type could enforce it. Filtering on a custom department lookup in Liquid alone left the Web API returning every case to department users.
Q55. How many records should a portal list load per page?
10 rows per request worked for this portal, set through the count attribute on the fetch element with the page number passed in the URL. The exact figure matters less than where the paging happens. Paging in FetchXML means Dataverse returns one page of rows. Paging in JavaScript means the browser downloads the full list first, which is the pattern that slows down once a table reaches thousands of cases.
Conclusion
Every visibility rule in a Power Pages portal needs a home in a table permission, and a rule that cannot be expressed there is a data model problem. Moving departments into the account hierarchy cost us a migration and removed the one place where page code stood between a user and someone else's cases. On the next portal we would run the Web API negative test on day one, before the first web template is written.
Ready to Build a Secure Customer Portal?
Ready to Build a Secure Customer Portal?
If your organization is planning a Power Pages portal with more than one audience, send us your web roles and the tables you need to expose. Our specialists will map each visibility rule to a table permission and flag the ones your current data model cannot enforce.
Connect with CloudFronts