7 min readRishi

Extensible Data Security (XDS) in D365 Finance & Operations: Row-Level Security That Scales

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.

  1. Create a query (MyCustTerritoryPolicyQuery) over CustTable that ranges SalesResponsible to the current worker.
  2. Create a security policy referencing that query, with CustTable as the constrained table and ContextType = RoleName, tied to the "Sales rep" role.
  3. 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.

PitfallFix
Policy query joins many tablesFlatten to a single indexed lookup; precompute if needed
Constraint column not indexedAdd an index covering the filter field
Policy applied to a hot, high-volume tableConstrain a narrower table or use a covering view
Context resolved with heavy code each callCache 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

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.