Inventory Structure
The inventory/ folder contains the current PIM state as JSON files, organized by workload and entity.
Top-level layout
inventory/
├── directory-roles/
├── pim-groups/
├── authentication-contexts/
├── conditional-access/
├── administrative-units/
├── activation-events/
└── archive/
Each entity gets its own subfolder using a slugified name. For example: inventory/directory-roles/global-administrator/.
Directory Roles
inventory/directory-roles/{slug}/
├── definition.json
├── policy.json
└── assignments.json
definition.json
{
"id": "62e90394-69f5-4237-9190-012177145e10",
"displayName": "Global Administrator",
"description": "...",
"isBuiltIn": true,
"isEnabled": true,
"rolePermissions": [
{
"allowedResourceActions": ["*"],
"condition": null
}
]
}
policy.json
PIM policy assignment with expanded rules:
{
"id": "policyAssignmentId",
"scopeId": "/",
"scopeType": "Directory",
"roleDefinitionId": "roleId",
"policy": {
"id": "policyId",
"rules": [
{
"id": "Enablement_EndUser_Assignment",
"enabledRules": ["MultiFactorAuthentication"],
"notificationLevel": "All"
},
{
"id": "Expiration_EndUser_Assignment",
"maximumDuration": "PT8H"
}
]
}
}
assignments.json
{
"permanent": [
{
"id": "assignmentId1",
"principalId": "userId1",
"principal": {
"id": "userId1",
"displayName": "Alice Admin",
"userPrincipalName": "alice@contoso.com"
},
"directoryScopeId": "/",
"createdDateTime": "2024-01-15T10:00:00Z"
}
],
"eligible": [
{
"id": "eligibleId1",
"principalId": "userId2",
"principal": { "id": "userId2", "displayName": "Bob User" },
"directoryScopeId": "/",
"scheduleInfo": {
"expiration": {
"endDateTime": "2027-04-20T00:00:00Z"
}
}
}
],
"active": [
{
"id": "activeId1",
"principalId": "userId3",
"scheduleInfo": {
"expiration": {
"endDateTime": "2026-04-21T15:30:00Z"
}
}
}
]
}
PIM Groups
inventory/pim-groups/{slug}/
├── definition.json
├── policy.json
└── assignments.json
definition.json
{
"id": "groupId",
"displayName": "Finance Admins",
"description": "...",
"groupTypes": [],
"visibility": "Private"
}
policy.json
Two policy assignments, one per access type:
{
"member": {
"id": "policyAssignment_member",
"scopeId": "groupId",
"scopeType": "Group",
"roleDefinitionId": "member",
"policy": {
"rules": [
{
"id": "Enablement_EndUser_Assignment",
"enabledRules": ["MultiFactorAuthentication"]
}
]
}
},
"owner": {
"id": "policyAssignment_owner",
"scopeId": "groupId",
"scopeType": "Group",
"roleDefinitionId": "owner",
"policy": {
"rules": [
{
"id": "Enablement_EndUser_Assignment",
"enabledRules": ["MultiFactorAuthentication"]
}
]
}
}
}
assignments.json
{
"eligible": [
{
"id": "scheduleId1",
"principalId": "userId1",
"groupId": "groupId",
"accessId": "member",
"principal": {
"id": "userId1",
"displayName": "Alice Admin"
},
"scheduleInfo": {
"expiration": {
"endDateTime": "2027-04-20T00:00:00Z"
}
}
}
],
"active": []
}
Each entry has an accessId field (member or owner).
Lookups
Lookups are read-only catalogs of IDs to display names. They contain only definition.json and are never diffed for severity.
authentication-contexts
inventory/authentication-contexts/{slug}/
└── definition.json
{
"id": "c1",
"displayName": "Require MFA",
"description": "..."
}
Resolves claimValue references in policy rules like AuthenticationContext_EndUser_Assignment.
The config.json file in each auth context folder is operator-defined and never written by the scan pipeline. See Auth Context CA Compliance for the supported fields.
Conditional Access
CA policies that reference at least one auth context claim are stored here. Only policies with conditions.applications.includeAuthenticationContextClassReferences set are stored. All other CA policies are out of scope.
inventory/conditional-access/{slug}/
└── definition.json
{
"id": "policyId",
"displayName": "PIM - Phishing-resistant MFA + SIF",
"state": "enabled",
"conditions": {
"applications": {
"includeAuthenticationContextClassReferences": ["c2"]
}
},
"grantControls": {
"authenticationStrength": {
"id": "00000000-0000-0000-0000-000000000003"
}
},
"sessionControls": {
"signInFrequency": {
"isEnabled": true,
"frequencyInterval": "everyTime"
}
}
}
Note: The includeAuthenticationContextClassReferences field is only available on the beta endpoint. PIM Monitor uses $GraphBeta/identity/conditionalAccess/policies for this workload.
administrative-units
inventory/administrative-units/{slug}/
└── definition.json
{
"id": "auId",
"displayName": "Finance Department",
"description": "..."
}
Resolves directoryScopeId in assignments scoped to an AU instead of the full tenant.
Archive
When a role or group disappears from PIM (removed, offboarded, or renamed to a different slug), its inventory folder is not deleted. PIM Monitor moves it to inventory/archive/{workload}/{slug}_{date}:
inventory/archive/
├── directory-roles/
│ └── old-role-name_2026-04-26/
│ ├── definition.json
│ ├── policy.json
│ └── assignments.json
└── pim-groups/
└── disbanded-group_2026-03-01/
├── definition.json
├── policy.json
└── assignments.json
The date suffix is the UTC date of the scan that first detected the removal.
What is preserved
The full folder contents (definition, policy, and assignments) are moved as-is. This means:
- The last known state of the entity is readable on disk without touching git history.
- The git history of the original folder is preserved.
git log inventory/directory-roles/old-role-name/still shows all changes the entity went through before it was removed. - Notifications fire for the removal: it is recorded as a
Highseverity change entry and sent via email or webhook if configured.
Renamed roles
If a role is renamed in Entra ID, its slug changes (slugs are derived from displayName). PIM Monitor sees this as a removal of the old slug and a creation of the new one. The old folder is archived; a new folder is created on the same scan run.
Browsing archived entities
ls inventory/archive/directory-roles/
git log --oneline inventory/archive/directory-roles/old-role-name_2026-04-26/
git show HEAD:inventory/archive/directory-roles/old-role-name_2026-04-26/assignments.json
Deterministic serialization
All JSON files are serialized the same way every time. Same data always produces identical output. This keeps git diff clean and meaningful.
- Objects with an
idfield: sorted byid - Strings: sorted alphabetically
@odata.context,@odata.type, and other metadata fields are stripped
Git history
Every inventory change becomes a commit:
git log --oneline inventory/
Output:
a1b2c3d scan: 2026-04-20T15:30:00Z
e5f6g7h scan: 2026-04-20T15:00:00Z
To see what changed between two scans:
git diff a1b2c3d~1 a1b2c3d -- inventory/