xrmverse
Documentation menu

Auditing

← xrmverse documentation

xrmverse records data changes in an audit table and returns them through the audit messages and types of the Dataverse SDK; whether each detail matches a real Dataverse is not verified (see the checklist). The Microsoft documentation describes the feature in Auditing overview, Configure auditing, Retrieve the history of audited data changes and Delete audit data. This section says what xrmverse does and where it stops. Anything not listed under "What is audited" writes no audit row.

Three switches. A row is written only when all of them are on:

Level Where Default
Organization organization.isauditenabled (Update of the organization row, xrmd audit enable, web admin) false: no audit cost until an administrator opts in
Table EntityMetadata.IsAuditEnabled (UpdateEntity, PUT EntityDefinitions(…), xrmd audit enable --table, a solution) true and changeable for account, contact, systemuser, team, role, businessunit; false and fixed for every other seeded table (audit itself, organization, plug-in, solution, async and security catalog tables, intersects, fileattachment); false and changeable for a table created in xrmverse
Column AttributeMetadata.IsAuditEnabled (UpdateAttribute, PUT …/Attributes(…), xrmd audit enable --table --column, a solution) true and changeable, except false and fixed for the primary key, createdon, createdby, modifiedon, modifiedby, owninguser, owningteam, owningbusinessunit, versionnumber, the money _base columns, file and image columns and their companion columns. ownerid, statecode and statuscode are audited

The table and column settings are BooleanManagedProperty values (managed property canmodifyauditsettings). CreateEntity and CreateAttribute accept either value; UpdateEntity and UpdateAttribute apply a changed value; a change of a fixed setting (CanBeChanged false), or of CanBeChanged itself, is a 400 naming IsAuditEnabled. The defaults of contact, systemuser, team, role and businessunit follow our reading of the documentation and are not verified against a real Dataverse (see the checklist). A change applies to the operations that start after it commits. Existing organizations get the settings at startup: values kept from an earlier solution import are moved into the metadata once, the others take the defaults above.

What is audited (action and operation codes as in the audit_action and audit_operation choices), always in the transaction of the operation, in stage 30: if the operation rolls back, so does its audit row.

  • Create (1, operation 1): the audited columns that hold a value after the create, as new values.
  • Update (2, operation 2): only the audited columns whose new value differs from the stored one, with the old and the new value. An Update that changes no audited column writes no row.
  • Assign (13) and SetState (41): as Update, with their own action. SetState still reaches plug-ins as an Update, with the same parameters as before.
  • Delete (3, operation 3): the audited columns that held a value, as old values, read before the row is removed. Every record deleted by a Cascade relationship is a Delete of its own and gets its own row.
  • Associate and Disassociate of a many-to-many relationship (33, 34), and of roles: systemuserroles (55, 56) and teamroles (53, 54). One row on the record named as Target, with the relationship and the other records. A one-to-many Associate is an Update of the lookup and is audited as an Update.
  • Sharing: GrantAccess (14), ModifyAccess (48), RevokeAccess (49). One row on the shared record with the principal and the rights before and after.
  • Role privileges: AddPrivilegesRole (57), RemovePrivilegeRole (58), ReplacePrivilegesRole (59). One row on the role, with the privileges before and after, when role is audited.

All of these use operation 2 except Create (1) and Delete (3). A relationship, share or role event that changes nothing (for example revoking a share that does not exist) writes no row.

