Skip to content

Tools Security

Ryan James edited this page Aug 17, 2026 · 3 revisions

Security tools

This page is generated from the code by scripts/gen_wiki_tools.py — do not edit by hand.

22 tools · category token security · enable with DATAVERSE_TOOLS=security (unset enables every category)

Tool-Index · Home

Write tools additionally require DATAVERSE_ALLOW_WRITE=true; delete tools require DATAVERSE_ALLOW_DELETE=true.


dataverse_add_team_members

Write · idempotent · category security

Add one or more system users to a Dataverse team.

Issues one $ref POST per user against the teams(<teamId>)/teammembership_association navigation property. Returns per-user results. Requires DATAVERSE_ALLOW_WRITE=true.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
team_id str yes GUID of the team to add members to. (min_len=36)
user_ids list[str] yes List of system user GUIDs to add as team members. At least one user_id must be provided. (min_len=1)

Returns {"team_id": ..., "results": ..., "total": ..., "succeeded": ..., "failed": ...}. Errors return {"error": true, "message": "..."}.


dataverse_assign_security_role

Write · idempotent · category security

Assign a security role to a user or team via the Web API $ref association.

Provide role_id and exactly one of user_id or team_id. For users: associates via systemuserroles_association on the systemusers entity. For teams: associates via teamroles_association on the teams entity. Requires DATAVERSE_ALLOW_WRITE=true.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
role_id str yes GUID of the security role to assign. (min_len=36)
user_id str | None no None GUID of the system user to assign the role to. Provide exactly one of user_id or team_id.
team_id str | None no None GUID of the team to assign the role to. Provide exactly one of user_id or team_id.

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_audit_user_access

Read · idempotent · category security

Return a composite access report for a Dataverse system user.

