Security
The model is Dataverse's: tree-shaped business units (one root), users (systemuser) in a BU,
teams (owner team teamtype 0, access team teamtype 1), roles with privileges at a depth, records owned
by a user or a team (ownerid, owningbusinessunit), plus per-record sharing. Every request (Web API,
SOAP, plugin service, asynchronous jobs) goes through the same checks before the pipeline; reads get an added
predicate in the SQL query (never row filters applied after the query).
| Depth | privilegedepthmask |
SDK PrivilegeDepth |
Records reached |
|---|---|---|---|
| Basic | 1 | 0 | own, own teams', shared with oneself or with one's teams |
| Local | 2 | 1 | of one's own BU |
| Deep | 4 | 2 | of one's own BU and child BUs |
| Global | 8 | 3 | all |
The roles of an owner team apply to its members as if assigned to them; access teams receive only shares (see Access teams for those created from a template).
For systemuser, team, businessunit and role the depth is evaluated on owningbusinessunit and Basic counts as Local.
Privileges: prv + verb + entity SchemaName (prvCreatepb_Project), verbs Create, Read, Write,
Delete, Append, AppendTo, Assign, Share; generated for every entity, custom ones too (privilegeid =
MD5 of "xrmd:privilege:" + lowercase name). Not entity-based: prvActOnBehalfOfAnotherUser, the metadata
privileges (prvCreateEntity, prvCreateAttribute and the others listed in Solution), prvImportCustomization,
prvExportCustomization, prvPublishCustomization, prvWorkflowExecution (run a workflow on demand).
Standard roles (in the root BU, roleid = template id):
| Role | Template id | Content |
|---|---|---|
| System Administrator | 627090FF-40A3-4053-8790-584EDC5BE201 |
every privilege at Global, also for entities created later (recognized by roletemplateid, without roleprivileges rows) |
| System Customizer | 119F245C-3CC8-4B62-B31C-D1A046CED15D |
plugin and role registration, reads of the system tables, metadata privileges, solution tables, every privilege at Global on the custom API tables and on workflow and processtrigger, prvWorkflowExecution Global, import/export/publish of customizations |
| Basic User | 236750CD-45AE-4939-AB12-B24B920CED93 |
Read Global on organization, businessunit, systemuser, team, role and the custom API tables (customapi, customapirequestparameter, customapiresponseproperty); Read/Delete Basic on asyncoperation; prvWorkflowExecution Global; Read Global on systemform (as Dataverse's Basic User: top-up system-form-role-privileges); Read Global on webresource (top-up web-resource-role-privileges), so that form libraries and web resources load for every user; nothing on custom tables |
| Delegate (template "Proxy") | D892CC0B-28C7-4E88-BD92-72F2C366BAED |
only prvActOnBehalfOfAnotherUser Global |
The content of System Customizer and Basic User is an xrmverse choice (xrmverse does not have the standard tables of the
real roles). Roles exist only in the root BU and are assigned to users and owner teams of any BU (no per-BU copies).
Startup recognizes the standard roles by roletemplateid and seeds their privileges only when it creates the role row:
privileges removed or changed afterwards stay as they are. A privilege that a later version adds to the standard roles
reaches an existing organization through a one-time top-up at startup: the standard roles get the grants they lack,
the top-up is recorded by name in meta.upgrade_step, and it never runs again, so a grant removed after it stays
removed. The custom API grants above (System Customizer and Basic User) arrive this way in an organization created
before custom APIs.
Administration (SDK/SOAP): Associate/Disassociate of systemuserroles_association, teamroles_association,
teammembership_association; AddPrivilegesRole, ReplacePrivilegesRole, RemovePrivilegeRole. Whoever assigns a role or
adds privileges must have Write on the principal or on the role, and every privilege of the role at an equal or higher depth
(RoleUpdateValidationFailure); System Administrator can be assigned only by a System Administrator. Adding a member to
an owner team is equivalent to assigning them every role of the team (same check).
Identity and first administrator: the token is mapped to systemuser.azureactivedirectoryobjectid (claim oid), without
auto-provisioning (unknown user: 0x80072560). On first start Seed:AdminObjectId creates the user with the
System Administrator role (dev/test path; xrmd org create arrives in Plan 6). Every start with Seed:AdminObjectId
reassigns the System Administrator role to that user if it was removed: this is a development path, not to be
configured in production. Other users are created with a
Create of systemuser (azureactivedirectoryobjectid, businessunitid) and an association to a role. A user with
isdisabled = 1 receives 0x80040225 from every endpoint; a user without roles (direct or from teams) receives 0x80042F09
from every endpoint except /healthz, so not even WhoAmI succeeds. Users cannot be deleted (400): they are disabled.
Web session cookie: so that the browser's own requests (a form library's <script src>, an HTML web resource in an
IFrame, a script's fetch(…, { credentials: "same-origin" })) are authenticated as they are in Dataverse, the web shell posts
its access token to POST /api/session with the bearer header after sign-in, after every silent token renewal and before the
token expires. The server sets the cookie xrmd.session to that same token: HttpOnly, SameSite=Strict, Path=/, no
Domain (an organization is its host name), Secure when the request is HTTPS (after the trusted proxy's forwarded headers),
expiring at the token's exp. A token longer than 3900 characters is refused (400): a browser would drop the cookie.
DELETE /api/session, also with the bearer header (never the cookie), clears it; at sign-out the shell sends it when it has a
token without user interaction, and otherwise leaves the cookie to lapse at the token's exp. The cookie carries
no other state: it is validated on every request exactly as the bearer token (signature, issuer, audience, lifetime), so an
expired token in it is a 401. It is read only when the request has no Authorization header (a header always wins, even an
invalid one) and only on /api/data/…, /WebResources/…, /{…}/WebResources/… (the cache-busting prefix) and
/ClientGlobalContext.js.aspx; never on /admin, /mcp, /XRMServices, /Image or /api/session itself (the cookie cannot
renew itself). CSRF: a cookie-authenticated POST, PATCH, PUT or DELETE is refused with 403 (0x80048306) when its
Origin header is present and is not the request's own origin (scheme, host and port; Origin: null included), or when its
Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain, the types an HTML form can send
without a CORS preflight. SameSite=Strict already keeps the cookie off cross-site requests; these rules also cover a
same-site but cross-origin page (another port or subdomain) and browsers that ignore SameSite. Requests with the bearer header
are not subject to them. The path list is part of this CSRF protection, not a sandbox for web resources (see below).
One cookie per browser, sign-in per tab. The shell keeps its sign-in per tab (session storage), but xrmd.session is one
cookie per host for the whole browser profile, and the last token posted wins. A tab therefore posts its own token again when it
gains focus or becomes visible, if that token differs from the one it last posted or another tab has set (or cleared) the cookie
since. The active tab wins: signing in as two different users in two tabs of the same browser at the same time is not
supported. While you work in one tab, the form scripts and web resources of a tab in the background run as the user of the
tab you last used; use separate browser profiles or a private window for a second user. A token counts as posted only when the
server accepted it. A failed post is retried after 2, 5, 15, 30 and 60 seconds, and the shell shows a warning bar with the
server's message until a post succeeds. A refusal (4xx) is not retried: in particular a token longer than 3900 characters (for
example an Entra token with many group claims) gets its own message, and form scripts and web resources do not work for that
user until the token is shorter.
Web resources are trusted code. Form libraries (a hidden about:blank IFrame of the shell) and HTML web resources (an
IFrame of /WebResources/…, not sandboxed) run on the shell's own origin, as they do in Dataverse. They can read the tab's
session storage, which holds the access token (MSAL's cache; in development mode the signing key itself), and the shell's
Xrm, so they can call every endpoint, /admin and /mcp included, with the bearer header and the full rights of the user
who opened the form. The cookie's path list and CSRF rules do not change this. Whoever can create or import a web resource or
change a form's libraries (System Customizer, System Administrator, and anyone with prvImportCustomization through a solution
import) can run code as every user who opens that form, System Administrators included. Grant those rights, and import
solutions, only from people and sources you trust as you trust code you deploy.
Impersonation: HTTP header MSCRMCallerID (systemuserid) or CallerObjectId (oid; takes precedence over MSCRMCallerID),
SOAP header CallerId (ServiceClient.CallerId; if present it replaces the HTTP header). It requires
prvActOnBehalfOfAnotherUser from a directly assigned role (not from a team) or a direct System Administrator, otherwise
0x8004A110; Guid.Empty = no impersonation; SYSTEM cannot be impersonated. The effective privileges are the intersection
of the impersonated user's and the impersonator's. createdby/modifiedby are the impersonated user; createdonbehalfby/modifiedonbehalfby are
not maintained. IOrganizationServiceFactory.CreateOrganizationService(userId) in plugins applies the security
of the given user without requiring prvActOnBehalfOfAnotherUser at runtime; the check is at registration (a step
with an impersonatinguserid different from the caller requires that direct privilege).
Sharing: GrantAccess, ModifyAccess, RevokeAccess on principalobjectaccess (accessrightsmask: Read 1,
Write 2, Append 4, AppendTo 16, Create 32, Delete 65536, Share 262144, Assign 524288). Rights the caller does not
have on the record cannot be granted; ModifyAccess without an existing share creates it. A share names its record by id
and table (objectid, objecttypecode): records of two tables can have the same id (a task created with an account's
id, for example), and GrantAccess, ModifyAccess, RevokeAccess and the Delete of a record read and change only the
shares of that table's record, never those of the other. The unique index of principalobjectaccess is
(objectid, objecttypecode, principalid); an existing database gets it from migration 0011_share_object_type.sql,
which replaces the earlier (objectid, principalid) index (plan 2.11, final review C2).
System tables (rules that also apply to SYSTEM and System Administrator): asyncoperation cannot be created, is updated
only to cancel a waiting job (3/32) and is deleted only if statecode is 3; plugintracelog only Read and Delete;
principalobjectaccess, privilege and the intersect tables (systemuserroles, teamroles, teammembership,
roleprivileges, privilegeobjecttypecodes) cannot be written directly; the SYSTEM row of systemuser cannot be modified.
The four standard roles cannot be deleted (0x80040220, "Standard roles cannot be deleted."); of the System
Administrator role only name and description can be changed (anything else: 0x80040220); a new role cannot use a reserved
template id as its roleid (400).
Entra identity: systemuser.azureactivedirectoryobjectid is unique among non-null values (filtered index; a
duplicate is 412 0x80040237). It is set at user Create; changing it with an Update is reserved to a System
Administrator (otherwise 0x80040220): whoever has only prvWriteSystemUser cannot relink another
user's identity (for example an administrator's) to their own token. SYSTEM and internal writes do not have this limit.
SYSTEM user: its systemuserid is derived from the organization name (organization.name, as in
SeedOptions.DeriveSystemUserId): changing organization.name would change the expected id, and the existing SYSTEM row would
no longer be recognized as such (nor protected by the rules above). Do not rename the organization.
Metadata: POST EntityDefinitions requires prvCreateEntity, POST EntityDefinitions(...)/Attributes
prvCreateAttribute (System Customizer and System Administrator have them; otherwise 0x80040220). The properties
assigned by the server (MetadataId, ObjectTypeCode, IsIntersect, IsManaged, IsCustomEntity, IsLogicalEntity,
IsPrivate, IntroducedVersion; on attributes IsManaged, IsCustomAttribute, IsLogical, AttributeOf,
IntroducedVersion) are ignored if they come from the client. SchemaName (from which the privilege names derive) is unique
among entities, case-insensitively: a duplicate is 400.
Error codes (Web API: HTTP 403 unless indicated; SOAP: fault with the same ErrorCode):
| Code | Name | When |
|---|---|---|
0x80040220 |
PrivilegeDenied | the prv… privilege is missing at the required depth; write to a system table |
0x80048306 |
AccessDenied | privilege present but record outside depth and not shared |
0x80040231 |
PrincipalPrivilegeDenied | the new owner (Assign) or the share recipient does not have prvRead on the entity |
0x80042F09 |
UserNotAssignedRoles | user without roles |
0x8004A110 |
CannotActOnBehalfOfAnotherUser | impersonation without prvActOnBehalfOfAnotherUser |
0x80048D3C |
RoleUpdateValidationFailure | assigning a role or privileges the caller does not have |
0x80090903 |
InvalidPrivilege (HTTP 400) | nonexistent privilege in AddPrivilegesRole and the like |
0x80040225 |
UserDisabled | disabled user |
0x80040333 |
DuplicateRecordsFound (HTTP 412, not 403) | a Create or Update that asked for the check (SuppressDuplicateDetection false) found a row a published duplicate detection rule matches; it is not a privilege error and is listed here for completeness (see Duplicate detection) |
Not verified on Microsoft Learn: the UserDisabled code 0x80040225, the 403 status code by code (it is the known
Dataverse behavior) and the object type codes of the intersects (principalobjectaccess 11, roleprivileges 12,
systemuserroles 15, teammembership 23, privilegeobjecttypecodes 31, teamroles 40).
Duplicate detection (see Duplicate detection): duplicaterule, duplicaterulecondition and
duplicaterecord get the usual generated privileges (prvCreateDuplicateRule, prvReadDuplicateRule, …). System
Administrator holds all of them; System Customizer holds every privilege on duplicaterule and duplicaterulecondition and
Read on duplicaterecord, at Global depth (an organization seeded by an earlier version gets these grants once at startup).
Creating, updating or deleting a condition also needs Write on its rule. PublishDuplicateRule and
UnpublishDuplicateRule need Write on the rule; whether Dataverse has a separate publish privilege is not verified. A lookup
(RetrieveDuplicates) reads the matches as the caller, so record security and field security apply to what it returns, and it
needs Read on the base table and on the given row; the Create/Update check counts matching rows whatever the caller can read.
The state of a rule (statecode, statuscode) changes only through the two messages; a write that sets it is a 400 for every
caller but the server itself.
Activities and notes
The activity tables share one privilege set instead of one per table (see Activities and notes):
- Privileges.
email,task,phonecall,appointment, every custom activity andactivitypointeruseprvCreateActivity,prvReadActivity,prvWriteActivity,prvDeleteActivity,prvAppendActivity,prvAppendToActivity,prvAssignActivityandprvShareActivity;annotationuses the same eight verbs withNote(prvReadNote, …). The privilege rows belong toactivitypointerandannotation, so deleting a custom activity keeps them and every role's grants. There is noprvReadEmailand no privilege foractivityparty. Per-table activity privileges are a possible later improvement (#66). - Basic User holds all sixteen at Basic depth (Assign and Share included; the depths are from memory). An organization
created earlier gets them once at startup (top-up
activity-role-privileges). activitypointer. A pointer row carries its activity's owner columns, so the usual depth, business unit and hierarchy rules apply withprvReadActivity. Shares and access teams are recorded on the activity, with its own table's type code; the pointer's read filter matches them by id and by the row'sactivitytypecode, so a share of an email opens that email's pointer row, and a share of another table's record that happens to have the same id opens nothing. A table left out of hierarchy security is left out through the pointer as well.activityparty. A party row is readable when the pointer row of its activity is; there is no privilege to check. A link fromactivitypartynever opens the rows of activities the caller cannot read.- Writing parties. Naming a record in a party list needs Read on it: no Read privilege on its table is 403
PrivilegeDenied, a record out of reach is 403AccessDenied, a missing record is 404, and nothing is written. Append and AppendTo on the party record are not needed. The sender thatSendEmailfills in is the caller's own user and is not checked. Reads are not filtered by party record: a reader of an activity sees each party's name and address. - Field security. The common activity columns of every activity table, every column of
activitypointerandannotation.documentbodycannot be secured (400FieldNotSecurable), because the pointer copy and the note body are read outside the column checks. Other columns of an activity table can be secured. - System table rules. Create, Update, Delete, Assign, SetState, GrantAccess and ModifyAccess of
activitypointer, and Create, Update and Delete ofactivityparty, are refused (400) for every caller, System Administrator and SYSTEM included. - Note bodies are read only for the notes a query returns, so they follow the note's read access; their
fileattachmentrows need Read on the note.
Classic workflows
Processes are rows of workflow, with the generated privileges (prvReadWorkflow, prvWriteWorkflow, …); see
Classic workflows.
- Grants. System Customizer holds every privilege on
workflowandprocesstriggerat Global; System Customizer and Basic User holdprvWorkflowExecutionat Global (its id is derived like every xrmverse privilege id; the depths are xrmverse's choice). An organization created earlier gets them once at startup (top-upsworkflow-role-privilegesandworkflow-execution-role-privilege). - Authoring. Creating a process needs
prvCreateWorkflow, updating itprvWriteWorkflowat the record's depth; listing, exporting and everyapply(a dry run too) needprvReadWorkflow. The authoring API and the MCP tools need no role: the privileges decide. See Workflow authoring as code. - Activating, deactivating, assigning, deleting a process needs Write, Assign or Delete on its definition, as for any row.
Activating or deactivating also needs the caller to own the definition or hold
prvActOnBehalfOfAnotherUser, plusprvActivateSynchronousWorkflow(real-time workflow, action) orprvActivateBusinessRule(business rule); a background workflow needs neither. System Customizer holds both new privileges at Global through the named grantworkflow-activation-role-privileges(the depths are xrmverse's choice, from memory); Basic User holds neither; a solution import is exempt. - Raw writes. An Update re-validates the XAML only when it sends a definition column (the XAML, mode, trigger or stage columns), so an imported Draft with unsupported steps, or of category 1, 4 or 5, can still be renamed. A public Create or Update of
workflowis accepted for a Draft definition of category 0, 2 or 3 whose XAML parses, validates and has no unsupported step;processtriggeris still written by solution import only. ExecuteWorkflowneeds Read on the process,prvWorkflowExecutionand Read on the record.- As whom steps run. A real-time workflow with
runas1 runs as the caller, withrunas0 as its owner; a background workflow runs as its owner; an on-demand run as the user who starts it; an action as its caller; a child workflow as the child's owner. Every step is a request checked with that user's privileges, record access and field security, so a step the user may not do fails with the usual 403.
Access teams
An access team template lets a user be given a fixed set of rights on one record, without creating a team by hand. A
table is switched on with AutoCreateAccessTeams, a template names the table and the rights, and a call to
AddUserToRecordTeam puts a user on the record's own access team for that template. The team is created on the first add, it
holds one share of the record with the template's rights, and its members read (and, if the template says so, write) the
record from then on. It adds no new check to the security predicate: the share is an ordinary principalobjectaccess row
granted to a team, which the read and write checks already honor.
Switching a table on. AutoCreateAccessTeams is a table setting. It is set on CreateEntity and UpdateEntity (SDK and
PUT EntityDefinitions(...)), in the web admin (the Auto-create access teams switch of a table), with
xrmd access-team enable <table>, or by a solution import (<AutoCreateAccessTeams> in customizations.xml), and it is
exported. Rules, all 400 InvalidArgument:
- only user-owned and team-owned tables, never organization-owned, business-owned or intersect tables;
- at most 100 tables are switched on at the same time (
MaxEntitiesEnabledForAutoCreatedAccessTeams, a fixed constant); - the setting cannot be turned off while a template for the table exists;
DeleteEntityof a table that was switched on is refused while a template or an access team refers to it.
Templates are rows of teamtemplate (object type code 92, entity set teamtemplates, organization-owned; see
Tables in every organization). Creating, reading, changing and deleting them needs the
teamtemplate privileges (prvCreateTeamTemplate, prvReadTeamTemplate, prvWriteTeamTemplate,
prvDeleteTeamTemplate, prvAppendTeamTemplate and prvAppendToTeamTemplate, Global depth only), which only the System
Administrator role holds. Rules, all 400 InvalidArgument:
objecttypecodemust be the object type code of a table that is switched on, and cannot change on Update;defaultaccessrightsmaskis a non-empty combination of Read 1, Write 2, Append 4, AppendTo 16, Delete 65536, Share 262144 and Assign 524288 (theAccessRightsvalues); any other bit, or 0, is refused;- at most 4 templates per table (
MaxAutoCreatedAccessTeamsPerEntity, a fixed constant); issystemis read-only: a request that names it is refused, even with the stored value.
A changed mask applies only to teams created afterwards; the share of an existing team is not touched. Deleting a template
deletes its access teams (relationship teamtemplate_Teams, Delete Cascade), with their memberships and shares, so its
members lose the access on their next request.
The access team. The first AddUserToRecordTeam for a record and a template creates one team (at most one per record and
template: a unique index guarantees it, also for parallel calls):
| Column | Value |
|---|---|
name |
{recordid}+{teamtemplateid}, lowercase GUIDs in "D" format |
teamtype |
1 (access team) |
systemmanaged |
true |
businessunitid |
the record's owningbusinessunit |
administratorid |
the caller (SYSTEM when the call is internal) |
regardingobjectid |
the record: a Lookup whose targets are the tables that are switched on |
teamtemplateid |
the template |
The team's share of the record carries the template's mask at that moment. The call then adds the user as a member. The security cache is invalidated on commit, so the change takes effect on the next request.
The messages. AddUserToRecordTeam and RemoveUserFromRecordTeam take Record (an EntityReference), SystemUserId and
TeamTemplateId, and answer AccessTeamId:
- SDK and SOAP:
AddUserToRecordTeamRequestandRemoveUserFromRecordTeamRequest. - Web API:
POST systemusers({id})/Microsoft.Dynamics.CRM.AddUserToRecordTeamwith{ "Record": { "@odata.type": "Microsoft.Dynamics.CRM.account", "accountid": "<id>" }, "TeamTemplate": { "teamtemplateid": "<id>" } }, answering{ "AccessTeamId": "<id>" };RemoveUserFromRecordTeamhas the same shape. A missing or untypedRecordis 400. - CLI:
xrmd access-team add|remove(see CLI).
Checks, in this order (the first that fails answers):
- the template exists (404
ObjectDoesNotExist); Recordis a row of the template's table (400);- the table is switched on (400);
- the record exists (404);
- the user exists (404) and is enabled (400; this applies to
RemoveUserFromRecordTeamtoo); - the caller holds
prvShareon the table at a depth that covers the record's owner, and holds certain rights on the record (roles, shares and hierarchy all count), otherwise 403 (own text); a System Administrator passes.AddUserToRecordTeamchecks every right it hands out, so a caller cannot use a template to hand out a right they lack: the template's current mask and, when the record's team for the template already exists, the rights of that team's share of the record (a new member receives the share, which can be wider than the template if the template was narrowed after the team was created).RemoveUserFromRecordTeamchecks only the template's current mask: it only takes rights away, so the wider share of an existing team is not asked for. This is the fail-closed choice; the checklist asks what Dataverse checks; AddUserToRecordTeamonly: the added user holds, at Basic depth or wider, the privilege of each of those rights on the table (prvReadfor Read,prvWritefor Write,prvAppend,prvAppendTo,prvDelete,prvShareandprvAssignlikewise), otherwise 400 naming the missing privilege (own text). The check applies whoever the caller is, a System Administrator included; it is not made for the internal caller (a request run by the server itself), and the built-in SYSTEM user as the added user is exempt.
Repeating an add is a no-op that returns the same team; removing a user who is not a member is a no-op.
RemoveUserFromRecordTeam answers Guid.Empty when the record has no team for the template yet. The team and its share stay
when the last member leaves.
Locking. The core locks the template, then the record, then the team's key until the commit, so the adds and removes
for one template are serialized (and so are a template Delete and a record Delete against the calls): two parallel first
adds create one team and one share, and both users end up members. If the template's mask widens, or the existing team's share gains
a right, between the guard's rights check and the write, the call is refused with 400 and can be retried. A pre-operation plug-in that changes Record,
SystemUserId or TeamTemplateId also gets 400, because the rights were checked on the original values.
Records and teams.
- Deleting a record deletes its access teams (memberships and shares go with them), and only its own: a team names its
record by
regardingobjectidandregardingobjectidtype, so the Delete of a record of another table that has the same id leaves them alone. Assigning or sharing a record leaves them as they are. - Revocation is immediate: the next Retrieve, RetrieveMultiple,
$expandor file download of the removed user is refused. - System-managed teams are written only by the two messages. A request that creates a
teamwithteamtemplateid,regardingobjectidorsystemmanagedis 400, and so is an Update that changes one of them (a Retrieve-then-Update that sends the stored values back passes, as forteamtype).AddMembersTeam/RemoveMembersTeamand an Associate or Disassociate ofteammembershipon a system-managed team are 400 ("use AddUserToRecordTeam/RemoveUserFromRecordTeam");AddMembersTeamandRemoveMembersTeamare not implemented in xrmverse (see Deferred), so today they answer "not supported", and the rule starts to act when they are built. Deleting a system-managed team stays allowed with the team privilege. - A system-managed team is shared only on its own record, and only by the record team messages:
GrantAccessorModifyAccessof any record to it is 400 ("a system-managed access team gets access only to its own record through AddUserToRecordTeam"), for every caller, System Administrator included. Learn says such a team "can't be shared with other records"; refusing a change of its own share too keeps its rights those the adders were checked for. The share the first add creates is written by the server directly, so it is not affected. - An access team owns no record. Learn: "An access team doesn't own records". A
CreateorUpdatewhoseowneridnames a team withteamtype1, and anAssignto one, are 400InvalidArgument("An access team cannot own records.", own text), whether the team is system-managed or made by hand, for every caller, SYSTEM and System Administrator included (only a request run by the server itself is exempt). Without the rule, a record owned by a system-managed team would give each memberAddUserToRecordTeamadds owner-level access, beyond the template rights the adder was checked for. The core operation also refuses an owner team of another type ("is not an owner team"), and ownership never moves through an Associate, so this rule refuses the request earlier, with one message, before the pre-operation plug-ins run. - A field share (
principalobjectattributeaccess, see Field-level security) never names a system-managed team: a Create whoseprincipalidis such a team, or an Update of a share whose principal is one, is 400InvalidArgument(own text), for every caller, System Administrator included. A column share would reach each new member without the adder being checked for it, as forGrantAccessabove. A field share to an access team made by hand stays allowed. - Plug-ins: both messages are SDK messages with
sdkmessagerows and no table filter; steps at stages 10, 20 and 40 run (AccessTeamIdis in the stage 40 output). The team, the membership and the share are written by the core, so the steps ofCreate,GrantAccessandAssociatedo not fire for them. - Auditing: creating the team writes one Share audit row on the record (principal = the team), as
GrantAccessdoes when the table is audited. Adding and removing a member write no audit row (team membership is not audited).
Known limits
- The two organization settings behind the limits (4 per table, 100 tables) are not modelled; the values are constants.
- A disabled user cannot be removed from an access team:
RemoveUserFromRecordTeamanswers 400 for a disabled user, asAddUserToRecordTeamdoes (ruling A7). A disabled user gets no access anyway. To clean up, re-enable the user, remove them and disable them again, or delete the whole team (with the team privilege; the nextAddUserToRecordTeamcreates a new one). - Shares do not cascade to related child records. In Dataverse a template's rights apply to the record and to its related
records according to the relationships' cascade rules (Share). xrmverse never cascades a share, whoever grants it
(
GrantAccessdoes not either: see "cascade of share/assign to child records" under Deferred), so the access team's share covers its own record only; the access-team case inherits that general limit. - A team template inside a solution is not exported or imported: only the
AutoCreateAccessTeamsflag of the table travels (see Solutions). The checklist asks what Dataverse writes. - The form sub-grid "Associated Record Team Members" is UI work of phase 3, and
RetrieveSharedPrincipalsAndAccessis not implemented, so no message lists the access team. - A user-made access team (
teamtype1 withGrantAccess) works as before; templates add nothing to it. - The names
team_regardingobjectid_<table>of the relationships thatregardingobjectidgets per table, the object type code 92, the cascade ofteamtemplate_Teams, the team name format and every error code of an access team failure are from our reading of the Microsoft documentation or from memory, not from a real environment. The manual checklist isdocs/superpowers/acceptance/access-teams-checklist.md.
Hierarchy security
The manager hierarchy lets a manager work with the records of the people who report to them. It is off by default and
needs no position model: only the manager model exists (systemuser.parentsystemuserid, relationship user_parent_user).
A System Administrator switches it on and sets its depth with xrmd hierarchy-security, the Admin API or the web admin
Hierarchy security page (see CLI, Admin API and Web admin).
Settings (columns of organization, changed by a System Administrator only; anyone else who changes one gets 403
0x80040220, while an Update that repeats the stored values passes):
| Column | Default | Meaning |
|---|---|---|
ishierarchicalsecuritymodelenabled |
false | the model is on |
maxdepthforhierarchicalsecuritymodel |
3 (from memory, unverified) | levels of reports a manager reaches; 1 to 100, else 400 |
usepositionhierarchy |
false | true is refused with 400 ("the position hierarchy is not supported") |
Reach of a manager M (model on, depth d):
- The reports are the users found by walking
parentsystemuseriddown from M: level 1 is the direct reports, down to leveld. Disabled reports count. A cycle in the data (written by SQL) cannot loop: each user is visited once. - A report counts only when M's business unit is the report's business unit or its parent. The rule is judged for each
report on its own: the walk goes on through a report that fails it, and the reports below that one are judged by
themselves. It follows the Dataverse default (
ManagersMustBeInSameOrParentBusinessUnitAsReports= true) and is not configurable. - A report's records are those it owns, those owned by any team it belongs to (xrmverse has no default team per business unit yet) and those shared with it or with one of its teams.
- Rights. On the records of direct reports: Read, Write, Append and AppendTo. On the records of reports at levels 2 to
d: Read only. Never Create, Delete, Assign or Share:GrantAccessandModifyAccessare never allowed through the hierarchy alone. Share must come from the manager's own role (a depth that covers the owner) or from a share of the record; a manager who holds Share that way can grant the rights it gets through the hierarchy (Write on a direct report's record, for example), never Delete or Assign. On a record that the manager reaches only through a share, the manager gets at most the rights of that share. - M must hold the matching privilege (
prvRead,prvWrite,prvAppend,prvAppendToon the table) at any depth. A System Administrator, or a role with Global depth, already reaches everything. - Reads add the reach as a predicate to the SQL query, like the other depths. Under impersonation the effective rights are
the AND of both users, so the reach of the impersonated manager never exceeds what the impersonator may do. A plug-in
that runs as the manager through
CreateOrganizationService(userId)gets the manager's reach. - A change to
parentsystemuserid, to a team or its members, or to the settings takes effect on the next request, without a restart.
Tables covered. Tables owned by users or teams, including new custom tables. Organization-owned and business-owned
tables, intersect tables and fileattachment (which follows its parent record) are never affected. A System Administrator
can exclude tables (xrmd hierarchy-security exclude <table>; stored in meta.hierarchy_security_exclusion): an
excluded table, or a table that no longer exists, is skipped. Exclusions do not travel in solutions.
Managers. A user cannot be their own manager and the chain cannot loop: both are 400 InvalidArgument, checked on every
write of parentsystemuserid (a loop already present above the new manager, written by SQL, is refused with the same code
and a message that names it) (on Create of a user that names itself, the security guard is expected to answer 404 because the new user does not exist
yet: this is observed behaviour, not covered by a request-level test, and the checklist compares it with Dataverse).
Where else the hierarchy shows. The tree operators under, eq-or-under, above, eq-or-above and not-under work
on every table that has a hierarchical relationship (account through account_parent_account, systemuser through
user_parent_user, a custom self-referencing relationship that a solution import marks IsHierarchical), and eq-useroruserhierarchy and
eq-useroruserhierarchyandteams filter by the caller and the caller's reports: see Supported queries.
Known limits
- Only the manager model. The position model (
position,systemuser.positionid) is not built (H1). ManagersMustBeInSameOrParentBusinessUnitAsReports= false is not available (H4).- There is no dedicated "change hierarchy security settings" privilege: only System Administrators change the settings. The checklist records Dataverse's privilege; a follow-up may add it to the catalog (H2).
- The Web API query functions (
Microsoft.Dynamics.CRM.Underand the others) are not supported in$filter, like every query function; FetchXML (?fetchXml=) andQueryExpressionwork (H9). rowaggregate="CountChildren"is refused with 400 (H8).- The hierarchy visualization and
hierarchyruleare UI work of phase 3, andRetrievePrincipalAccessis not implemented (see Deferred), so there is nothing that reports access gained through the hierarchy. - Field-level security does not follow the hierarchy. A secured column is read and written according to the caller's own field security profiles and field shares (and, under impersonation, the AND of both users), never the reach, the profiles or the shares of the reports. The field-share rules check Read on the record, and that check includes the hierarchy reach, so a manager who holds the column rights can create a field share on a report's record. The checklist compares this with Dataverse (see Field-level security).
- The default depth 3 and the cascade of
user_parent_userare from memory; the error codes of hierarchy failures are our own generic ones. The manual checklist isdocs/superpowers/acceptance/hierarchy-security-checklist.md.
Differences and xrmverse choices
role.roletemplateidis read-only: no request writes it (400), not even a System Administrator's.team.teamtypeis fixed at creation: an Update with a type different from the stored one is rejected (400 "The team type cannot be changed."). A NULLteamtypecounts as Owner (0) everywhere: record ownership, team roles and the escalation check.- Adding a member to a team requires Write on the team and, for an owner team, the same escalation check
as assigning each of the team's roles (
RoleUpdateValidationFailure; a team with System Administrator can be widened only by a System Administrator). Whoever has Write on teams without those privileges cannot add members to teams more powerful than themselves. - Changing the business unit of a user or team does not move the
owningbusinessunitof the records they own. CallerObjectIdtakes precedence overMSCRMCallerID; via SOAP theCallerIdheader replaces the HTTP header. A SOAPHeaderafter theBodymakes the request malformed (fault0x80040203, "The SOAP request is malformed.").- Asynchronous jobs run as the owner of the
asyncoperationrow (ownerid=UserIdof the context that queued them);createdbyis theInitiatingUserId, which the job restores. The Entra object ids of a job's context are read from thesystemuserrows of these two users, not from the payload. An asynchronous step whose impersonation user does not exist or is disabled fails immediately (3/31, without retry). IPluginExecutionContext3.AuthenticatedUserIdis the authenticated user of the request (the impersonator if there is impersonation); nested calls inherit it.- Registering plugins is equivalent to being able to act as SYSTEM: a plugin's code can call
CreateOrganizationService(null)and write without restrictions. Whoever has registration privileges (System Customizer included) must therefore be considered as trusted as an administrator; theprvActOnBehalfOfAnotherUsercheck on steps withimpersonatinguseridis defense in depth, not a security boundary. - Security checks run before stage 10, on the request parameters: a PreValidation or
PreOperation plugin can still change
Target(for example add columns or changeownerid) after the check, and the change is not rechecked. This too presupposes trusted plugins. - Nested plugin calls (
CreateOrganizationService(context.UserId)) run with all the privileges of the impersonated user: the impersonator is not propagated, so the privilege intersection applies only to the top-level request. - The deserialization allowlist (SOAP and asynchronous payloads) admits only .NET primitives, types and enums of
Microsoft.Xrm.Sdk/Microsoft.Crm.Sdk.Proxy, their arrays and early-bound subclasses ofEntity,OrganizationRequest,OrganizationResponse:DBNulland every other .NET type are rejected (a plugin that puts a different type inSharedVariablesfails when queued asynchronously). - The privilege cache is invalidated entirely after every write to users, teams, BUs, roles, privileges or intersects.
- Associate/Disassociate 1:N require Append on the referencing record and AppendTo on the referenced one.
- Create with an
owneriddifferent from the caller evaluates the Create depth on the owner, without requiring Assign. - Lookup names remain visible even if the record pointed to is not readable.
- Custom actions have no privilege checks. A custom API with an
executeprivilegenamedemands that privilege, at any depth, before stage 10 (4030x80040220); one without it, like a custom action, checks nothing. Its plug-in type then runs as the caller.
Deferred
- Plan 6:
xrmd org createand thexrmd user/rolecommands. - Phase 2: hierarchy security, field-level security and access team templates are done: see Hierarchy security, Field-level security and Access teams.
- Out of the MVP:
RetrievePrincipalAccess,RetrieveUserPrivileges,RetrieveRolePrivilegesRole,RetrieveSharedPrincipalsAndAccess,AddMembersTeam/RemoveMembersTeam,SetBusinessSystemUser, default team per BU, cascade of share/assign to child records.