Roles
In roles.yaml we define the roles that can be used in model.yaml. To add users to a role, define them in roles.yaml. Since this can be quite a long list of users, it's most advisable to use Entra-groups where those people can be added, so that roles.yaml only contains that Entra-group.
Next to specific rights on the model, there are also more general rights on PowerBI.com, like workspace access and dataset permissions (Build, Write, Admin etc).
For the latter, you are advised to use Entra-groups as well, but it is out of the scope of Tabular. To develop Power BI reports or connect with Excel you need to be in such a group.
Defining a role
- Role:
Name: NoFinance
Alias: NoFinance
Description: "Access without finance data"
Groups:
- NoFinance@easytabular
A role supports the following keys:
| Key | Type | Description |
|---|---|---|
Name | string | Role name. This is the value referenced from PermittedRoles, ProhibitedRoles and Daxfilters. |
Alias | string | Friendly alias for the role. |
Description | string | Free-text description. |
Groups | string[] | Entra (Azure AD) groups added as members of the role. Not every group type is allowed — see Which groups Power BI accepts. |
Members | string[] | Individual Entra (Azure AD) user principals added as members of the role. |
Daxfilters | list | Row-level security filters; see Row-level security. |
Use Groups for Entra-groups and Members for individual users — both are added to the role as Azure AD members. Every role is granted model-level Read; the keys below narrow that access down.
Which groups Power BI accepts
Power BI accepts only these principals as a role member:
| Type | Refer to it by |
|---|---|
| User | user principal name, e.g. ann@contoso.com |
| Mail-enabled security group | its mail address |
| Distribution group | its mail address |
| Entra security group | obj:<objectid>@<tenantid> — a plain security group has no mail address |
A Microsoft 365 group is not accepted by its mail address, even though it has one. Microsoft's own wording is in Row-level security (RLS) with Power BI.
By object id it does work. Two Microsoft 365 groups referred to as obj:<objectid>@<tenantid> deployed cleanly onto DemoPlatform's VT model (build 1362); the same two groups by mail address had failed before that. So the deciding factor is the form you write, not the group type on its own.
When Power BI does reject a member it rejects the whole model with "There are invalid rolememberships in roles", without naming the member at fault — so the message gives you nothing to go on. Changing one member at a time is the only way through it.
A mail-enabled security group cannot be created in the Entra portal; that type lives in Exchange (Exchange admin center, or New-DistributionGroup -Type Security). A plain security group can be created in Entra, and is then referenced by object id rather than by name.
The obj: prefix matters. Without it the value is read as a user principal name, and a principal by that name does not exist. It is the same family as the app:<appid>@<tenantid> form the deploy uses for its own service principal.
Service principals cannot be role members at all.
Members do not appear in the model definition
A role's members are service state, not model metadata. getDefinition on a deployed semantic modelThe "brain" of your data that tells Power BI how different pieces of information relate to each other. returns the role, its modelPermission and its tablePermission lines — and no member line, whatever you deployed. The membership is real and visible under Security on the semantic model; it simply is not part of the TMDL.
So the TMDL cannot be asked who is in a role. The model's own metadata views can — $SYSTEM.TMSCHEMA_ROLE_MEMBERSHIPS over the XMLA endpoint, described under Testing roles. Those are two different things about the same model: the definition is a file you deploy, the membership is state the service keeps.
That split is what the variant scenarios follow: t7_vt_rollen_definitie checks that the definition survives the deploy, t8_vt_rollen_impersonatie that the roles actually filter what they claim to filter.
Restricting tables and columns
In model.yaml a role can be set on a table or a column with the ProhibitedRoles or PermittedRoles key (string array). With ProhibitedRoles defined on a table, the whole table is no longer accessible for that role; with PermittedRoles set, the role has access to the table. For a column this works the same way. The default is that every role has access (permitted) to all objects in a Tabular model.
- Table:
Name: Finance
TableType: Fact
Description: "Fact table with sensitive finance data"
ProhibitedRoles: [NoFinance]
In this case the whole fact table Finance is not accessible for the role NoFinance.
Row-level security (Daxfilters)
Daxfilters restrict which rows a role can see, rather than hiding a whole table or column. Each entry maps a table to a DAX boolean expression; only rows for which the expression evaluates to true are visible to members of that role.
Define the filters under the role in roles.yaml:
- Role:
Name: SalesNL
Alias: SalesNL
Description: "Sales reps - Netherlands only"
Groups:
- SalesNL@easytabular
Daxfilters:
- Table: Customer
Expression: "Customer[Country] = \"NL\""
| Key | Type | Description |
|---|---|---|
Table | string | Name of the Tabular table the filter applies to. |
Expression | string | DAX boolean expression; rows where it is true remain visible. |
A Daxfilter stands on its own — you do not need to also list the role in the table's PermittedRoles. Every role already has read access to all tables (model-level Read); the filter narrows that access down to the matching rows.
With the role above, members of SalesNL can read the Customer table but only see rows where Customer[Country] = "NL".
The Table of a Daxfilter must match the Tabular table Name exactly (case-sensitive), and a role may define at most one Daxfilter per table. A filter pointing at a table that is not defined anywhere in the model, or more than one Daxfilter on the same table, fails generation with an error — so the mistake surfaces at build time rather than as missing or ambiguous row-level security at run time.
A role can be shared across the base model and a derived model. When a Daxfilter targets a table that exists in the base but is left out of a derived model, the filter cannot be applied in that derived model. Generation does not fail (this is a valid setup), but it emits a build warning: the filter is simply not enforced there, so members of the role are not restricted by it in that model. Review these warnings — if the unfiltered table (or facts related to it) is present in the derived model, the role will see its data unrestricted.
Example: dynamic row-level security from Entra + source data
A static Daxfilter hard-codes the allowed values per role, so every change means editing roles.yaml. For security that follows your data — each user sees only the rows for their own department, managed centrally and refreshed with every load — drive the filter from a security bridge table instead of from a literal expression.
The idea: combine Entra (who the user is) with a source system (what they are allowed to see) into one helper dimension, then write a single Daxfilter that looks the current user up in that table at query time.
A dynamic filter evaluates USERPRINCIPALNAME(), so it only means anything when the model answers as a named user. That is the one thing a pipelineAn automated "conveyor belt" that moves data from one place to another or performs a task automatically. identity cannot arrange — see What a service principal cannot do. An automated test can still prove the role exists, carries the filter expression you expect, and has the right group as a member; that the right person sees the right rows is checked with View as.
1. Build the security bridge table
In a notebook, load Entra into the data platform (the user's UserPrincipalName and group memberships) and join it to employee information from a source system — for example the department an employee works in. Materialise the result as a view, e.g. dm.D_UserRoleLevelSecurity, in an entity-attribute-value shape so one helper table can secure any dimension:
| UserPrincipalName | TableName | Attribute | AttributeValue |
|---|---|---|---|
ann@contoso.com | Department | Department code | 100 |
ann@contoso.com | Department | Department code | 200 |
bob@contoso.com | Department | Department code | ALL |
One row per user per allowed value. The sentinel value ALL grants unrestricted access — handy for managers and admins.
2. Add the helper dimension to the model
Add the table as a hidden helper dimension in model.yaml. IsHidden: True keeps it out of the report field list:
- Table:
Name: UserRoleLevelSecurity
SourceSchema: dbo
SourceTable: D_UserRoleLevelSecurity
TableType: Dim
IsHidden: True
Description: "Helper dimension: User Role Level Security"
Columns:
- SourceColumn: UserPrincipalName
Type: varchar(255)
- SourceColumn: TableName
Type: varchar(255)
- SourceColumn: Attribute
Type: varchar(255)
- SourceColumn: AttributeValue
Type: varchar(255)
Column display names are derived from the source columns, so UserPrincipalName becomes User Principal Name and AttributeValue becomes Attribute Value — these are the names the DAX below refers to.
3. Filter the real dimension with USERPRINCIPALNAME()
Add a role with a Daxfilter on the dimension you want to secure (here Department). The expression looks the signed-in user up in the helper table, honours the ALL escape hatch, and otherwise keeps only the departments listed for that user:
- Role:
Name: DepartmentSecurity
Alias: DepartmentSecurity
Description: "Each user sees only their own department(s)"
Groups:
- AllEmployees@contoso
Daxfilters:
- Table: Department
Expression: |
VAR UserRightsTable =
FILTER(
UserRoleLevelSecurity,
[User Principal Name] = USERPRINCIPALNAME() &&
UserRoleLevelSecurity[Table Name] = "Department" &&
UserRoleLevelSecurity[Attribute] = "Department code"
)
VAR HasAllAccess =
CALCULATE(
COUNTROWS(UserRightsTable),
UserRoleLevelSecurity[Attribute Value] = "ALL"
) > 0
RETURN
IF(
HasAllAccess,
TRUE(),
Department[Department code] IN
SELECTCOLUMNS(
UserRightsTable,
"AllowedValues",
UserRoleLevelSecurity[Attribute Value]
)
)
Now everyone in the AllEmployees@contoso group shares a single role, but each person sees only their own department's rows — with managers flagged ALL in the bridge table seeing everything. Access changes are made in the source/Entra data and take effect on the next load, with no edit to roles.yaml.
Testing roles
Roles are security: a wrong PermittedRoles, a mistyped table in a Daxfilter or a role that never reaches the workspace shows up as data leaking to the wrong people. Test them at four levels, from cheap to conclusive.
-
Generated model. The api-generator turns
roles.yamland thePermittedRoles/ProhibitedRoleskeys into Tabular Object Model roles, table permissions, column permissions and filter expressions. Unit tests on that output prove the mapping itself: a prohibited table becomesMetadataPermission.None, a Daxfilter becomes the table'sFilterExpression, an unknown table fails generation. -
Deployed definition. After a deploy, fetch the semantic model definition (Fabric
getDefinition, TMDL) and checkdefinition/roles/<Name>.tmdl:modelPermission,tablePermission,columnPermissionandfilterExpression. This proves the roles survived the deploy — withroleDeployment: RetainRolesthey do not, by design. Do not look for members here; see Members do not appear in the model definition. -
Deployed membership. The members do show up in the model's own metadata views, over the XMLA endpoint:
SELECT [ID], [Name] FROM $SYSTEM.TMSCHEMA_ROLES
SELECT [RoleID], [MemberName] FROM $SYSTEM.TMSCHEMA_ROLE_MEMBERSHIPSMemberNamecomes back in the exact form it was deployed in — for a group,obj:<objectid>@<tenantid>— which is what answers "is the right Entra group in this role?". Reading these views needs database-admin rights on the model: Workspace Admin, not Member or Build.Only one view can be queried at a time and they cannot be joined, so a single query tells you which principals the model knows, not which role each one belongs to. Pairing them means matching
RoleIDclient-side across two queries. -
Effective permissions. Open an XMLA connection with
Roles=<RoleName>in the connection string and run the same DAX with and without it: row-level filters apply, and a query on a prohibited table fails. A role needs no user for this, so a role with a static filter is fully testable unattended.EffectiveUserNameis the other half — it answers as a named user, which is what a dynamic filter needs. It requires rights this framework's deploy identity does not have on a Power BI model (see below), so dynamic RLS stays a manual check: View as on the semantic model's security page in the Power BI service.
Members that only exist to be impersonated in tests are ordinary Members in roles.yaml; keep them in a dedicated test role so they never widen a production role.
What a service principal cannot do
Both automated levels above go over XMLA, and that is not a preference. A pipeline authenticates as a service principal, and two shorter routes are closed to it:
Datasets - Execute Queries(the Power BI REST API) states that, regardless of the tenant setting, service principals are not supported for datasets with RLS. Against a model with roles every query returns HTTP 401 — including one without impersonation, and including one that has nothing to do with security. Adding a role to a model therefore moves all of its automated DAX to XMLA, not only the queries that test the role: a pipeline that checked a measure against the warehouse over this API breaks on the deploy that introduces the first role.EffectiveUserNameover XMLA is refused even with the service principal as workspace admin: the connection fails to open with "Either the database'<name>'does not exist, or you do not have permissions to access it", while the same connection without impersonation succeeds.
So an automated suite can prove that a role filters, and which principals are in it, but not what one named person sees. That last step belongs to a human with View as, or to a test that runs under a real user's token.