Gathers in one call: user identity, direct security roles, team memberships (with each team's roles), and optionally effective privileges and record-level access rights. Resolves all type codes to human-readable names.

Provide either user_id (GUID) or user_domain_name (e.g. 'user@contoso.com'). Optionally provide target_entity_set_name + target_record_id to include a record-level access check.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
user_id str | None no None GUID of the system user to audit. Provide either user_id or user_domain_name, not both. (min_len=36)
user_domain_name str | None no None Domain name of the user to audit (e.g., 'user@contoso.com'). Provide either user_id or user_domain_name, not both. (min_len=1)
target_entity_set_name str | None no None OData collection name of a specific record to check access against (e.g., 'accounts'). Requires target_record_id. (pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
target_record_id str | None no None GUID of the specific record to check access against. Requires target_entity_set_name. (min_len=36)
include_privileges bool no True When True, includes the user's effective privilege list. Set False to skip this call and speed up the report.

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_get_attribute_change_history

Read · idempotent · category security

Retrieve the audit trail for ONE COLUMN of ONE RECORD — who changed this field.

The column-scoped sibling of dataverse_retrieve_record_change_history, which returns every change to the record across all audited columns. Use this one when the question is about a single field ('when did this account's creditlimit last change, and to what?'); it answers from the server rather than making you filter a whole record's history client-side.

MIND THE SINGULAR/PLURAL SPLIT — this tool takes the SAME TABLE TWICE, under two different names, and they are NOT interchangeable:

  • entity_set_name — the PLURAL collection name ('accounts'). This is the only one sent to the function, inside the target EntityReference.
  • table_logical_name — the SINGULAR logical name ('account'). Never sent to the function; used only by the audit-configuration probes described below, which address table metadata by LogicalName. Both are required because the plural cannot be derived from the singular (or vice versa: 'webresource' -> 'webresourceset'), and it cannot be looked up either — $filter on the root EntityDefinitions collection is refused with HTTP 400 [0x80060888]. Use dataverse_get_entity_sets to confirm the plural.

ON A 404, READ THE ERROR CODE — DO NOT ASSUME WHICH FAILURE IT IS. Live-confirmed on this function, and the older warning that a missing record and a wrong entity set were indistinguishable was wrong:

  • a well-formed but NONEXISTENT record id with the CORRECT plural entity set returns HTTP 200, not a 404. The function did not check that the record exists, so a bogus GUID (or a deleted record) simply yields zero changes and the audit_configuration diagnosis below. A successful empty answer is therefore never proof that the record is there.
  • a VALID id with the WRONG (singular) entity set returns HTTP 404 [0x80060888] "Resource not found for the segment '<name>'" — which NAMES the bad segment. That message is the singular-for-plural slip, not a missing record: fix the entity set name rather than hunting a deleted row. Do NOT read those two as an exhaustive split. On the sibling function RetrieveRecordChangeHistory, a sweep of 15 entity sets found a THIRD outcome — [0x80048d02] from a CORRECT plural entity set ('audits'), meaning the row really was absent, i.e. some entity sets DO validate the target. That sweep was run against the record-scoped function, not this one, so it is not confirmed here; it is reason enough to read the code that actually came back rather than trust a two-case rule. THE ALL-ZERO GUID IS HANDLED DIFFERENTLY BY THE TWO FUNCTIONS, live-confirmed. Passing 00000000-0000-0000-0000-000000000000 as record_id returns HTTP 200 with zero changes HERE, but the record-scoped dataverse_retrieve_record_change_history rejects the same id with HTTP 400 [0x80040203] "Expected non-empty Guid." That is Dataverse's own inconsistency between the two messages, not this server's. So an empty, successful answer from this tool can mean the caller passed a placeholder id — check the id before reading zero changes as a fact about the record.

AN EMPTY RESULT IS AMBIGUOUS, AND THIS TOOL RESOLVES IT. Audit rows are written only where auditing is enabled at organization AND table AND column level, so zero changes cannot by itself distinguish "nothing ever changed" from "auditing was never switched on". ONLY when there are zero changes, three probes fire concurrently and an audit_configuration block is attached carrying organization_audit_enabled / table_audit_enabled / column_audit_enabled and a diagnosis naming the OUTERMOST disabled level:

  • auditing_off_at_organization / auditing_off_at_table / auditing_off_at_column
  • auditing_enabled_no_changes_recorded — all three on, so the empty answer is real (auditing still only records changes made after it was switched on)
  • undetermined — a probe failed or returned an unreadable shape; the level is reported as null with the reason in probe_errors, and NOTHING is guessed.

NOT EVERY ENTRY IS A RESULT. Dataverse MAY add org-level audit-CONFIGURATION rows (auditing itself switched on or off) to a response. They arrive when an audit-configuration change falls inside the TARGET RECORD'S history window, so their presence and count VARY BY TARGET — a record created after the last such change gets none, while older records on the same org got four each, live-measured. audit_configuration_events_count: 0 is a normal, expected answer. They can arrive anywhere in the list and are identified by their SHAPE — no @odata.type, AuditRecord and nothing else, and an all-zero AuditRecord._objectid_value — never by their position. They are split out into audit_configuration_events (with audit_configuration_events_count) and are NOT counted as changes: audit_details, count and has_more cover this column's own changes only.

ENTRIES ARE POLYMORPHIC. Each change is returned verbatim, so read its @odata.type: a column-scoped call is expected to yield AttributeAuditDetail (AuditRecord, OldValue, NewValue, InvalidNewValueAttributes, plus AuditRecord.attributemask naming the changed columns) but nothing guarantees it — AuditDetail has several subtypes. An entry with an UNRECOGNIZED @odata.type is reported as a change, never quietly dropped; only the typeless AuditRecord-only shape with an all-zero objectid is treated as configuration. detail_types counts the @odata.type values actually present on the returned page, and unclassified_typeless_count reports how many entries carrying NO @odata.type were kept as changes — 0 on every response observed so far, and a non-zero value means this tool met an entry it could not name rather than that anything was lost.

RESPONSE SHAPE IS CHECKED, NOT ASSUMED. The entries sit TWO levels down (AuditDetailCollection -> AuditDetails). If that container is absent or is not a list, the tool returns normalized: false with the raw body — a missing container is NOT reported as "no changes".

PagingInfo is not sent, so the changes are trimmed client-side to top and has_more reports whether anything was cut. total_record_count appears ONLY when Dataverse supplied a real count: it is live-confirmed to arrive as -1 here both with and without PagingInfo, and a negative count is suppressed rather than passed on. It is never substituted with the number of returned entries.

URL form: GET /api/data/v9.2/RetrieveAttributeChangeHistory( Target=@t,AttributeLogicalName=@a) ?@t={"@odata.id":"accounts(<guid>)"}&@a='name'

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
entity_set_name str yes OData collection name of the record's table — the PLURAL entity set name ('accounts', 'contacts', 'new_projects'), NOT the singular logical name this tool ALSO takes as table_logical_name. Use dataverse_get_entity_sets to confirm it; the plural is irregular often enough that guessing it costs a 404. (min_len=1, max_len=64, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
record_id str yes GUID of the record whose column history to retrieve (the row's primary key, e.g. an accountid). It becomes the key predicate inside the target EntityReference, so it must be a well-formed GUID.
table_logical_name str yes SINGULAR lowercase logical name of the record's table ('account', not 'accounts'). It is NOT sent to the function — it is used only to probe whether auditing is enabled on the table and the column when the result comes back empty, which is what turns an empty answer into a usable one. (min_len=1, max_len=50, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
column_logical_name str yes Lowercase logical name of the column whose changes to retrieve ('name', 'telephone1', 'new_status'). (min_len=1, max_len=50, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
top int no 50 Maximum number of audit detail entries to return. The function is called without PagingInfo, so the list is trimmed here; has_more reports whether anything was cut. (ge=1, le=5000)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_get_audit_details

Read · idempotent · category security

Retrieve full details from a single audit record.

Calls the bound RetrieveAuditDetails function on the audit entity, returning a polymorphic AuditDetail. The most common subtype is AttributeAuditDetail which includes OldValue and NewValue (each containing the changed attribute values keyed by logical name) plus InvalidNewValueAttributes.

Common AuditDetail subtypes (identified by @odata.type):

  • AttributeAuditDetail — field changes with OldValue/NewValue
  • RelationshipAuditDetail — relationship association/disassociation
  • ShareAuditDetail — record sharing
  • RolePrivilegeAuditDetail — role privilege changes
  • UserAccessAuditDetail — user login/access events

Note: requires auditing enabled on the org. If auditing is disabled, Dataverse returns an HTTP error — check the error message for guidance.

URL form: GET /api/data/v9.2/audits(<audit_id>)/Microsoft.Dynamics.CRM.RetrieveAuditDetails

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
audit_id str yes GUID of the audit record whose full details to retrieve (e.g., 'a1b2c3d4-1234-5678-abcd-ef0123456789'). Obtain audit record GUIDs from dataverse_list_audit or dataverse_retrieve_record_change_history. (min_len=36)

Returns {"audit_id": ..., "audit_detail": ...}. Errors return {"error": true, "message": "..."}.


dataverse_get_role_privileges

Read · idempotent · category security

Answer "what can this security role actually DO?" — list a role's privileges.

Calls the unbound RetrieveRolePrivilegesRole function. This is the companion to dataverse_get_security_role, which returns the role RECORD (name, business unit, managed flag) and says nothing about what the role permits. Use dataverse_list_security_roles to find a role id by name.

Scope: this is the role's OWN privilege set. For a specific person's effective privileges across all their roles and teams use dataverse_retrieve_user_privileges, or dataverse_audit_user_access for the full access report.

RESPONSE SHAPE (verified live). Dataverse returns one top-level property, RolePrivileges, holding the whole list with no wrapper; privileges_source reports where the collection was found. Every entry carries all six of:

  • PrivilegeName — the familiar 'prvReadAccount' form. Already present on every entry, so NO extra lookup against the privileges table is made or needed.
  • PrivilegeId — GUID of the privilege.
  • Depth — the access level (see below).
  • BusinessUnitId — GUID of the business unit the depth is scoped to.
  • RecordFilterId, RecordFilterUniqueName — record-filter binding; empty on ordinary privileges. Entries are passed through exactly as Dataverse sent them: nothing is added, renamed or dropped.

Depth arrives HUMAN-READABLE and is never relabelled. OData serializes the PrivilegeDepth enum as its member NAME, and only member names were observed live ("Basic", "Local", "Deep", "Global" — increasing scope, Global being org-wide; "Basic" is the user's own records). Should a numeric PrivilegeDepth code ever arrive instead, it is reported raw: that mapping is not confirmed for this function, and a wrong access-level label is more dangerous than an unlabelled one. depth_summary counts every entry by its Depth value.

THE LIST IS BIG AND IS TRIMMED BY DEFAULT. The function has no server-side paging — it returns every privilege in one response. Measured live: a System Administrator role carries 4,132 privileges in a ~1 MB raw response. That is why top defaults to 50 (~14 KB) and why the raw payload is never echoed back on the normalized path. The magnitude is never hidden: total_count is always the full number Dataverse returned regardless of trimming, has_more says whether anything was trimmed, and depth_summary is computed over ALL entries rather than just the returned page. Raise top (max 1000) to see more.

A well-formed but nonexistent role id returns an ERROR, not an empty list: Dataverse answers HTTP 404 [0x80040217] "Entity 'role' With Id = ... Does Not Exist", surfaced through the standard {"error": true, "message": ...} envelope. An empty privileges list therefore means a real role that grants nothing.

The function's inner properties are undocumented on Microsoft Learn, so the collection is still located by shape as well as by name (RolePrivileges first, then a lone object-list at the top level, then one level down inside a named wrapper) as insurance against a future platform change. If it cannot be identified unambiguously, nothing is guessed: normalized is false, no counts are reported, and the payload comes back unchanged under raw_response (minus the @odata.* envelope) for you to read yourself.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
role_id str yes GUID of the security role whose privileges to list. Use dataverse_list_security_roles to find a role id by name, or dataverse_get_security_role if you already have one and want the role record itself.
top int no 50 See note below.
  • top — Maximum number of privilege entries to return. RetrieveRolePrivilegesRole has no server-side paging — it returns every privilege in one response, and a System Administrator role was measured live at 4,132 privileges in a ~1 MB payload — so the list is trimmed here. total_count always reports the full number Dataverse returned and has_more says whether anything was trimmed, so the magnitude is never hidden. depth_summary is computed over ALL entries, not just the returned page. Raise this to see more. (ge=1, le=1000)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_get_security_role

Read · idempotent · category security

Retrieve a single Dataverse security role by its GUID.

Returns full role details including name, business unit, and managed status.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
role_id str yes GUID of the security role to retrieve. (min_len=36)
select list[str] | None no None Columns to return. Defaults to roleid, name, _businessunitid_value, ismanaged, modifiedon.

Returns {"record": ...}. Errors return {"error": true, "message": "..."}.


dataverse_get_team

Read · idempotent · category security

Retrieve a single Dataverse team by its GUID.

Returns full team details including name, type, and business unit.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
team_id str yes GUID of the team to retrieve. (min_len=36)
select list[str] | None no None Columns to return. Defaults to teamid, name, teamtype, _businessunitid_value, isdefault, modifiedon.

Returns {"record": ...}. Errors return {"error": true, "message": "..."}.


dataverse_get_team_privileges

Read · idempotent · category security

Answer "what can this TEAM actually DO?" — list a team's privileges.

Calls the entity-bound RetrieveTeamPrivileges function on the team record. It takes no parameters of its own: the team id is the key predicate.

This completes the three-way security picture. dataverse_get_role_privileges answers it for a ROLE, dataverse_retrieve_user_privileges for a USER, and this for a TEAM — the missing third. It is the companion to dataverse_get_team, which returns the team RECORD (name, type, business unit) and says nothing about what the team permits. Use dataverse_list_teams to find a team id by name, and dataverse_audit_user_access for one person's full access report across their direct roles and team memberships.

RESPONSE SHAPE. Microsoft Learn documents the call and the return type RetrieveTeamPrivilegesResponse but NOT its inner properties. VERIFIED LIVE: the collection arrives under RolePrivileges — NOT TeamPrivileges, despite the response type name — exactly as the sibling RetrieveUserPrivileges does. The collection is still located by name first (TeamPrivileges, which has never been observed, then RolePrivileges, which is what really comes back) and then by shape: a lone object-list at the top level, then one level down inside a named wrapper. privileges_source reports where it was found, so check it. If no collection can be identified unambiguously, nothing is guessed: normalized is false, no counts are reported, and the payload comes back unchanged under raw_response (minus the @odata.* envelope) for you to read yourself.

AN EMPTY LIST IS A REAL ANSWER, NOT A FAILURE. count: 0 with normalized: true means the team has NO DIRECTLY-ASSIGNED SECURITY ROLES — a common and entirely normal state, and the usual one: most teams get their access from their members' own roles rather than from a role assigned to the team itself. Do not read it as an error, and do not read it as "this team's members have no access": members still hold their own roles, and dataverse_audit_user_access is the tool for a person's effective access.

Entries mirror RetrieveRolePrivilegesRole's, VERIFIED LIVE for teams across 484 entries: PrivilegeName ('prvReadAccount'), PrivilegeId, Depth, BusinessUnitId, RecordFilterId, RecordFilterUniqueName — one identical key set on every entry, with PrivilegeName present and populated throughout, so no name-resolution step is needed. Entries are passed through EXACTLY as Dataverse sent them — nothing is added, renamed or dropped — so trust the returned keys over this list.

Depth is never relabelled. OData serializes the PrivilegeDepth enum as its member NAME, and this function was VERIFIED LIVE to return the member name — "Basic", "Local", "Deep", "Global" (increasing scope, Global being org-wide) — as a STRING on every one of 484 entries, with no numeric PrivilegeDepth code ever arriving. Should one nonetheless arrive it is reported raw rather than mapped: a wrong access-level label is more dangerous than an unlabelled one. depth_summary counts every entry by its Depth value, over the WHOLE list before any trimming.

This function and dataverse_get_role_privileges return the SAME privilege set for a team and its assigned role, but in a DIFFERENT ORDER — verified live as equal sets, unequal sequences. Never assume the two line up by index.

THE LIST CAN BE BIG AND IS TRIMMED BY DEFAULT. The function has no server-side paging — it returns every privilege in one response — and a team carrying a broad role inherits thousands of privileges (the role function was measured live at 4,132 privileges in a ~1 MB response). top therefore defaults to 50. The magnitude is never hidden: total_count is always the full number Dataverse returned, has_more says whether anything was trimmed, and depth_summary is computed over ALL entries rather than the returned page. Raise top (max 1000) to see more.

A well-formed but nonexistent team id returns an ERROR, not an empty list — VERIFIED LIVE: Dataverse answers HTTP 404 [0x80040217] "Does Not Exist", as the role function does, and it is surfaced through the standard {"error": true, "message": ...} envelope. The two cases are therefore distinguishable: an empty privileges list is always a REAL team with no directly-assigned roles, never a bad team id.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
team_id str yes GUID of the team whose privileges to list. Use dataverse_list_teams to find a team id by name, or dataverse_get_team if you already have one and want the team record itself.
top int no 50 See note below.
  • top — Maximum number of privilege entries to return. RetrieveTeamPrivileges has no server-side paging — it returns every privilege in one response, and its sibling RetrieveRolePrivilegesRole was measured live at 4,132 privileges in a ~1 MB payload for a broad role — so the list is trimmed here. total_count always reports the full number Dataverse returned and has_more says whether anything was trimmed, so the magnitude is never hidden. depth_summary is computed over ALL entries, not just the returned page. Raise this to see more. (ge=1, le=1000)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_get_user

Read · idempotent · category security

Retrieve a single Dataverse system user by their GUID.

Returns full user details including fullname, domainname, email, and disabled status. Use dataverse_whoami to get the current caller's UserId.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
user_id str yes GUID of the system user to retrieve. Use dataverse_whoami to get the current caller's UserId. (min_len=36)
select list[str] | None no None Columns to return. Defaults to systemuserid, fullname, domainname, internalemailaddress, isdisabled, _businessunitid_value.

Returns {"record": ...}. Errors return {"error": true, "message": "..."}.


dataverse_list_audit

Read · idempotent · category security

Query the audit table with optional OData filters.

Returns audit records from the 'audits' entity set. Common columns:

  • auditid, createdon — record identity and timestamp
  • operation — 1=Create, 2=Update, 3=Delete, 4=Access, 5=Upsert
  • action — specific event code (e.g., 1=Create, 2=Update, 3=Delete, 64=User Access via Web, 65=User Access via Web Services)
  • objecttypecode — logical name of the audited entity (e.g., 'account')
  • _userid_value — GUID of the user who made the change
  • _objectid_value — GUID of the audited record
  • transactionid — groups related changes in one operation

Use dataverse_get_audit_details to fetch full before/after values for a specific audit record.

Note: requires auditing enabled on the org. If auditing is disabled, Dataverse may return an empty result set or an HTTP error.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
filter str | None no None OData $filter expression to narrow results. Use lowercase logical names. Examples: "operation eq 2" (Updates only), "objecttypecode eq 'account'", "createdon gt 2024-01-01T00:00:00Z", "_userid_value eq '<guid>'"
select list[str] | None no None Columns to return. Defaults to auditid, createdon, operation, action, objecttypecode, _userid_value, _objectid_value, transactionid.
orderby list[str] | None no None Sort order. Each entry is 'column_name asc' or 'column_name desc'. Example: ['createdon desc']
top int no 50 Maximum number of audit records to return. (ge=1, le=5000)

Returns {"records": ..., "count": ..., "has_more": ...}. Errors return {"error": true, "message": "..."}.


dataverse_list_business_units

Read · idempotent · category security

List business units in the Dataverse environment.

Returns businessunitid, name, parent business unit, disabled flag, and modifiedon. Use filter to narrow results (e.g., "isdisabled eq false").

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
filter str | None no None OData $filter expression to narrow results (e.g., "isdisabled eq false").
select list[str] | None no None Columns to return. Defaults to businessunitid, name, _parentbusinessunitid_value, isdisabled, modifiedon.
top int no 50 Maximum number of business units to return. (ge=1, le=5000)

Returns {"records": ..., "count": ..., "has_more": ...}. Errors return {"error": true, "message": "..."}.


dataverse_list_privileges

Read · idempotent · category security

List the privileges DEFINED in the environment — the catalogue of what CAN be granted.

This is the reference list, not an assignment. dataverse_get_role_privileges, dataverse_get_team_privileges and dataverse_retrieve_user_privileges answer "who HOLDS what"; this answers "what privileges exist, what access right does each carry, and at which depths can it be granted". Use it to look up the privilege behind a name those tools return ('prvReadAccount'), or to enumerate everything that exists for one table.

ACCESS RIGHTS ARE DECODED BY A HAND-ROLLED MAP, AND THAT IS THE POINT. The accessright column is an integer with NO option set behind it anywhere in Dataverse: the PicklistAttributeMetadata cast 404s, GlobalOptionSetDefinitions for it 404s, and annotation-included FormattedValues return only the integer with thousands separators. So access_right_name comes from a map derived empirically and cross-checked across every privilege in the environment: 0 None · 1 ReadAccess · 2 WriteAccess · 4 AppendAccess · 16 AppendToAccess · 32 CreateAccess · 65536 DeleteAccess · 262144 ShareAccess · 524288 AssignAccess The gaps are real (8 and 16-32768 are unused), so a name is never derived by shifting bits. AN UNRECOGNISED VALUE IS REPORTED RAW: access_right still carries it, access_right_name is ABSENT, and the value is listed under unmapped_access_rights. Nothing is invented — a wrong access-level label is more dangerous than an unlabelled one, the same discipline dataverse_get_team_privileges applies to Depth. accessright 0 marks the non-CRUD privileges (prvActOnBehalfOf... and friends); it is a real value, not "unknown".

depths COLLAPSES THE FOUR canbe* FLAGS into one ordered list, e.g. ["Basic","Local","Deep","Global"] — the depths at which that privilege may be granted, by increasing scope (Basic = the user's own records, Global = org-wide). Only six combinations exist in practice and nearly every privilege allows Global. An EMPTY depths list is unexpected and means the flags could not be read, not that the privilege can be granted nowhere.

total_count COMES FROM AN AGGREGATION, NOT @odata.count. On this collection @odata.count CAPS AT 5,000 and lies — ?$count=true reports 5,000 where the true catalogue is ~7,346 — so the count is taken with $apply=aggregate($count as c), which bypasses the cap. If a trustworthy total cannot be obtained, total_count is OMITTED and message says so; a capped number is never reported as the truth. count is the size of the returned page, has_more says whether anything was trimmed.

TABLE SCOPING GOES THROUGH A JOIN TABLE, NOT THROUGH PRIVILEGE NAMES. Passing table_logical_name queries privilegeobjecttypecodesset, whose objecttypecode column holds the table's LOGICAL NAME STRING. Filtering by name instead — endswith(name,'Account') — is WRONG in general even though it looks right on the tables people test with: endswith(name,'Role') returns 25 privileges spanning FOUR different tables (role, connectionrole, relationshiprole, mspp_webrole). Privileges are also many-to-many with tables (one privilege can map to as many as 14), which a name can never express. That join table is private and undocumented, so if it fails you get a clear error naming it — never a silent fall back to name matching.

AN UNKNOWN TABLE NAME IS AN ERROR, NOT AN EMPTY LIST. objecttypecode is an EntityName column, so Dataverse validates it: an unknown, misspelled or plural logical name answers HTTP 400 [0x80041102] "The entity with a name = '…' with namemapping = 'Logical' was not found in the MetadataCache", naming the offending entity — that message is the reliable signal for a bad table name, and it is what the error surfaces first. Casing is never the cause: table_logical_name is lowercased for you, matching name_startswith's case-insensitivity on both routes. An EMPTY privileges list means the opposite — the table EXISTS and genuinely has no privileges mapped to it, live-confirmed on 'privilege' itself, which returns 0.

source names the route that actually ran ('privileges' or 'privilegeobjecttypecodesset'); the response shape is identical either way. On the join route, name_startswith and access_right are applied client-side, so count/total_count describe the filtered set.

Each entry: name, privilege_id, access_right (raw integer), access_right_name (absent when unknown), depths, can_be_entity_reference, can_be_parent_entity_reference. Bulky and empty columns are deliberately dropped (privilegetype does not exist on this entity at all).

If the response carries no readable collection, nothing is guessed: normalized is false, no counts are reported, and the body comes back under raw_response. An empty privileges list with normalized: true is a real answer; a missing container is not.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
table_logical_name str | None no None See note below.
name_startswith str | None no None Optional prefix of the privilege NAME to filter on, case-insensitively ('prvRead', 'prvCreate', 'prvAppendTo'). Privilege names follow the prv{Verb}{Table} form, so a prefix is the natural way to ask 'every read privilege'. Combine it with table_logical_name to narrow one table's privileges further. (min_len=1, max_len=100)
access_right Literal['None', 'ReadAccess', 'WriteAccess', 'AppendAccess', 'AppendToAccess', 'CreateAccess', 'DeleteAccess', 'ShareAccess', 'AssignAccess'] | None no None Optional access right to filter on, given by NAME. The name is translated to its integer here — no caller text is ever interpolated into the query. Note that 'None' is a REAL access right (the value 0, carried by non-CRUD privileges such as prvActOnBehalfOfAnotherUser), not a way of saying 'no filter' — omit the field entirely for that.
top int no 50 Maximum number of privileges to return. The environment-wide catalogue runs to roughly 7,300 rows, so the list is trimmed. total_count reports the TRUE total (obtained by aggregation, because @odata.count caps at 5,000 on this collection and under-reports) and has_more says whether anything was trimmed, so the magnitude is never hidden. (ge=1, le=5000)
  • table_logical_name — Optional SINGULAR logical name of a table ('account', not 'accounts') to scope the list to that table's privileges. The value is LOWERCASED for you — Dataverse logical names are always lowercase and it rejects 'Account' outright — so only the singular/plural distinction is yours to get right. Scoping goes through a join table, NOT by matching privilege names: matching on the name is wrong in general (endswith(name,'Role') returns 25 privileges spanning role, connectionrole, relationshiprole and mspp_webrole), it just happens to look right on the tables people test with. Omit it to list the whole environment-wide privilege catalogue. (min_len=1, max_len=50, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_list_security_roles

Read · idempotent · category security

List security roles in the Dataverse environment.

Returns roleid, name, businessunitid, managed status, and modifiedon. Use filter to narrow results (e.g., "ismanaged eq false"). Use dataverse_get_security_role for full details on a specific role.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
filter str | None no None OData $filter expression to narrow results. Use lowercase logical names (e.g., "ismanaged eq false", "_businessunitid_value eq '<guid>'")
select list[str] | None no None Columns to return. Defaults to roleid, name, _businessunitid_value, ismanaged, modifiedon.
top int no 50 Maximum number of roles to return. (ge=1, le=5000)

Returns {"records": ..., "count": ..., "has_more": ...}. Errors return {"error": true, "message": "..."}.


dataverse_list_shared_principals

Read · idempotent · category security

Answer "WHO has this record because it was SHARED with them?".

Merges two unbound Web API functions that answer one question:

  • RetrieveSharedPrincipalsAndAccess — the principals the record was shared with, and the access rights each was given.
  • RetrieveSharedLinks — the existing shared links over the record that the caller is allowed to see.

This is the list neither neighbouring tool can produce. dataverse_retrieve_principal_access answers "which rights does ONE named principal have" (the mask), dataverse_retrieve_access_origin answers "WHY does ONE named principal have them" — both need you to already know who to ask about. This one enumerates them.

MIND THE INPUT ASYMMETRY. This tool takes the PLURAL entity_set_name ('accounts'), because the target is expressed as an OData EntityReference and an @odata.id names a collection. dataverse_retrieve_access_origin takes the SINGULAR logical_name ('account'). Confusing the two is the easy caller error here — use dataverse_get_entity_sets to confirm the plural, which is irregular often enough ('webresourceset') that guessing costs a 404.

A WRONG ENTITY SET NAME LOOKS EXACTLY LIKE A MISSING RECORD. VERIFIED LIVE: a nonexistent record id and a VALID id paired with the WRONG entity set both return HTTP 404 [0x80040217] "Entity '<Type>' With Id = <guid> Does Not Exist" from BOTH functions — the same status, the same error code, indistinguishable text. Both calls therefore fail and you get the standard {"error": true, "message": ...} envelope. So when this tool errors with "Does Not Exist", CHECK THE ENTITY SET NAME FIRST (plural — 'accounts', not 'account'; see the asymmetry note above) before concluding the record is gone. That singular-for-plural slip is the likeliest cause and it misdiagnoses as a missing record.

THE TWO CALLS FAIL INDEPENDENTLY. Each is made on its own: if one is unavailable or privilege-gated, its failure is reported in partial_errors and the other's data is still returned. Only a failure of BOTH yields the standard {"error": true, "message": ...} envelope. RetrieveSharedLinks is in principle the more likely of the two to be missing (it was available on the org tested, never landing in partial_errors), so a partial_errors entry naming it is an expected outcome rather than an error — check partial_errors before concluding a record is unshared.

RESPONSE SHAPES (verified live). Microsoft Learn documents RetrieveSharedPrincipalsAndAccessResponse but not its inner properties; live runs confirm the collection arrives under PrincipalAccesses, and that is the name tried first before the by-shape fallback. RetrieveSharedLinks returns Collection(team), an ordinary OData collection, and its entries duly arrive under the standard 'value' property. Each block reports the source it was found under — check it. If a payload cannot be identified unambiguously that block carries normalized: false, no counts, and the raw payload (minus the @odata.* envelope) under raw_response; nothing is fabricated.

Both lists are trimmed to top (default 50, max 1000) because neither function pages server-side. count, total_count and has_more are reported per block and total_count is always the full number Dataverse returned.

An empty result is NOT proof the record is private: these functions report explicit shares (the POA table) that the CALLER can see, not access granted by ownership, security roles, team membership or the business-unit hierarchy. Use dataverse_retrieve_access_origin for a specific principal, and read partial_errors before drawing any conclusion.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
entity_set_name str yes OData collection name of the record's table — the PLURAL entity set name ('accounts', 'contacts', 'new_projects'), NOT the singular logical name dataverse_retrieve_access_origin takes. Use dataverse_get_entity_sets to confirm it ('account' -> 'accounts'); the plural is irregular often enough that guessing it costs a 404. (min_len=1, max_len=64, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
record_id str yes GUID of the record whose shares to list (the row's primary key, e.g. an accountid). It becomes the key predicate inside the target EntityReference, so it must be a well-formed GUID.
top int no 50 Maximum number of entries to return from EACH of the two functions. Neither has server-side paging, and a heavily shared record can carry many principals, so each list is trimmed here. total_count and has_more are reported per list and always describe the full set, so the true magnitude is never hidden. (ge=1, le=1000)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_list_teams

Read · idempotent · category security

List teams in the Dataverse environment.

Returns teamid, name, teamtype, businessunitid, and modifiedon. Use filter to narrow results (e.g., "teamtype eq 0" for owner teams). Use dataverse_get_team for full details on a specific team.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
filter str | None no None OData $filter expression to narrow results (e.g., "teamtype eq 0" for owner teams, "isdefault eq false").
select list[str] | None no None Columns to return. Defaults to teamid, name, teamtype, _businessunitid_value, isdefault, modifiedon.
top int no 50 Maximum number of teams to return. (ge=1, le=5000)

Returns {"records": ..., "count": ..., "has_more": ...}. Errors return {"error": true, "message": "..."}.


dataverse_list_users

Read · idempotent · category security

List system users (systemusers) in the Dataverse environment.

Returns systemuserid, fullname, domainname, email, disabled flag, and businessunitid. Use filter to narrow results (e.g., "isdisabled eq false", "domainname eq 'user@contoso.com'"). Use dataverse_get_user for full details on a specific user.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
filter str | None no None OData $filter expression to narrow results (e.g., "isdisabled eq false", "domainname eq 'user@contoso.com'").
select list[str] | None no None Columns to return. Defaults to systemuserid, fullname, domainname, internalemailaddress, isdisabled, _businessunitid_value.
top int no 50 Maximum number of users to return. (ge=1, le=5000)

Returns {"records": ..., "count": ..., "has_more": ...}. Errors return {"error": true, "message": "..."}.


dataverse_remove_security_role

Delete · idempotent · category security

Remove a security role from a user or team via the Web API $ref disassociation.

Provide role_id and exactly one of user_id or team_id. For users: disassociates via systemuserroles_association on the systemusers entity. For teams: disassociates via teamroles_association on the teams entity. Requires DATAVERSE_ALLOW_DELETE=true.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
role_id str yes GUID of the security role to remove. (min_len=36)
user_id str | None no None GUID of the system user to remove the role from. Provide exactly one of user_id or team_id.
team_id str | None no None GUID of the team to remove the role from. Provide exactly one of user_id or team_id.

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_remove_team_members

Delete · idempotent · category security

Remove one or more system users from a Dataverse team.

Issues one $ref DELETE per user against the teams(<teamId>)/teammembership_association navigation property. Returns per-user results. Requires DATAVERSE_ALLOW_DELETE=true.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
team_id str yes GUID of the team to remove members from. (min_len=36)
user_ids list[str] yes List of system user GUIDs to remove from the team. At least one user_id must be provided. (min_len=1)

Returns {"team_id": ..., "results": ..., "total": ..., "succeeded": ..., "failed": ...}. Errors return {"error": true, "message": "..."}.


dataverse_retrieve_access_origin

Read · idempotent · category security

Answer "WHY does this principal have access to this record?".

Calls the unbound RetrieveAccessOrigin function, which explains where a principal's rights over one specific row come from — object ownership, or the Principal Object Access (POA) table that backs explicit shares and team or hierarchy grants.

This is the companion to dataverse_retrieve_principal_access, which returns only the access MASK (which rights: Read, Write, Delete, …) and cannot say where those rights came from. When you are debugging "why can this user see this record?" or "why can't they?", the mask is the symptom and this is the cause. Use dataverse_audit_user_access for the wider picture (roles, teams, effective privileges) and dataverse_get_role_privileges for what one role permits in general rather than on one row.

Inputs:

  • object_id — the record's own GUID.
  • logical_name — the SINGULAR lowercase logical name of that record's table ('account', not 'accounts'). This is deliberately not the entity set name the record-access tools take.
  • principal_id — a systemuser id or a team id. No other principal type is accepted; use dataverse_list_users / dataverse_list_teams.

RESPONSE SHAPE (verified live). Dataverse answers with ONE scalar string property, Response — never a collection, in a raw body of roughly 286 bytes. It is surfaced as access_origin, with access_origin_source naming the property it was read from, and the payload (minus the @odata.* envelope) rides along under raw_response so you can check that for yourself. There is no count: the answer is never list-shaped. Should a future platform change move the answer somewhere unrecognizable, normalized is false, nothing is fabricated, and raw_response is the whole answer.

HTTP 200 DOES NOT MEAN "HAS ACCESS" — READ THE STRING. Three materially different outcomes all come back as a successful call with normalized true, and they are distinguishable ONLY by the English prose inside the string. The text is passed through verbatim and deliberately NOT classified into a boolean: pattern-matching platform prose is fragile and locale-dependent, and a wrong security verdict is worse than none. Observed live in ONE org — these wordings are observations, not a documented platform contract, so treat the list as incomplete and never match on it:

  1. Access exists, with the reason. Two forms seen, both meaning "owner" — "PrincipalId is object owner (<guid>)" on a user- or team-owned row, and "PrincipalId is member of organization (<guid>) who is object owner (<guid>)" on an ORGANIZATION-owned row (see the org-owned note below for why that answer is the same for every principal).
  2. NO access at all — "Access origin could not be found. Access does not come from POA table or object ownership."
  3. The record DOES NOT EXIST — still HTTP 200, carrying the platform's "Does Not Exist" exception text inside the Response string. A bad object_id is NOT a 404 from this function, so an unread string looks exactly like a successful answer. Do not report that the principal has access unless the string says so.

Other live-confirmed behaviour:

  • An unknown but grammar-valid logical_name is a clean HTTP 400 [0x80041102] "... was not found in the MetadataCache", surfaced through the standard {"error": true, "message": ...} envelope. Confirm the name with dataverse_list_tables.
  • On an ORGANIZATION-owned table (solution, role, …) the answer is the same for every principal, because ownership resolves at organization level. That is correct platform behaviour, not a defect — discrimination between principals shows up on user- and team-owned rows.
  • A nonexistent principal_id is not validated against an org-owned row: it returned the same generic ownership text as a real one. Confirm the principal exists with dataverse_get_user / dataverse_get_team first.
Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
object_id str yes GUID of the record whose access is being explained (the row's primary key, e.g. an accountid or an incidentid).
logical_name str yes Lowercase logical name of the table that record belongs to (e.g. 'account', 'incident', 'new_project') — the singular logical name, NOT the plural entity set name. Use dataverse_list_tables to confirm it. (min_len=1, max_len=50, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
principal_id str yes GUID of the principal whose access is being explained. Must be a systemuser or a team — no other principal type is accepted. Use dataverse_list_users or dataverse_list_teams to find one, or dataverse_whoami for the current caller's own UserId.

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_retrieve_record_change_history

Read · idempotent · category security

Retrieve the full audit change history for a specific record.

Calls the unbound RetrieveRecordChangeHistory function, which returns the same AuditDetailCollection container as its column-scoped sibling dataverse_get_attribute_change_history — the entries sit TWO levels down (AuditDetailCollection -> AuditDetails), not one.

AN HTTP 200 IS NOT PROOF OF ANYTHING, AND ON A 404 READ THE ERROR CODE. Live- confirmed on this function, and the earlier note that auditing being off produces an HTTP error was WRONG:

  • auditing disabled at organization/table level returns HTTP 200 carrying the audit-configuration rows described below and ZERO genuine changes — not an error, and no error message to read;
  • MOST tables do NOT validate that the target record exists: a well-formed but NONEXISTENT record id with the CORRECT plural entity set returns HTTP 200 with zero genuine changes (12 of 15 entity sets swept on one org behaved this way, 'accounts' among them). An empty answer is therefore never evidence that the record is there, or that it never changed;
  • a 404 is NOT automatically a naming mistake — the error CODE decides. [0x80060888] "Resource not found for the segment '<name>'" NAMES the bad segment: the entity set is wrong (typically the singular slipped in for the plural) or that table is not provisioned on this org, so fix the name with dataverse_get_entity_sets rather than hunt a deleted row. [0x80048d02] has been seen instead from a CORRECT plural entity set ('audits') and there means what it says — the row really is absent. So some entity sets DO validate the target. One org and 15 entity sets were swept, so treat neither group as a complete list and read the code that actually came back. Use dataverse_get_attribute_change_history when the question is about one column; it additionally diagnoses which level auditing is switched off at.

NOT EVERY ENTRY IS A RESULT. Dataverse MAY add org-level audit-CONFIGURATION rows (records of auditing itself being switched on or off) to a response. They arrive when an audit-configuration change falls inside the TARGET RECORD'S history window, so their presence and count VARY BY TARGET — a record created after the last such change gets none, while older records on the same org got four each, live-measured. audit_configuration_events_count: 0 is a normal, expected answer. They are identified by their SHAPE — no @odata.type, AuditRecord and nothing else, and an all-zero AuditRecord._objectid_value — never by their position, which is not a contract. They are split out into audit_configuration_events (with audit_configuration_events_count) and are NOT counted: audit_details, count and has_more cover this record's own changes only.

ENTRIES ARE POLYMORPHIC — read each one's @odata.type, and detail_types counts the values present on the returned page. A RECORD-scoped call spans everything that happened to the record, so expect a wider mix than a column-scoped one: AttributeAuditDetail (OldValue/NewValue per changed field), RelationshipAuditDetail, ShareAuditDetail (live-confirmed here), RolePrivilegeAuditDetail and UserAccessAuditDetail are all documented subtypes. Every subtype carries an AuditRecord navigation property (who, when, what operation). An entry with an UNRECOGNIZED @odata.type is reported as a change, never quietly dropped, and unclassified_typeless_count reports how many entries carrying NO @odata.type were kept as changes because they did not match the configuration shape — it is 0 on every response observed so far, and a non-zero value means this tool met an entry it could not name rather than that anything was lost.

RESPONSE SHAPE IS CHECKED, NOT ASSUMED. If the AuditDetailCollection container is absent or is not a list of entries, the tool returns normalized: false with the raw body — a missing container is NOT reported as "no changes".

PagingInfo is not sent, so changes are trimmed client-side to top and has_more reports the server's MoreRecords OR anything the trim cut. total_record_count appears ONLY when Dataverse supplied a real count: it is live-confirmed to arrive as -1 here ("not counted"), and a negative value is suppressed rather than passed on as a number that reads like a count.

URL form: GET /api/data/v9.2/RetrieveRecordChangeHistory(Target=@p1) ?@p1={'@odata.id':'<entity_set_name>(<record_id>)'}

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
entity_set_name str yes OData collection name of the record's table (e.g., 'accounts', 'contacts'). Use dataverse_get_entity_sets to discover the correct name. (min_len=1, pattern=^[a-zA-Z_][a-zA-Z0-9_]*$)
record_id str yes GUID of the record whose change history to retrieve (e.g., 'a1b2c3d4-1234-5678-abcd-ef0123456789'). (min_len=36)
top int no 50 Maximum number of audit detail entries to return. (ge=1, le=5000)

Returns JSON. Errors return {"error": true, "message": "..."}.


dataverse_set_user_state

Write · idempotent · category security

Enable or disable a Dataverse system user.

PATCHes the writable boolean isdisabled field on the systemuser record: isdisabled=true disables the user, isdisabled=false enables them. The systemuser entity has no statecode/statuscode, and the unbound SetState action is not exposed in current Web API environments — isdisabled is the supported field per the systemuser Web API entity reference.

Note: the caller must hold the System Administrator role, and a user cannot disable their own account. In online environments user lifecycle is also governed by Microsoft Entra ID. Requires DATAVERSE_ALLOW_WRITE=true.

Param Type Req Default Notes
dataverse_url str yes Required Dataverse organization URL for this request (e.g., 'https://yourorg.crm.dynamics.com').
user_id str yes GUID of the system user to enable or disable. Use dataverse_whoami to get the current caller's UserId. (min_len=36)
disabled bool yes True to disable the user (statecode=1, statuscode=2); False to enable the user (statecode=0, statuscode=1).

Returns {"updated": ..., "user_id": ..., "disabled": ...}. Errors return {"error": true, "message": "..."}.


Clone this wiki locally