en-US/about_Omnicit.EntraRBAC.help.txt
|
TOPIC about_Omnicit.EntraRBAC SHORT DESCRIPTION Manage Entra ID and Azure RBAC building blocks across tenants. LONG DESCRIPTION Omnicit.EntraRBAC manages Entra ID groups and PIM, Administrative Units, Entitlement Management, Access Reviews, Azure resources and RBAC, and Azure PIM across multiple tenants, plus a JSON inventory and a declarative apply engine. Authenticate with Connect-OER, then use the OER cmdlets. Configuration per tenant is stored in Tenant Profile files managed by New/Get/Set/Remove-OERConfiguration. Calling Connect-OER is optional. Every cmdlet that reaches Graph or Azure authenticates on first use, reusing a cached token for the same tenant and identity. Cmdlets that reach Azure Resource Manager need an ARM token; pass -IncludeARM to Connect-OER to acquire one up front. All state-changing cmdlets support -WhatIf and -Confirm. Deletions of high-value objects use ConfirmImpact High and warn before the destructive call. Use Get-Command -Module Omnicit.EntraRBAC to list all 98 cmdlets, and Get-Help <cmdlet> -Full for parameters and examples. Use Get-OERRequiredScope for the permissions any of them needs; see PERMISSIONS below. COMMAND COHORTS The cohorts below are for orientation only. They are NOT permission boundaries, so none of them carries a scope line -- ask Get-OERRequiredScope instead. Authentication and Tenant Profiles Connect-OER, Disconnect-OER New-OERConfiguration, Get-OERConfiguration, Set-OERConfiguration, Remove-OERConfiguration Groups and PIM for Groups New-OERGroup, Get-OERGroup, Set-OERGroup, Remove-OERGroup Add-OERGroupMember, Get-OERGroupMember, Remove-OERGroupMember Add-OERGroupEligibility, Get-OERGroupEligibility, Remove-OERGroupEligibility Get-OERGroupPimPolicy, Set-OERGroupPimPolicy Administrative Units New-OERAdministrativeUnit, Get-OERAdministrativeUnit, Set-OERAdministrativeUnit, Remove-OERAdministrativeUnit Add-OERAdministrativeUnitMember, Remove-OERAdministrativeUnitMember Add-OERAdministrativeUnitScopedRole, Get-OERAdministrativeUnitScopedRole, Remove-OERAdministrativeUnitScopedRole New-OERGroup -AdministrativeUnit places a new group into an AU at creation time. Entitlement Management Catalogs: New-OERCatalog, Get-OERCatalog, Set-OERCatalog, Remove-OERCatalog Catalog resources: Add-OERCatalogResource, Get-OERCatalogResource, Remove-OERCatalogResource Access packages: New-OERAccessPackage, Get-OERAccessPackage, Set-OERAccessPackage, Remove-OERAccessPackage Resource roles: Add-OERAccessPackageResourceRole, Get-OERAccessPackageResourceRole, Remove-OERAccessPackageResourceRole Policy builders: New-OERAccessPackageApprovalStage, New-OERAccessPackageRequestorScope, New-OERAccessPackageRequestorSettings Policies: New-OERAccessPackageAssignmentPolicy, Get-OERAccessPackageAssignmentPolicy, Set-OERAccessPackageAssignmentPolicy, Remove-OERAccessPackageAssignmentPolicy Assignments: New-OERAccessPackageAssignment, Get-OERAccessPackageAssignment, Remove-OERAccessPackageAssignment Access Reviews New-OERAccessReviewDefinition, Get-OERAccessReviewDefinition, Set-OERAccessReviewDefinition, Remove-OERAccessReviewDefinition New-OERAccessReviewStage composes one stage of a multi-stage review. Get-OERAccessReviewInstance, Get-OERAccessReviewInstanceDecision, Stop-OERAccessReviewInstance, Invoke-OERAccessReviewInstanceDecision, Send-OERAccessReviewReminder Directory roles Get-OERDirectoryRoleManagementPolicy, Set-OERDirectoryRoleManagementPolicy Eligible: New-OEREligibleDirectoryRoleAssignment, Get-OEREligibleDirectoryRoleAssignment, Remove-OEREligibleDirectoryRoleAssignment Active: New-OERActiveDirectoryRoleAssignment, Get-OERActiveDirectoryRoleAssignment, Remove-OERActiveDirectoryRoleAssignment Azure inventory, RBAC, resource groups and resources Get-OERManagementGroup, Get-OERSubscription, Get-OERRoleDefinition Get-OERRoleAssignment, New-OERRoleAssignment, Set-OERRoleAssignment, Remove-OERRoleAssignment New-OERResourceGroup, Get-OERResourceGroup, Set-OERResourceGroup, Remove-OERResourceGroup, Get-OERResource Azure PIM Eligibility: New-OEREligibleRoleAssignment, Get-OEREligibleRoleAssignment, Remove-OEREligibleRoleAssignment Active: New-OERActiveRoleAssignment, Get-OERActiveRoleAssignment, Remove-OERActiveRoleAssignment Activation: Enable-OEREligibleRoleAssignment, Disable-OEREligibleRoleAssignment Policy: Get-OERRoleManagementPolicy, Set-OERRoleManagementPolicy, New-OERPolicyNotificationRule Inventory and JSON orchestration Get-OERInventory reads tenant state into a round-trippable inventory object. Export-OERInventory writes that state to a bundle folder (JSON, schema, LLM prompt, README). Test-OERStructure validates a structure document offline with no tenant call. Invoke-OERStructure applies one idempotently in dependency order, with -WhatIf plan mode and opt-in child-scope -Prune. Permissions Get-OERRequiredScope reports the Graph permissions and Azure RBAC roles one or more cmdlets need. See PERMISSIONS below. Conditional Access Get-OERAuthenticationContext SYNCHRONIZED GROUPS Get-OERGroup shows OnPremisesSyncEnabled for every group: True for a group synchronized from on-premises Active Directory, which is managed there and read-only in the cloud. Invoke-OERStructure writes nothing to such a group: each change to its properties, members, owners, eligibility or pimPolicy is reported Skipped, with one warning per group, and -Prune removes nothing from it. Export-OERInventory marks it onPremisesSynced in groupsRoster.json, and -IncludeSyncedGroups keeps the synchronized security groups in full detail in inventory.json. GROUPS IN AN EXPORT Unless -AllGroupsDetailed is given, Export-OERInventory details only RBAC-relevant security groups in inventory.json: role-assignable ones, ones with PIM eligibility, ones found to use PIM for Groups and, with -IncludeSyncedGroups, synchronized ones. It decides that before it reads any group in full. Every group costs one request for its PIM eligibility and, when that does not decide it, one for its PIM-for-Groups policies; a group found not relevant costs those two and no more, so its members, owners and policies are never read. A group whose relevance could not be read is read in full, and what could not be read is named in IncompleteReads. The export shows progress while it reads the groups. -AllGroupsDetailed reads every security group in full, as earlier versions did. In groupsRoster.json a group the export did not read in full has memberCount null, meaning not known rather than zero. A dynamic group's members key is written null, not as the list its rule happened to derive, and is not an unread collection: its membership rule owns the membership, so a failed read of those members names nothing in InventoryPartial. The null is the document's "leave it untouched", and Invoke-OERStructure plans no member row for such a group, with or without -Prune, which never touched a dynamic group's members. A dynamic group the export reads in full, and whose members it can read, still has its memberCount in groupsRoster.json. A group's members, owners and eligibility and an administrative unit's members are written in one fixed order, so two reads of an unchanged tenant write the same lists whatever order Microsoft Graph answered in. -GroupFilter narrows the groups inventory.json is read from and never widens them. The export lists groups with 'securityEnabled eq true and (your filter)' and then keeps only those the read shows as security-enabled, with one warning naming how many it left out. The filter is sent as typed: double a single quote inside a quoted value yourself. It applies to the Groups section only, and groupsRoster.json still lists every group. To widen the document beyond security-enabled groups, use Get-OERInventory -GroupFilter instead. Each groupsRoster.json row also carries the group's id, its type (securityEnabled, mailEnabled and groupTypes), visibility and createdDateTime, so two groups that share a name can be told apart. Display names are not unique in Microsoft Entra ID, and Invoke-OERStructure refuses a name more than one live object answers to, so Get-OERInventory and Export-OERInventory leave out every object whose name another object of its kind shares, letter case aside. A group's name is counted over every group in the tenant, Microsoft 365 and distribution groups included; an access package's among the packages of its catalog; an assignment policy's among the policies of its own access package; and the name of an administrative unit, a catalog or an access review among the ones read (with Get-OERInventory -Catalog, only the catalogs it reads; an access review among the access-package reviews captured). The export warns once, and its README lists each name, with how many objects carry it, under "Names two or more objects share". An access package's catalog, an access review's access package or assignment policy, and a group named as the principal of a role assignment or a directory role assignment (with principalType Group) are written by object id when their name is shared. Test-OERStructure refuses two assignment policies of one name in one access package. PIM ACTIVATIONS ARE NOT STANDING MEMBERS Get-OERInventory and Export-OERInventory write a group's standing members and owners only. A member or owner whose access PIM for Groups holds -- an activation, or another active assignment that is not standing, such as a time-bound one -- is left out, and the bundle README counts them per group, by group name, under "Members and owners left out as PIM for Groups access". Where the group's PIM assignments cannot be read, the members and owners eligible for the same access are left out instead and 'groups/<name>/pimAssignments' is named in InventoryPartial; where its eligibility cannot be read either, a members collection that holds entries is written as null and an owners collection that holds entries is left out. Invoke-OERStructure adds no standing member or owner for a principal PIM for Groups holds or makes eligible for the same access: the entry is Skipped, with a warning, also under -WhatIf. So an activation listed under members in a document an earlier version exported is not added while PIM for Groups still holds that principal or makes it eligible. -Prune removes none (Skipped with -Prune, Extra without). While either of a group's PIM for Groups reads (its assignments or its eligibility) fails for a group that already existed, nothing is added to or removed from its members and owners; a group the same run creates is not read, so no member or owner declared for it is held back for PIM for Groups. The read needs the permission PrivilegedAssignmentSchedule.Read.AzureADGroup, which Get-OERRequiredScope lists. AZURE ROLE POLICIES IN AN EXPORT With RoleManagementPolicies in -Include, Export-OERInventory keeps a role's Azure PIM policy at a scope only when the role has an active role assignment or a PIM eligibility exactly at that scope, or the policy has been changed (it carries a modification date or names who modified it). That costs one more paged role assignment list per scope; the policies are still read with one list per scope and nothing per policy. A policy the export cannot judge, because a read it needs failed or the list gives nothing to judge by, is kept, and its scope is named in IncompleteReads as 'roleManagementPolicies/role selection at <scope>' and in InventoryPartial. -AllRolePolicies exports every policy at every scope, as earlier versions did. It has nothing to act on without RoleManagementPolicies in -Include (the default -Include does not name it), and neither has -AllDirectoryRolePolicies without DirectoryRoleManagementPolicies: the export warns once, at the start, for each. Invoke-OERStructure reads and writes only the policies a document declares, so a policy the export left out stays as it is. AZURE LIGHTHOUSE DELEGATIONS IN AN EXPORT Azure Lighthouse lets another tenant, a service provider for example, manage subscriptions or resource groups of this one, and the role assignments of this tenant do not list the access it gives. With RoleAssignments or RoleManagementPolicies in -Include, Export-OERInventory also writes lighthouseDelegations.json: the delegations at the subscriptions its Azure walk visits and at their resource groups, each with the managing tenant, the offer, and the roles its principals hold, permanently or as eligible (just-in-time) authorizations. The file is read-only context like azurePimEligibility.json: written exactly when that file is, always a JSON array ([] when none was read), and never part of the apply document, so Invoke-OERStructure never creates or removes a delegation. The export warns once with the count, and its README lists each delegation under "Azure Lighthouse delegations (access from another tenant)", as a finding to review. The walk reads only this tenant's own subscriptions, and Azure Lighthouse refuses a delegation whose managing tenant is the subscription's own tenant, so every row is access another tenant holds here. A subscription whose delegations could not be read has no row in the file and is named in IncompleteReads as 'lighthouseDelegations at <scope>' and in InventoryPartial, and so is a role whose name could not be read, as 'lighthouseDelegations role <id>', its roleName written null. The read costs two paged lists per subscription and one read per distinct role, plus a listing of the subscription's resource groups and one read per group only when a delegation may sit at one; Reader covers it. When the Azure PIM eligibility read, the Lighthouse read or the role policy selection stops as a whole, the export still writes the bundle and ends with InventoryPartial, with one warning that names the cause: the eligibility read names every scope in SkippedEligibilityScopes and writes azurePimEligibility.json as [], the Lighthouse read is named 'lighthouseDelegations at <all Azure scopes: the read stopped>' in IncompleteReads and writes lighthouseDelegations.json as [], and the selection keeps every policy it read and names the scope of each as not judged. SUBSCRIPTIONS OF ANOTHER TENANT Export-OERInventory's Azure walk reads only the session tenant's own subscriptions, judged by the tenantId Azure Resource Manager returns for each: a subscription of another tenant (one delegated to this tenant through Azure Lighthouse, say) is not read, and one warning and the bundle's README name it under "Subscriptions delegated from another tenant (not read)", while one whose home tenant could not be read is named in IncompleteReads ('subscriptionHomeTenant at <scope>') and makes the bundle partial. Invoke-OERStructure refuses a role assignment or a role management policy at a scope in another tenant's subscription (SubscriptionTenantMismatch): it reads no role assignment or policy there and writes nothing for it. Get-OERInventory checks the same way before its RoleAssignments or RoleManagementPolicies section reads a -Scope or a -Subscription, once per call, with one read of the subscription (and of the subscription list for one named by display name). A subscription of another tenant is not read: each of those sections it includes is written empty, with one SubscriptionTenantMismatch error. One whose home tenant cannot be told (its read failed or gave no tenantId that is a GUID, the session holds no Graph token whose tenant ID is a GUID, or the scope cannot be resolved or is spelled in a way no document can declare) is not read either, and those sections are named in InventoryPartial. A -ManagementGroup, and a -Scope at a management group or the root scope, lie in no subscription and are not checked. Whenever RoleAssignments, or RoleManagementPolicies with exactly one of -Role, -CommonRoles and -AllRolesAtScope (without which it reads nothing), is included with a -Scope or a -Subscription, the call asks for the Azure Resource Manager token in its own sign-in, as -IncludeARM does, so when that sign-in fails it sends nothing, its Entra ID sections included. Inside Export-OERInventory the walk's answers are reused, so the check sends no request. A -Subscription is read by the ID the check found, never by the name as given. A RoleAssignments or RoleManagementPolicies read that fails is named in InventoryPartial too, by the section's own name, with the reader's message as its cause, and the section is written empty, which does not mean the scope has none; only -CommonRoles differs, since it skips a role it cannot read with a warning and carries on. A -Scope, -Subscription or -ManagementGroup of white space only is refused while PowerShell binds the parameters of Get-OERInventory, as are the -Scope, -ManagementGroup and -GroupFilter of Export-OERInventory. SERVICE PRINCIPAL ACCESS IN AN EXPORT Export-OERInventory -IncludeServicePrincipalAccess also writes servicePrincipalAccess.json: read-only context on the app roles and delegated permission grants of the tenant's service principals and on the owners of its service principals and app registrations, which the apply document does not model. A service principal's Microsoft Entra directory roles and Azure roles are not in it but in directoryRoleAssignments.json and roleAssignments.json, each within its own coverage, so a service principal this file does not name is not shown to be without privilege. Its four parts are applicationPermissions (one row per app role a service principal holds on a resource, application permissions among them, with the role's value, null for a role the resource does not declare or declares without a value, and for a resource the service principal list did not return, whose resourceDisplayName is then the assignment's own, else null), delegatedPermissionGrants (one row per delegated permission grant: the client, the resource, the consent type, the scopes and, for a grant to one user, its principalDisplayName and principalUserPrincipalName), applicationOwners and servicePrincipalOwners (one row per owner of an app registration or a service principal, with the owner's type and names). Every row carries ids and display names. The file is never part of the apply document: inventory.json and schema.json do not carry it, and Invoke-OERStructure never grants, revokes or changes any of it. The read costs one request per page of each of three lists (the service principals with their app role assignments, the service principals with their owners, and the app registrations with their owners) and of the grants, one directoryObjects/getByIds per 1,000 users named in grants, and one more paged read per object with 20 or more expanded entries or with an expanded entry that lacks the id its row needs: 29 requests for a test tenant of 1,129 service principals and 67 app registrations. A part is read whole or not at all: one that could not be read is written null, never [], and is named in IncompleteReads as 'servicePrincipalAccess/<part>' and in InventoryPartial, and the grants are not read when the service principal list they are named through was not. The bundle's README gives each part's row count under "Service principal access (read-only)". Directory.Read.All, which the export already requires, covers every read. Without the switch none of it is read or written. ROLE ASSIGNMENTS AT THE ROOT SCOPE A role assignment at the Azure root scope '/', above every management group and subscription in the tenant, is elevated access, which the export and the apply engine leave alone. Get-OERInventory leaves it out of roleAssignments, and Export-OERInventory out of inventory.json and roleAssignments.json, each with a warning that names it by role and principal. The export writes one such warning for the whole walk, and its README lists the assignments under "Role assignments at the root scope (elevated access)", as a finding to review. Test-OERStructure reports a roleAssignments entry at the root scope as a Warning finding, which does not make the document invalid. Invoke-OERStructure reports such an entry Skipped, with a warning, before it looks up, reads or writes anything for it, also under -WhatIf, and never prunes the root scope, also with -Prune. ROLE ASSIGNMENTS WHOSE PRINCIPAL IS NOT RETURNED An Azure role assignment can name a principal that Microsoft Entra ID does not return. Get-OERInventory and Export-OERInventory keep such an assignment in roleAssignments, written by the principal's object id with principalStatus: Deleted when the principal is among the directory's deleted items, Unresolved when it is found neither there nor in the directory (deleted beyond the retention period, a principal of another tenant, or not readable) or when its deleted items could not be read. Each warns once with the count, and the export README lists them under "Role assignments whose principal the directory did not return", as a finding to review. A principal whose lookup failed is written by its object id without principalStatus; Get-OERInventory names it in its InventoryPartial error, and Export-OERInventory in IncompleteReads and its own InventoryPartial, as each does a failed read of the deleted items. The read of the deleted items needs no permission beyond Directory.Read.All, which the inventory already needs. Test-OERStructure refuses a principalStatus that is neither Deleted nor Unresolved in any letter case (another letter case is a Warning, and Invoke-OERStructure reads it in the canonical case), and one beside a principal that is not an object id. Invoke-OERStructure looks no principal up for such an entry and never creates the assignment: where it would, the entry is reported Skipped, also under -WhatIf. A live assignment is matched and kept like any other, and a document without the entry lets -Prune remove it like any undeclared assignment at a scope the document declares. This covers the roleAssignments section only. ROLE ASSIGNMENTS AT A SCOPE NO DOCUMENT CAN DECLARE A role assignment at a scope whose spelling the validator refuses (see RENAMES, MAIL ALIASES AND SCOPE SPELLING below), as the name of a resource of a few types can make it, cannot be declared, so Get-OERInventory and Export-OERInventory leave it out of roleAssignments, each with one warning that counts them, and the export README lists them under "Role assignments at a scope no document can declare", with the rules each scope breaks. Invoke-OERStructure never creates or removes a role assignment at such a scope, since it prunes only at a scope a document declares. SCOPED ROLE PRINCIPAL TYPES In administrativeUnits[].scopedRoles, Get-OERInventory and Export-OERInventory write principalType (User, Group or ServicePrincipal) on a row whose principal is an object id that the directory returns as one of those types, looked up together through directoryObjects/getByIds. A lookup that fails leaves those rows without it and names the unit in InventoryPartial as 'administrativeUnits/<name>/scopedRoles/principalType'; the unit's scopedRoles is still written. Test-OERStructure refuses any other value (another letter case is a Warning), and Invoke-OERStructure does not use the key to match or write a scoped role, which it matches by role and principal. PIM FOR GROUPS IN A RESTRICTED MANAGEMENT UNIT Microsoft Learn states that Privileged Identity Management does not support a group in a restricted management administrative unit. With Groups and AdministrativeUnits included, Export-OERInventory warns once with the count of the groups inventory.json writes with PIM eligibility or a pimPolicy that a restricted unit lists among its members, and its README names each such group and its units under "Groups using PIM for Groups in a restricted management unit", as a finding; nothing is left out. Test-OERStructure warns, and never refuses, when a document places a group that declares PIM eligibility or a pimPolicy in a restricted unit it declares. Invoke-OERStructure still sends such a group's PIM settings and eligibility; what Microsoft Graph answers to them has not been measured. ACCESS REVIEWS ARE MATCHED BY NAME An existing access review is matched by display name, and display names are not unique in a tenant. Invoke-OERStructure updates the review it finds only when that review reviews the access package and assignment policy the entry declares (a GUID compared as written, a name resolved as New-OERAccessReviewDefinition resolves it). A review of a group, a role, another access package or another policy is reported Failed with AccessReviewScopeMismatch, and nothing is written to it, the same in a -WhatIf plan; a name that matches nothing is reported Failed with AccessPackageNotFound or AssignmentPolicyNotFound. An access package name is not unique in the tenant, nor an assignment policy name in its access package: when such a name is shared, the entry is reported Failed with AmbiguousName until it gives the package or the policy by id. The export writes either by id whenever that lookup would not find that package or policy alone. Give a new review a display name no other review in the tenant uses. RENAMES, MAIL ALIASES AND SCOPE SPELLING A group, an administrative unit, a catalog, an access package (within its catalog), an assignment policy (within its access package) and an access review are renamed in place through a structure document: declare the new name as displayName and the current name, or the object id, as previousDisplayName. Every run looks up both names before it reads or writes anything else for the object. The object found only under previousDisplayName is renamed in the same update as any other changed property. When both names find different objects the entry fails with GroupRenameConflict, AdministrativeUnitRenameConflict, CatalogRenameConflict, AccessPackageRenameConflict, AssignmentPolicyRenameConflict or AccessReviewRenameConflict and nothing is changed; when neither finds one it fails with the matching GroupRenameNotFound, AdministrativeUnitRenameNotFound, CatalogRenameNotFound, AccessPackageRenameNotFound, AssignmentPolicyRenameNotFound or AccessReviewRenameNotFound and nothing is created. When the rename itself fails, the object's children (members, scoped roles, resources, resource roles, assignment policies) are not reconciled, and under -WhatIf the plan says 'would rename' and plans them against the object found under its previous name. An assignment policy's object id matches only a policy of its own access package, and a policy that declares previousDisplayName in a package the same run creates fails with AssignmentPolicyRenameNotFound. An access review's two names are looked up in the whole tenant (an object id is read by id), and the review found under either name is updated or renamed only when it reviews the access package and assignment policy the entry declares; otherwise the entry fails with AccessReviewScopeMismatch and nothing is written. After a rename, the review read back must carry the new name exactly, letter case included, or the entry is Failed. A previous name that several policies of the package, or several reviews, carry fails the entry with AmbiguousName: declare the object id then. Microsoft Graph's name lookup can follow a rename with a delay: keep previousDisplayName in the document until the new name resolves. In the same run, a group that is created into a renamed unit, an access package declared in a renamed catalog and an access review that names a renamed access package or assignment policy find the renamed object by its object id, so name the object by its new name in those references. When the rename is refused, or the run cannot decide it (one of its names could not be looked up, the access package's entry stopped before it decided, or -Include leaves out the AdministrativeUnits, Catalogs or AccessPackages section that holds the entry), the group that would be created, every package of the catalog and every access review that names the package or the policy by its new name are Failed and nothing is created or written for them, instead of being looked up by name; a group that already exists is not affected, an access review that already exists is. A review that names the access package by its object id and a renamed policy by its new name reaches the policy, or is refused, the same way once the package's entry knows that package's id. Until then the review looks the policy up by name, as before: so it does when the entry stopped before it knew any package id (its catalog refused, a failed read of the catalog's packages, or a package rename that found neither name), when the entry stopped at a display name several packages of its catalog carry and the review names the package that carries the entry's previous name (only the packages that carry the display name are marked; the previous name is not looked up by then), or when -Include leaves out the AccessPackages section. While -Include leaves the section out and the entry still declares previousDisplayName, every reference by the new name is refused -- for a catalog every package declared in it, for a package or a policy every review that names it by that name (one that names the package by object id looks a policy up by name, as above) -- existing ones included, even when the rename was done in an earlier run: include the section, or remove previousDisplayName once the new name resolves. A refusal because -Include leaves the section out publishes no error record, only the Failed row, so -ErrorAction Stop and $? do not see it: check the rows. An access review that names, by its display name, an access package the same document declares reaches the package that accessPackages entry found in its catalog or created there, by its object id, whether or not the entry renames: its name is never looked up in the whole tenant, where a package of that name in another catalog would be found. Until the entry has that id, the review is Failed and nothing is read or written for it, under -WhatIf too: when the entry stopped first, when a -WhatIf plan would create the package (a real run creates the package, then the review) or a declined confirmation skipped its create, and when -Include leaves out the AccessPackages section. Such a refusal publishes no error record of its own, only the Failed row. Include AccessPackages, or name the package by its object id. A review that names a package the document does not declare is resolved as before. Test-OERStructure warns on a reference that still names a renamed object by its previous name, an access review's assignmentPolicy included. It refuses, as an Error, an access review whose accessPackage gives by name a package that two or more accessPackages entries declare, as displayName or previousDisplayName, whether or not one of them is renamed: the run keys a package by its name alone, so it cannot tell which package the review means. Name the package by its object id. A group's mail alias does not follow a rename. Declare mailNickname for the new name; the validator warns when a renamed group keeps the alias derived from its old name or declares none. A mailNickname that is empty, longer than 64 characters or holds one of @ ( ) \ [ ] " ; : < > , a space or a tab is an Error, since Microsoft Graph was measured to refuse each of them; the validator refuses any other white space or control character too, which was not measured. New-OERGroup refuses to derive an alias that would be empty or longer than 64 characters (DerivedMailNicknameInvalid), creating nothing: pass -MailNickname for such a name. A roleAssignments or roleManagementPolicies scope written as a path is an Error too, beside the existing rule against a trailing or doubled '/', when it holds a '.' or '..' segment, a '%', a '\', a '?' or a '#', white space at the start or end of the scope or of a segment, white space other than a space, or a control character. The mg:, sub: and subscription: forms are resolved by a lookup and are not checked for these. One or more spaces between other characters of a segment are accepted, since an Azure resource name can hold them, so an export that holds a role assignment at such a resource applies. A resource whose name holds one of the refused characters, which a few resource types allow, cannot be declared: the export leaves a role assignment there out (see ROLE ASSIGNMENTS AT A SCOPE NO DOCUMENT CAN DECLARE above), and a hand-written entry for one is to be removed. A -WhatIf plan reports an access package declared in a catalog the same document creates as 'would create access package' instead of Failed. VALIDATING AND APPLYING A DOCUMENT Test-OERStructure and Invoke-OERStructure read a document as written on PowerShell 7.5 or later. On PowerShell 7.2 to 7.4 the JSON reader turns a string that holds an ISO 8601 date and time with seconds, such as an assignment policy's expirationDateTime of 2027-01-01T00:00:00Z, into a date, so the text written is lost: each such value is an Error at its path, and Invoke-OERStructure refuses the document. Run both on PowerShell 7.5 or later for such a document; a date alone, such as 2026-07-01, or a time without seconds, such as 2026-07-01T10:00, stays a string. A displayName that is not a string is an Error too, and so is a group template that cannot be turned into a name with its tokens, which used to be that group's Failed row and now refuses the whole document before anything is read or written. A token's value is inserted as text, so a '$' in it stays as written. Invoke-OERStructure writes nothing for a policy whose live read fails or returns nothing: a roleManagementPolicies entry, or a group's pimPolicy for that access type, is Failed with the read's error (PolicyNotFound or PimPolicyNotFound for an empty answer), also under -WhatIf. A group's pimPolicy access type that declares one half of an expiration pair alone (allowPermanentEligibility or eligibleDurationDays, allowPermanentActive or activeDurationDays) is Failed the same way, with PimRuleValueUnknown, when the live policy gives no value for the other half, or a duration that is not a positive whole number of days, and the declared half does not already match; declare both halves. A PIM policy change that empties an approver side while no other approver remains is Failed with ApproverRequired in a -WhatIf plan, as in a run, and so, for a directory role or a group policy, is requireApproval true over a live approval stage that names nobody. -Prune never removes a group's last remaining owner, nor its last remaining user owner, which Microsoft Graph refuses to remove even while a service principal is an owner (measured); the row is Skipped. An owner the same run adds counts as one standing, and as a user owner when the document names it by user principal name, so replacing a user owner with one named that way completes in one run. A section set to null is not declared and gives no row. In a runspace with no host of its own, such as a thread job, a run without -WhatIf or -Confirm:$false (or a $ConfirmPreference of None) is refused with ConfirmationUnavailable before it signs in, also one that would change nothing; run it with -Confirm:$false or -WhatIf. In another host that cannot show the prompt, an entry stopped by it is Failed, with a row that says the host cannot show the confirmation prompt and that nothing was written. A run within about 30 seconds of a write can send the same update again, since Microsoft Graph can still answer a read with the value from before the write; the update changes nothing more, but its row reads Updated again. NAMING STANDARD Test-OERStructure -NamingStandard checks the names in a document, or in the inventory.json of an Export-OERInventory bundle, against a naming standard file. A name gets one Warning for each rule it breaks, with Section, Item, Path and a message that says which rule the name breaks and what was expected. Valid does not change, and Invoke-OERStructure, which takes no naming standard, applies the document as before. An example standard is kept in the repository at docs/examples/naming-standard.json, with its JSON Schema at docs/examples/naming-standard.schema.json; neither ships inside the installed module. The check covers the groups inventory.json holds: by default the RBAC-relevant security groups (a role-assignable group, one with PIM eligibility, one with a pimPolicy). Export-OERInventory -AllGroupsDetailed keeps every group inventory.json covers in full detail and -IncludeSyncedGroups keeps the synchronized ones; a Microsoft 365 group whose securityEnabled is false is in groupsRoster.json only. The standard's includeSyncedGroups therefore matters for an export only when the export was made with Export-OERInventory -IncludeSyncedGroups (or -AllGroupsDetailed), a different switch with a similar name. The standard is a JSON file: version ("1.0"), separator, maxLength, sections (the ones to check: groups, administrativeUnits, catalogs, accessPackages, assignmentPolicies and accessReviews), includeSyncedGroups, segments (in order) and exceptions. A segment has a name and may have values (a closed list) or pattern, but not both, a case (PascalCase, camelCase, UPPERCASE or lowercase) and optional. $schema and description are free text. A Warning names one of six rules: maxLength, separator, segmentCount, values, pattern and case. A pattern, in a segment or in exceptions, has to match the whole text, and is case-sensitive unless it carries (?i): Fin does not match the segment Finance, and the exception 'All Users' does not exempt 'All Users Copy'. A values entry is compared exactly, letter case included, and is never read as a pattern. PascalCase is an uppercase letter first and then only letters and digits; camelCase is the same with a lowercase letter first; UPPERCASE holds no lowercase letter and lowercase no uppercase letter. Letters are Unicode letters. A name is split at the separator, as text. An optional segment is present when the number of parts needs it, and otherwise when the part fits the segment. A name with an empty segment, no separator where one is needed, or too few or too many segments is reported for that and not checked segment by segment. A name that matches an exception is never checked. The name checked is the one an object carries after the step: its displayName, or for a group declared with template and tokens the name they give. A group whose template cannot be turned into a name gets one Warning saying it was not checked. A previousDisplayName is never checked. The assignment policies of an access package are checked when assignmentPolicies is listed, and their Warnings carry the section accessPackages. A group with onPremisesSynced true is not checked unless includeSyncedGroups is true; its one Warning says so. When it is checked, each Warning says that its name can only change in the on-premises directory. A file that cannot be used (a path that is not a file, a file that exists but cannot be read, malformed JSON, a key or value the standard does not allow, a pattern that does not compile) is refused once with the error InvalidNamingStandard, which says what is wrong, and nothing is validated. Known limit: the JSON parser reads a string that holds an ISO 8601 date and time, such as 2024-01-01T10:00:00, as a date (a date alone, such as 2024-01-01, stays a string), so such a value in the file is refused as not a string. PERMISSIONS Ask the module rather than reading a table kept in step by hand: Get-OERRequiredScope -Cmdlet New-OERGroup Get-OERRequiredScope -Cmdlet Get-OERGroup* -Unique Get-OERRequiredScope -Unique It reads a static table, so it needs no tenant and no sign-in and answers before Connect-OER. Three things worth knowing about the answers: - The area a cmdlet belongs to is not its permission boundary. Microsoft Graph grants permissions by the resource a call touches, so one scope line covering a whole cohort is wrong by construction. The three *AdministrativeUnitScopedRole cmdlets write directory role memberships rather than unit memberships and need a role-management write permission; Get-OERGroupPimPolicy and Set-OERGroupPimPolicy hit roleManagementPolicies and need the RoleManagementPolicy permissions, not the eligibility permissions the other PIM-for-Groups cmdlets use. - Each answer is transitive. It covers the cmdlet's own calls plus every call its internal helpers make, including the directory reads behind friendly-name parameters such as -User, -Group and -ServicePrincipal. Consenting to what is listed is enough for every parameter form; the Note property says so where a permission is needed for only one of them. - The Transport property disambiguates an empty list: Graph, Arm, GraphAndArm, or None for a cmdlet that touches no tenant at all. An empty GraphScope on an ARM-only cmdlet means "none needed", never "unknown". Where an entry lists User.ReadBasic.All, Group.Read.All and Application.Read.All, a single Directory.Read.All covers all three; the narrower permissions are listed because the module prefers least privilege. Where an entry lists Directory.Read.All instead, that cmdlet reads directoryObjects, which accepts nothing narrower, so the covered permissions are deliberately NOT also listed and the Note property says which parameter makes the call. Azure cmdlets additionally need an ARM token: pass -IncludeARM to Connect-OER, or let the cmdlet acquire one. TENANT PROFILE A Tenant Profile is a PSD1 file holding per-tenant configuration, stored under <home>/.config/Omnicit.EntraRBAC/Profiles/<alias>.psd1 and managed with New-OERConfiguration, Get-OERConfiguration, Set-OERConfiguration and Remove-OERConfiguration. <home> is the current user's profile folder as .NET reports it, falling back to $HOME: the same directory as $env:USERPROFILE on Windows, and the user's home directory on Linux and macOS. Connect-OER -TenantAlias <alias> resolves the TenantId from it. Only TenantId is required; every other section is optional. A key, a list item or a value that carries an apostrophe, straight or curly, is written escaped, so it reads back unchanged. @{ TenantId = '00000000-0000-0000-0000-000000000000' Naming = @{ Group = 'role_sec_{area}_{tier}' AU = '{prefix}_au_{name}' Catalog = 'CAT-{org}-{scope}' } Defaults = @{ PrimaryApprovers = @('<guid>', '<guid>') EscalationApprovers = @('<guid>') Catalog = 'CAT-IT-PRG-Core' AuthenticationContextId = 'c1' ActivationMaxHours = 8 } } Naming templates substitute {token} placeholders case-insensitively. An unresolved placeholder is an error, so a malformed name is never produced. Set-OERConfiguration preserves sections you do not supply; New-OERConfiguration is create-only and errors if the alias already exists. A profile may also carry an optional Environment key naming the sovereign cloud that tenant lives in: Global, USGov, USGovDoD or China. See SOVEREIGN CLOUDS below. LONG RUNS An access token lasts about an hour: Microsoft Entra ID gives one a default lifetime of 60 to 90 minutes, so a run longer than that outlives the token it started with. Where the sign-in type allows it, the module renews a token in two places: when a command starts within five minutes of the token's expiry, and in the middle of a command when Microsoft Graph or Azure Resource Manager rejects the token with 401, after which the rejected request is sent once more. What a renewal costs depends on the sign-in type: Interactive When a command starts near the token's expiry, first tries to renew without a prompt, from the interactive credential AzAuth already holds (see below); when that is not possible, and always for the renewal after a 401, the browser opens to sign in again, so a long run can still ask you to sign in. The account picker lets you choose another account of the same tenant, and the command then carries on as that account: choose the account the session signed in with Device code Every renewal prints a new code to enter, about once an hour, as every device code sign-in does: see the known limitation under SWITCHING TENANTS Managed identity Renews with no prompt Client secret Not renewed, since the module never keeps the and certificate secret or certificate: once the token has expired a request is refused with AppOnlyTokenRefreshUnsatisfiable, and a command that starts within five minutes of its expiry, or later, fails to sign in with AppOnlySessionCredentialUnavailable, until you run Connect-OER with the secret or certificate again The silent renewal of an interactive session. From AzAuth 2.11.0 the module asks Get-AzToken to renew an interactive session without -Interactive, so AzAuth answers from the interactive credential it already holds. It does so only for the tenant, client and cloud the session signed in with, and accepts the token only when it belongs to the account and tenant the session already has; any other outcome is discarded and the browser opens once, as before. It is not used for Connect-OER itself, for the renewal after a request was rejected with 401 (which always signs in interactively), for a claims challenge, for a cloud switch or for any sign-in type but interactive. Two limits. AzAuth holds one interactive credential, the one the last interactive request built. A renewal is silent only for the resource whose interactive request came last, and -IncludeARM sign-ins request Azure Resource Manager last, so a session that uses both resources alternates that credential between the two and still prompts for each renewal; the silent renewal helps a session that uses only Microsoft Graph (every sign-in requests Graph first). And this is read from the sources of AzAuth 2.11.0 and Azure.Identity 1.21.0, not measured against a tenant: if the refresh inside the stored credential fails because it needs the user, Azure.Identity opens the browser from that non-interactive request, with no timeout the module can bound, and the module then signs in interactively, which can be a second browser. So a long interactive run still needs someone at the keyboard, though it may be asked to sign in less often. In a console started with pwsh -NonInteractive an interactive sign-in, the silent renewal included, is refused with InteractiveSignInUnavailable before any tenant lookup or token request; a session already held and still valid is answered from the cache. Use a certificate, a client secret, a managed identity or -DeviceCode there. Only the console host started with -NonInteractive is detected: another host (an editor's console, say), or a service or scheduled task that runs pwsh without -NonInteractive, is not, and an interactive sign-in there is the caller's to avoid. Before a long run, run Connect-OER -Force with the session's own sign-in (the same -TenantId, credential and -IncludeARM as the first Connect-OER), so the run starts with a new token. A plain Connect-OER with the same sign-in returns the session it has while its token has more than five minutes left, and a bare Connect-OER -Force signs in interactively, as a bare Connect-OER always does. Stay at the keyboard during an interactive or device code run: a managed identity is the only sign-in that renews unattended. An app-only run renews only between commands. Run Connect-OER -Force with the secret or certificate before each command, and keep each command shorter than a token's lifetime. Without -Force, Connect-OER with the same credential returns at once while the token has more than five minutes left and signs in again when it has less. A renewal nobody completes (a browser sign-in or a device code left unanswered) fails like any other sign-in: the request it was for is not sent, and the session is left uncertain, so a later command that names no tenant sends nothing, as GRAPH SDK SESSION below describes. SWITCHING TENANTS One PowerShell session works in one tenant at a time. Whether a later Connect-OER call naming a different tenant actually switches depends on the sign-in type: Client secret Reuses the credential built for the earlier tenant, so the switching sign-in fails by default -- or, with AZURE_IDENTITY_DISABLE_MULTITENANTAUTH set, requests its token from the earlier tenant, and the module refuses that token (TenantMismatch) -- until you run Connect-OER ... -Force Device code Does not send the tenant you name; its token may come from the signed-in account's own tenant when that account cannot obtain one in the tenant you name, and the module then refuses it (TenantMismatch) -- and every device code sign-in asks for a new code: see the known limitation below Managed identity Does not send the tenant you name; its token normally comes from the identity's own tenant, and the module refuses it when that is not the tenant you name (TenantMismatch) Interactive Signs in to the tenant you name Certificate Signs in to the tenant you name KNOWN LIMITATION -- every device code sign-in asks for a new code. The module makes AzAuth build a new credential for every device code sign-in, Microsoft Graph and Azure Resource Manager alike, so each one prints a new code to enter -- two with -IncludeARM, one for each token. That covers the first sign-in in a process, one naming another tenant, a new Connect-OER after Disconnect-OER, and every renewal of the token: a command that starts within five minutes of its expiry, a request whose token Microsoft Graph or Azure Resource Manager rejects, and a claims challenge. Before this release such a sign-in could reuse the credential AzAuth keeps for the PowerShell process and never return, with no code printed and no error; Connect-OER -Force is no longer needed to avoid that. A command that needs only tokens the session already holds, each with more than five minutes left, asks for no code. Each code has to be entered, so a long device code run needs someone at the keyboard. The module checks each token against the tenant you name, whether you named it by its tenant ID or by a domain, and refuses a token issued for another tenant with TenantMismatch before it is used. For a domain it first looks the tenant ID up at the cloud's Microsoft Entra ID authority: one unauthenticated request per domain and cloud, remembered for as long as the module stays imported, and asked again at the next sign-in if it fails. A domain the authority does not resolve to a tenant is refused with TenantResolutionFailed, and no token is requested. A tenant is a GUID or a verified domain: any other value is looked up the same way and refused when it names no tenant, 'common' among them. 'organizations', which the module uses when no tenant is named and there is no session, names no tenant and is not checked against one. When a sign-in under it acquires an Azure Resource Manager token, that token must come from the same tenant as the session's Microsoft Graph token, or it is refused with TenantMismatch. When only the Microsoft Graph token is renewed, the session keeps its Azure Resource Manager token only if the renewed Microsoft Graph token comes from the same tenant; otherwise it drops it and, as soon as a command needs Azure Resource Manager, acquires a new one, checked the same way. The module also warns before a client secret sign-in for the same application when the tenant you name differs from the one the credential AzAuth is currently holding for that application was built for, and no Force is on the call: you did not pass -Force, and the module did not add Force itself for a sovereign-cloud switch. AzAuth only rebuilds that credential for -Force, for a different application, or for a different kind of sign-in -- a plain retry of the same switch, with none of those, reuses the same credential and keeps warning every time, whether the earlier attempt's token came from the old tenant or was refused outright. This is the module's own view: a Get-AzToken call made outside the module does not update it, and it resets only when Omnicit.EntraRBAC itself is re-imported. The warning does not stop the sign-in, but under -WarningAction Stop (or $WarningPreference = 'Stop') it does, before any token is requested -- the same safe direction as the module's existing ambient AZURE_AUTHORITY_HOST warning. Disconnect-OER clears this module's own session state and closes the Graph SDK session the module connected; it does not clear the credential AzAuth keeps for the process. Connect-OER -Force is the supported way, inside the same PowerShell process, to move a client secret sign-in to a new tenant. Name a tenant by its tenant ID (a GUID) or by a verified domain -- a Tenant Profile's TenantId included. Both are checked, and a token issued for another tenant is refused either way; a GUID saves the lookup. # Move a client secret sign-in for the same application to # another tenant Connect-OER -TenantId '00000000-0000-0000-0000-000000000000' ` -ClientId $AppId -ClientSecret $Secret -Force GRAPH SDK SESSION Connect-OER sets up a Microsoft Graph PowerShell SDK session in the current process: it calls Connect-MgGraph with the module's token, and so does the automatic sign-in of any other OER cmdlet. Disconnect-OER closes only the session the module connected. A session it did not connect, or has no record of connecting -- one another Connect-MgGraph started, say -- is left connected, with a warning written before the confirmation, so -WhatIf shows it. Run Disconnect-MgGraph to end that one; the next OER cmdlet signs in again, and its own Connect-MgGraph replaces it, as any Connect-MgGraph does. If another Connect-MgGraph -- yours, or another tool's -- replaces the module's session in the same process, the next OER cmdlet sends nothing: it refuses its Microsoft Graph calls with GraphSessionChanged instead of sending them under that session, and its Azure Resource Manager calls with SignInRefused. An error can be reported more than once for one cmdlet. The module never switches the session back by itself. Run Connect-OER with the same sign-in the session used -- for an app-only session, its certificate or client secret, since a bare Connect-OER signs in interactively -- to connect the module again, which takes the session back, or use a new PowerShell process. If the session is closed with Disconnect-MgGraph instead of Disconnect-OER, the next OER cmdlet signs in again by itself, except on an app-only session (client secret or certificate), which reports AppOnlySessionCredentialUnavailable until Connect-OER is run with the secret or certificate. Runspaces in one process (ForEach-Object -Parallel, Start-ThreadJob) share one Graph SDK session, so a parallel fan-out across tenants in one process gets GraphSessionChanged; run each tenant in its own process instead, with Start-Job or a separate PowerShell process. A sign-in that fails or is refused usually leaves the module's session as it was -- the previous tenant's, or none -- and a script carries on past the error. From then on the module sends nothing for a command that names no tenant: such a command (no -TenantId, or -TenantId organizations) is refused with SignInRefused before it requests a token, and so are its requests and the sign-ins of the cmdlets it calls. A command that names its tenant signs in as usual. A successful sign-in by a command that names its tenant, a successful Connect-OER with or without a tenant, or Disconnect-OER makes the module send again. Before this release, when a Connect-OER in this loop failed, the Invoke-OERStructure after it applied that alias's document, -Prune included, in the previous tenant; now it sends nothing: foreach ($Alias in $Aliases) { Connect-OER -TenantAlias $Alias -ClientId $AppId -CertificatePath $Pfx Invoke-OERStructure -Path "$Alias.json" -Prune } Invoke-OERStructure without -TenantId likewise refuses every document piped to it after one whose sign-in was refused. A Connect-OER whose parameters PowerShell cannot bind never runs, so it leaves nothing behind for the module to see. An empty -TenantAlias, typed or piped, is refused with InvalidTenantAlias, and an empty, whitespace or $null -TenantId with InvalidTenantId; both count as a refused sign-in. Every other cmdlet refuses an empty or $null -TenantId while PowerShell binds its parameters, so that command never runs and sends nothing: an empty cell in a loop over tenants no longer stands for the current session's tenant. New-OERConfiguration and Set-OERConfiguration store the tenant instead of signing in to it, so they also refuse a -TenantId of white space and invisible formatting characters only (such as U+200B or U+FEFF) while PowerShell binds their parameters: a blank tenant can no longer be entered into a profile through them. A profile already on disk with one is still read as it is, and keeps it through an update that does not name -TenantId. On any other cmdlet, a -TenantId of spaces is looked up like any value that is not a tenant ID, and refused with TenantResolutionFailed when it names no tenant. Export-OERInventory ends instead of carrying on: when its own sign-in is refused, or the Azure Resource Manager token it asked for cannot be acquired, it reads nothing, writes no bundle folder and returns no object, under every error preference. The sign-in's own error reaches the caller, as a terminating error when the sign-in terminated, so a try around the export catches it and, outside a try, only that statement ends. Get-OERInventory carries on after its own refused sign-in, but its document then names no tenant (no tenantId): a session an earlier command left is not the tenant it was read in. One OER pipeline works in one tenant with one identity. If commands in the same pipeline sign in to different tenants or identities, a command whose sign-in another one replaced sends nothing more: every request made while it runs is refused with SignInSuperseded, including the requests of a command that handles its output, a ForEach-Object script block among them. Most OER cmdlets sign in before any command in the pipeline processes input, so that is usually the first command. A cmdlet that reports a failed lookup under an error of its own carries the refusal's message in that error instead -- New-OERGroup's GroupResolveFailed, for one, which names the refusal (SignInRefused or SignInSuperseded) and the command it names, and carries the refusal's record as its inner exception. A tenant counts by the tenant its token was issued for, so naming one tenant by its GUID on one command and by its domain on another, or not naming it at all, is not refused for that alone. But a command that names the tenant differently from the session inherits nothing from it and signs in again -- interactively, when it names no credential, which on an app-only session means a browser prompt and another identity, and the pipeline is then refused as above. So name it the same way on every command, as "Name the tenant explicitly and consistently" under SOVEREIGN CLOUDS below says. Invoke-OERStructure signs in when it processes its document, not before, but without -TenantId it acts only under the session it began with: when another command in the same pipeline has signed in to a different tenant or identity by then -- any sign-in counts if the module held no session when it began -- it refuses that document with SignInSuperseded and sends nothing for it. New-OERAccessPackageApprovalStage, New-OERAccessPackageRequestorScope and New-OERAccessReviewStage do the same before they resolve the approvers, targets or reviewers they are given. Called inside a script block or a function in a pipeline, such as ForEach-Object { Invoke-OERStructure ... }, each of these commands begins only when that block runs, after every other command in the pipeline has begun and so after most of their sign-ins, and it takes the session they left for its own. A document exported by Get-OERInventory or Export-OERInventory names its tenant (tenantId, when it carries one), and Invoke-OERStructure refuses it in any other tenant with DocumentTenantMismatch, reading and writing nothing for it, whichever session it began with. A document without tenantId, and the three cmdlets above, still take that session for their own, so name -TenantId there. Run the commands as separate statements; to move objects between tenants, collect them in a variable first: # Read in one tenant, then write in another, as two statements $Filter = "startswith(displayName,'role_sec_')" $Groups = @(Get-OERGroup -TenantId $TenantA -Filter $Filter) foreach ($Group in $Groups) { New-OERGroup -TenantId $TenantB -DisplayName $Group.DisplayName } # Copy a structure the same way: read it, then apply it $Inventory = Get-OERInventory -TenantId $TenantA -Include Groups # The export names tenant A; apply it to B as a template $Inventory.PSObject.Properties.Remove('tenantId') Invoke-OERStructure -InputObject $Inventory -TenantId $TenantB -WhatIf # Not as one pipeline: New-OERGroup would run inside Get-OERGroup's # output and send nothing, and so would Invoke-OERStructure inside # Get-OERInventory's # Get-OERGroup -TenantId $TenantA ... | # ForEach-Object { New-OERGroup -TenantId $TenantB ... } # Get-OERInventory -TenantId $TenantA ... | # Invoke-OERStructure -TenantId $TenantB ... The second statement switches tenant in the same PowerShell process, so whether it reaches the tenant it names depends on the sign-in type, as SWITCHING TENANTS above describes. Invoke-OERStructure applies a document that names its tenant only in that tenant. Given a -TenantId that is another tenant ID, it refuses the document with DocumentTenantMismatch before it signs in, with no token request; otherwise, once it has signed in, it compares the document with the tenant the session's tokens were issued for. To apply an export in another tenant -- as a template, like the copy above -- change its tenantId to that tenant's ID or remove the key first. SOVEREIGN CLOUDS Connect-OER -Environment selects the sovereign cloud a session signs in to and calls. Four values are supported: Global (default) Worldwide commercial cloud graph.microsoft.com / management.azure.com USGov GCC High graph.microsoft.us / management.usgovcloudapi.net USGovDoD DoD dod-graph.microsoft.us / management.usgovcloudapi.net China 21Vianet microsoftgraph.chinacloudapi.cn / management.chinacloudapi.cn Microsoft 365 GCC uses the commercial (Global) endpoints and needs no -Environment at all. Only GCC High, DoD and a 21Vianet tenant are separate cloud boundaries; GCC itself is not. The chosen cloud becomes part of the session: every later cmdlet calls the cloud the session was established in, and switching clouds re-authenticates instead of reusing a token minted at the previous cloud's authority. Connect-OER -TenantId 'contoso.onmicrosoft.us' -Environment USGov -IncludeARM A Tenant Profile's optional Environment key stores the cloud for that tenant, so Connect-OER -TenantAlias picks it up automatically without repeating -Environment on every call; an explicit -Environment on the command line still overrides it. Name the tenant explicitly and consistently. The session is keyed on the tenant exactly as you spell it, so a later call naming a tenant the session was not established for inherits nothing from it -- including the same tenant written as a GUID one time and as a domain the next, and a Connect-OER -Environment USGov with no -TenantId at all, which records the tenant as 'organizations' rather than as yours. The cloud then resets to Global and that call signs in at the commercial authority. Its token and its endpoints stay consistent with each other, so nothing crosses a cloud boundary, but the sign-in fails at the authority with an error that names the tenant and never the cloud, which makes it easy to misread. (A tenant named by domain is looked up at that same authority first, and may be refused there instead, with TenantResolutionFailed, whose message does mention -Environment.) Pass the same -TenantId spelling on every call, or use a Tenant Profile whose Environment key carries the cloud for you. Known limitation: PIM-for-Groups is pinned to the Microsoft Graph beta endpoint (see PERMISSIONS above), and beta endpoint availability in US Government and China clouds is not established. A sovereign tenant that needs PIM-for-Groups may find that pinned beta path behaves differently, or not at all, from the commercial cloud this module is built and tested against. AZURE RESOURCE TARGETING Azure RBAC and PIM assignment cmdlets (New/Get-OERRoleAssignment, New/Get/Remove-OEREligibleRoleAssignment, New/Get/Remove-OERActiveRoleAssignment, Enable/Disable-OEREligibleRoleAssignment) accept a resource scope in three forms: - Raw scope id: -Scope '<full ARM resource id>' - Friendly decomposition: -Subscription <s> -ResourceGroup <rg> -ResourceType <type> -ResourceName <name> - Management group: -ManagementGroup <name-or-id> Get-OERRoleManagementPolicy and Set-OERRoleManagementPolicy accept the same scope forms minus -ResourceType and -ResourceName, because a role management policy is assigned at a management group, subscription or resource group scope. By contrast Set-OERRoleAssignment and Remove-OERRoleAssignment take -Id only: they act on one existing assignment, which already carries its own scope. Get-OERResource lists Azure resources within a subscription or resource group (client-side -Name/-ResourceType filters) and, with -IncludeRoleAssignments (optionally -ResolveNames), reports the role-assignment delegations on each resource. Output is tagged Omnicit.EntraRBAC.Resource and binds directly into the assignment cmdlets by property name -- for example: Get-OERResource -Subscription Prod -ResourceGroup rg-app -Name stgfoo | New-OERRoleAssignment -Role Reader -Group role_sec_readers Get-OERManagementGroup lists the management groups the caller can read, each with its parent (ParentId, ParentName, ParentDisplayName), read with at most one further call per list. Only the tenant root group has none. A parent that cannot be read is reported, after the groups, as one non-terminating ManagementGroupParentReadFailed error naming those groups, rather than left silently empty. A caller that can read no management group is refused by the list (AuthorizationFailed), not given an empty list. -Expand (the direct children) and -Recurse (the whole hierarchy) read below ONE group, so they need -Name: without it the call fails at parameter binding, or PowerShell asks for -Name where the host can prompt. ARM OBJECT IDS Get-OERResource, Get-OERResourceGroup, Get-OERSubscription and Get-OERManagementGroup expose the full ARM resource path as ResourceId, never as a bare Id. This is deliberate: -PolicyId on Get-OERRoleManagementPolicy/Set-OERRoleManagementPolicy and -RoleEligibilityScheduleId on Enable-OEREligibleRoleAssignment both carry an Id alias and bind from the pipeline by property name, so a bare Id on an ARM-resource object would silently mis-route as a policy id or an eligibility schedule id instead of the resource path it actually is. Get-OERRoleDefinition follows the same no-bare-Id rule but stores the ARM role definition path the other way round: RoleDefinitionId is the one STORED value (so a piped role definition still binds -Role on New-OERRoleAssignment, and its PIM siblings, through that alias), and ResourceId is exposed as an AliasProperty of it rather than a second stored copy. Either property reads the same value; there is deliberately no bare Id that could mis-bind downstream. Role-ASSIGNMENT-shaped objects (RoleAssignment, EligibleRoleAssignment, ActiveRoleAssignment) are a different case: they identify an assignment INSTANCE, not an ARM resource, so their own domain-specific id (RoleAssignmentId, RoleEligibilityScheduleId, RoleAssignmentScheduleId) is the right property to carry, not ResourceId. Set-OERRoleAssignment and Remove-OERRoleAssignment act on -Id alone (the RoleAssignmentId, which already embeds the scope) rather than on the friendly -Subscription/-ResourceGroup/-ManagementGroup decomposition above. ACCESS PACKAGE ASSIGNMENT POLICY -- GRANULAR SETTINGS New-OERAccessPackageRequestorSettings builds a requestor-settings object that controls how users and managers may submit requests: $Rs = New-OERAccessPackageRequestorSettings -AllowSelfRequest ` -AllowManagerRequest -ManagerLevel 1 -AllowSelfExtend All -Allow* parameters are switches. Pass the result to New-OERAccessPackageAssignmentPolicy or Set-OERAccessPackageAssignmentPolicy via -RequestorSettings. Both policy cmdlets also accept (the toggles are switches): -RequireApproval -- enable the approval workflow -RequireRequestorJustification -- require a justification from the requestor -RequireApprovalForUpdate -- require approval for extension requests -DurationInHours <int> -- assignment lifetime in hours (PT{n}H) -DisableAssignmentNotifications -- suppress built-in assignment emails New-OERAccessPackageApprovalStage accepts -ApproverInfoVisibility (Default / Visible / NotVisible) to control whether approver identity is shown to the requestor. Set-OERAccessPackageAssignmentPolicy is a read-modify-write (GET then PUT): fields you do not supply are preserved from the live policy (including the out-of-scope reviewSettings, custom questions and custom extension stages and, while -RequestorScope is not passed, an automatic assignment policy's settings). Microsoft Graph removes custom extension stages on an update that does not send them, so the read expands each stage's custom extension and the update sends every stage back; a stage whose custom extension id could not be read is refused with InvalidPolicyInput, also under -WhatIf, and no update is sent. A declared -RequestorScope never inherits the automatic assignment settings. An update without -RequestorScope of a policy whose allowedTargetScope reads as unknownFutureValue is refused with UnknownAllowedTargetScope, also under -WhatIf: Microsoft Graph names a scope this module cannot read that way, and an update that sent the placeholder back was measured to store it as the policy's scope. Pass -RequestorScope to set the scope; Invoke-OERStructure reports such a policy Failed whatever its entry declares, a requestorScope included, since the engine has no such escape. Given -DisplayName and no other property to update, the cmdlet renames the policy when the name differs from the live one, carrying everything else forward, and reports NothingToUpdate after one read when it is the live name, compared ignoring case. On both New- and Set-OERAccessPackageAssignmentPolicy the Description parameter defaults to the policy display name when omitted. Limitations: policy-embedded access reviews (reviewSettings), custom requestor questions and custom extension stages cannot be expressed in an apply document -- they are preserved by the read-modify-write. Fallback approvers are preserved when their stage is unchanged but are dropped when the stage is rebuilt. DURATION VOCABULARY Most cmdlets that accept a lifetime or expiration share the same duration vocabulary. (A few narrowly-scoped parameters, such as Set-OERRoleManagementPolicy -ActivationMaxHours and New-OERAccessPackageApprovalStage -EscalationDays, keep their own already-unit-specific name instead.) -Duration <string> -- raw ISO 8601 duration (P365D, PT8H) -DurationDays <int> -- whole days, converted to P{n}D -DurationHours <int> -- whole hours, converted to PT{n}H -DurationInDays / -DurationInHours (access packages, access reviews) and -EligibleDuration / -ActiveDuration (PIM policies) are the canonical parameter names; -DurationDays / -DurationHours and -EligibleDurationDays / -ActiveDurationDays are the module-standard aliases this unification added. Exception: Add-OERGroupEligibility -Duration also accepts a bare whole day count (-Duration 365 means 365 days) for back-compat with its original shipped signature. This is deliberate and scoped to that one cmdlet -- New-OEREligibleRoleAssignment -Duration, by contrast, validates strictly against the ISO 8601 pattern and rejects a bare number such as 365 outright. SEE ALSO Connect-OER Get-OERConfiguration Get-OERGroup Get-OERAdministrativeUnit Get-OERAccessPackage Get-OERAccessReviewDefinition Get-OERRoleAssignment Get-OEREligibleRoleAssignment Get-OERRoleManagementPolicy Get-OERInventory Invoke-OERStructure |