Extensible Data Security (XDS) in D365 Finance & Operations: Row-Level Security That Scales
Role-based security in Dynamics 365 Finance & Operations controls which objects a user can reach. It does not, on its own, control which rows. When a sales rep should only see their own region's customers, or a subcontractor should see only their own projects, you need Extensible Data Security (XDS) — policies that inject a WHERE clause into every query against a table. Microsoft's walk-through is in the extensible data security policies docs.
Object security is not row security
Duties and privileges decide whether a user can open CustTable at all. That is the roles, duties, and privileges hierarchy. Once they can open it, a form without a range returns every customer the table will give them.
A form range is not row security. A user who can run a report or an X++ job will walk around a range on one screen. XDS rewrites queries against the constrained table. If the requirement is "hide a button" or "default this one screen," stop. That is a form range. XDS is for "this user must not read that row through table queries."
Three pieces, one rewrite
An XDS policy has three ingredients:
- A constrained table — the table whose rows you want to filter (for example
CustTable). - A primary table / policy query — a view that returns the rows the current user is allowed to see, based on their context.
- A context — what "the current user" means: a role, or an application-defined value you set in code.
At runtime, the kernel adds the policy query to SELECT, UPDATE, DELETE, and INSERT on the constrained table. Forms and X++ selects honor it. Microsoft documents that data entities do not support XDS — do not assume an entity export or an OData page is filtered the same way as the form. Test the path you actually ship.
The policy attaches to a role when ContextType is RoleName. Users without that role do not pay the join and do not get the filter. Users with that role always pay both. There is no "just this form" switch. Two policies that both apply intersect: a row must satisfy every applicable policy.
Territory filter on CustTable
Suppose CustTable has a SalesResponsible field, and you want each rep to see only customers they own.
- Create a query (
MyCustTerritoryPolicyQuery) overCustTablethat rangesSalesResponsibleto the current worker. - Create a security policy referencing that query, with
CustTableas the constrained table and ContextType = RoleName, tied to the "Sales rep" role. - The policy query uses a context function to resolve the current user to a worker:
-- conceptual shape of the policy query range
SELECT * FROM CustTable
WHERE CustTable.SalesResponsible =
(SELECT PersonnelNumber FROM HcmWorker
WHERE HcmWorker.Person = currentUserWorkerPerson())
In X++ the range is expressed with a computed column or a method on the policy that returns the constraint. The kernel appends it to every consuming query.
Test it as two users. As a rep, All customers shows only theirs. As a manager not in the constrained role, everyone is still visible. Then, as the same rep, export the customer data entity and hit OData. If those lists are wider than the form, that is the documented entity gap, not a broken policy. Decide whether the requirement still holds.
Index SalesResponsible before you call the policy done. A demo of twenty customers will not show the stall.
Constrained tables tax every query
XDS adds a join to every query on the constrained table. If your policy query is expensive, you have just made the entire system slow — every lookup, every validate, every integration read.
| Pitfall | Fix |
|---|---|
| Policy query joins many tables | Flatten to a single indexed lookup; precompute if needed |
| Constraint column not indexed | Add an index covering the filter field |
| Policy applied to a hot, high-volume table | Constrain a narrower table or use a covering view |
| Context resolved with heavy code each call | Cache the resolved value per session |
The policy query must be cheap and indexable. It runs on reads you never see in your own code. Constraining CustTable taxes the sales order, the invoice, and the credit check. If the secret is the sales order, constrain SalesTable and leave customers alone.
Two policies on the same table intersect. A user in two constrained roles gets both joins — or an accidental empty set. Scope tightly.
Integration accounts and the entity gap
An integration that reads the table through X++ or a form-backed query sees the filter. An integration that reads a data entity may not. Microsoft's entity security notes say data entities do not support XDS. That is how a "secure" customer list on the form becomes a full export from DMF.
Give interactive users the constrained role. Give integration users a role outside the policy context unless you have proven that path is filtered. Batch jobs run as a user: constrained X++ work sees a slice even when an entity job would not.
To prove a missing-row complaint is XDS, assign XDSDataAccessPolicyBypassRole to a sandbox copy of the user. If the rows appear, the policy is the filter. Remove the bypass when you are done.
Application context leaks across the session
Role-based context is static. When the filter depends on runtime data — "show only the company the user picked in a lookup" — use application context. You set a value in code and the policy reads it:
// Documented application-context API
XDS::SetContext('MyRegionContext', strFmt('%1', selectedRegionId));
Your policy's context type is then ContextString, and the policy applies when that string matches the policy's Context String. Application context is stateful.
Set it at the start of the operation. Clear it when the operation ends — including on error — so the next form in the same session does not keep region A. Context is per session. Assume the session will reuse the last value you set.
Skip XDS when a form query is enough
- The user is allowed to see the row; you only want a shorter list on one screen. Use a query range.
- The rule is "this duty cannot post," not "this duty cannot read the journal." That is privileges, not XDS.
- The filter is a temporary debug view. Do not leave a policy in the AOT for a one-off.
- You cannot make the policy query cheap. Narrow the table or precompute a relation the policy can hit with one index.
- The constrained table is a financial-dimension source or a dimension view. Microsoft documents that XDS on financial dimension data can corrupt it, and policies on backing tables (customers, vendors, operating units) can blank dimension values.
XDS is for row visibility as a security requirement, not a UI nicety.
Verify the policy, not just the form
- Negative test: the excluded row is gone from the form and from an X++ select. Then check the entity/OData path separately.
- Integration test: the service account is not in the constrained role unless you intend a partial payload.
- Performance test: a lookup on the constrained table with real volume, policy on versus off.
- Context test: set application context, use it, clear it, open a second form, confirm the filter did not follow you.
- Register: active policies, constrained tables, and roles.
Build the policy query like it is on the hot path — because it is. The XDS docs cover the AOT properties.
Keep reading
update_recordset: SQL Ran, and update() Never Did
update_recordset, insert_recordset, and delete_from send SQL now. They skip table methods, CoC, and events. Stamp the columns the method would have stamped, or stay on the row path.
FormDataSource.research: The Range You Added Never Ran
research reruns the live QueryRun. A range on query() waits for executeQuery. reread reloads only the current RecId, so an insert stays off the grid.
CacheLookup in X++: Found Serves the Stale Row Inside ttsbegin
Found serves the same CustTable inside ttsbegin. NotInTTS goes to SQL. EntireTable is for parameters. No unique index means the cache never hits.
crossCompany in X++: The Query That Mixes Legal Entities
A normal select stays in the current company. crossCompany returns every legal entity the user may read. How the company container, changeCompany, and OData differ.
Budget Control in Finance 10.0.49: Closed Periods, Reservations, and Broken Tracking
The budgeting features in Dynamics 365 Finance 10.0.49 — accounting-date advancement, reservation posting, encumbrance reporting, and a diagnostic for corrupt budget tracking.
Bank and Cash in Finance 10.0.49: Delayed Settlement, Match Preview, and Bridge Selection
What Dynamics 365 Finance 10.0.49 changes in payment journals and bank reconciliation, which pieces are still preview, and which bank behaviors you can no longer disable.
Newsletter
New posts, straight to your inbox
One email when a new post goes up. Double opt-in, unsubscribe anytime. Not a daily digest.
Comments
- No comments yet. Be the first.