The row carries userid (the caller; the SYSTEM user for the server's own work), callinguserid (the impersonating user, or null), transactionid (one id per SQL transaction), createdon (UTC) and timetoliveinseconds (see retention). attributemask stays null (xrmverse has no column numbers).

The audit table (object type code 4567, from memory and not verified, entity set audits, ownership None) is read-only for everyone: Create, Update, Delete and Assign through any front end are a 400, System Administrator included. Reading it needs prvReadAuditSummary (Global; otherwise 403). A filter on objecttypecode with an integer type code is translated to the table's logical name (an unknown code is a 400). Two indexes serve the history and the deletes: IX_audit_objectid (objectid, objecttypecode, createdon descending) and IX_audit_createdon; startup creates them when missing, so an existing organization gets them too.

changedata holds xrmverse's own versioned JSON ({"v":1,…}). Do not parse it: as the documentation also advises, the messages below are the contract, and the format may change. The column is not part of the Web API audit entity type (absent from $metadata and from every response, including the columns of an all-attributes FetchXML link-entity; $select, $filter or $orderby on it, or naming it anywhere in a fetchXml query, is a 400); the SDK returns it as stored. Text values longer than 5000 characters are cut to 5000 and end with …. Lookups keep the name of their target as it was when the row was written.

Privileges. Four privileges not tied to a table, Global only: prvReadAuditSummary, prvReadRecordAuditHistory, prvDeleteRecordChangeHistory, prvDeleteAuditPartitions. System Administrator holds them; System Customizer and the other standard roles do not (our choice; not verified against a real Dataverse). Grant them to any role with AddPrivilegesRole. Access to the audited record itself is not checked: the record may already be deleted.

Messages. They run outside the plug-in pipeline (no sdkmessage rows, no steps can be registered on them) and work on the SDK, SOAP and Web API paths, and from a plug-in's service.

Message Privileges Web API
RetrieveAuditDetails(AuditId) → AuditDetail prvReadAuditSummary GET audits(<id>)/Microsoft.Dynamics.CRM.RetrieveAuditDetails
RetrieveRecordChangeHistory(Target, PagingInfo?) → AuditDetailCollection prvReadRecordAuditHistory and prvReadAuditSummary GET RetrieveRecordChangeHistory(Target=@target,PagingInfo=@paginginfo)?@target={'@odata.id':'accounts(<id>)'}&@paginginfo={"PageNumber":1,"Count":10}
RetrieveAttributeChangeHistory(Target, AttributeLogicalName, PagingInfo?) → AuditDetailCollection the same GET RetrieveAttributeChangeHistory(Target=@target,AttributeLogicalName='telephone1')?@target=…
DeleteRecordChangeHistory(Target) → DeletedEntriesCount prvDeleteRecordChangeHistory POST DeleteRecordChangeHistory with {"Target":{"@odata.type":"Microsoft.Dynamics.CRM.account","accountid":"<id>"}}
DeleteAuditData(EndDate) → PartitionsDeleted prvDeleteAuditPartitions POST DeleteAuditData with {"EndDate":"2026-06-30T00:00:00Z"}
  • The details are AttributeAuditDetail (Create, Update, Assign, SetState, Delete), RelationshipAuditDetail, ShareAuditDetail and RolePrivilegeAuditDetail. OldValue and NewValue are entities of the audited table with formatted values; a cleared value is left out. On the SDK path AuditRecord is the audit row (with formatted createdon, action, operation and the user's name); the Web API does not return it (whether Dataverse's does is not verified: checklist step 1 (m)): read audits for the date and user.
  • History is newest first. PagingInfo: PageNumber, Count (default 5000, at most 5000), ReturnTotalRecordCount (TotalRecordCount is -1 otherwise). The returned PagingCookie is <cookie page="N" />; it is accepted back, but paging follows PageNumber, and any other cookie is a 400.
  • History survives the record: the rows of a deleted record, column or table stay readable. A column the table no longer has moves to DeletedAttributes. On the Web API the history of a deleted table cannot be named (see Known limits); the SDK and SOAP paths reach it.
  • Prefer: odata.include-annotations adds the formatted values to the Web API JSON. The types are declared in $metadata.

Deleting audit data and retention.

  • DeleteRecordChangeHistory deletes every audit row of one record and returns how many.
  • DeleteAuditData stands in for Dataverse's quarterly partitions: it deletes the rows of every calendar quarter (UTC) that ended on or before EndDate, never the current quarter, and returns the number of quarters that had rows. It runs in one transaction, which holds the locks of the deleted rows until it commits.
  • Retention: organization.auditretentionperiodv2 is a number of days from 1 to 365000, or -1 to keep rows forever (default 30; any other value, null included, is a 400). Each row is stamped with it when written (timetoliveinseconds, -1 for forever), so a new period applies to new rows only (our reading of the documentation, not verified: checklist step 1 (n)). A background service deletes expired rows every hour (the first run one minute after startup), in batches.
  • xrmd audit retention <days>|--forever and the web admin set the period.

Other organization settings. isuseraccessauditenabled set to true is a 400 (user access auditing is not supported); false is accepted. useraccessauditinginterval is stored and has no effect. auditsettings accepts only null or {"StoreLabelNameforPicklistAudits":false} (xrmverse stores option values, not labels); anything else is a 400.

Solutions. IsAuditEnabled of tables and columns, and the table's CanModifyAuditSettings, are read on import and written on export. A file that sets a fixed setting to another value is not applied and the import reports a warning. The organization audit settings are not solution components and do not travel. xrmd apply schema files do not carry IsAuditEnabled: use xrmd audit enable/disable or a solution.

Tools. xrmd audit (status, enable, disable, retention, history, delete; see CLI xrmd), the MCP tool get_audit_history (see MCP) and the web admin Audit section (see Web admin). No Admin API endpoint: auditing is served by the Core.

Known limits

What xrmverse does not audit, or does differently. None of these is silent parity: each is either refused with an error or stated here.

  • Not audited (no row is written):
    • user access events (64, 65, 112, 113): isuseraccessauditenabled true is refused;
    • audit configuration and metadata events (100–111: a table or column deleted, auditing switched on or off at any level, audit data deleted). The documentation gives their action codes but not the shape of their detail;
    • adding or removing team members (teammembership);
    • the lookups cleared by a RemoveLink cascade (a direct update; see "Delete cascades" in Solution);
    • the rows a Delete removes directly with the deleted record: its many-to-many associations, its shares (principalobjectaccess, including those a deleted team or user held), for a deleted role or team its role assignments (systemuserroles, teamroles), and for a deleted role its privileges (roleprivileges). The Delete row of the record is written; no Disassociate (34), RevokeAccess (49) or role removal (54, 56) row is written for them (what Dataverse writes is checklist step 1 (o));
    • role privilege changes made by a solution import (they are not messages);
    • an Update, Assign or SetState that changes no audited column;
    • the bootstrap administrator created by server init (it exists before auditing can be switched on).
  • Not implemented and refused as unsupported messages: RetrieveAuditPartitionList, BulkDelete of audit, Upsert and Merge (so they write no audit row either).
  • DeletedAttributes: the SDK type is a dictionary keyed by integer. xrmverse has no column numbers, so the key is an ordinal with no meaning; only the values (the logical names) are reliable. For a deleted table, the entities are empty and every stored column is listed in DeletedAttributes: this is our own reading, since the documentation does not say what Dataverse returns (checklist step 1 (i)).
  • Empty rather than an error: RetrieveAttributeChangeHistory with a column the table never had returns an empty collection (so the history of a deleted column stays readable). Dataverse may refuse it.
  • timetoliveinseconds is an Integer: a retention above 24855 days is stamped as int.MaxValue seconds (about 68 years), so such rows are deleted after about 68 years, not after the configured period.
  • Table and column switches of the CLI and the web admin read the full definition and send it back without If-Match: a metadata change made by someone else in between can be overwritten.
  • CanBeChanged true sent explicitly on a fixed setting with an unchanged value is ignored, not refused: the server cannot tell it from the default of the SDK object.
  • Web API ShareAuditDetail rights are written as .NET flag names separated by , ("ReadAccess, WriteAccess"); Dataverse's exact separator is not verified.
  • Deleted tables on the Web API: RetrieveRecordChangeHistory and DeleteRecordChangeHistory name the record by its @odata.type or entity set, which a deleted table no longer has, so the Web API refuses them with a 400 for a record of a deleted table. The SDK and SOAP paths name the table by its logical name and reach that history.
  • Web admin record history calls RetrieveAuditDetails once per row; one failing call fails that page of 20 (as the CLI's history does).
  • Not verified against a real Dataverse: the type code 4567, the defaults of the seeded tables, which roles hold the audit privileges, the action of an Update that changes only ownerid (xrmverse: Update 2, not Assign 13), the userid of a row written by a plug-in that runs as another user (xrmverse: the user the service runs as), and the paging cookie format.

The manual acceptance checklist is docs/superpowers/acceptance/audit-checklist.md.