public/Get-MsecBusinessCentralAccess.ps1
|
function Get-MsecBusinessCentralAccess { <# .SYNOPSIS Who holds which permission set in Business Central, across every environment and company - including the Entra applications that Entra alone cannot tell you about. .DESCRIPTION The access review for the finance system. One row per user per permission set per company, with the user's state and licence type alongside, so "who has SUPER" is a filter rather than an afternoon in the portal. AN ENTRA APPLICATION'S REAL ACCESS IS NOT VISIBLE FROM ENTRA. Service-to-service access takes two steps in two systems: an application permission in Entra, and then a separate record in Business Central's 'Microsoft Entra applications' page where the app is enabled and assigned permission sets. Entra shows only that an app MAY reach Business Central; what it can touch is set here. An app holding API.ReadWrite.All might carry a trivial permission set, or a broad one, and Get-MsecEntraAppConsent cannot tell the two apart. IsApplication marks those principals. BLANK COMPANY MEANS EVERY COMPANY, NOT NONE. An assignment with Scope 'System' carries an empty company, because it applies across the whole environment. Rendering that as a missing value - or filtering it out as incomplete - turns the broadest grant in the system into the one that looks like nothing. Company is reported as '(all companies)' for exactly that reason. A DISABLED USER STILL HAS ASSIGNMENTS, so State travels with every row. A list of SUPER holders that does not say which of them are disabled overstates the live exposure, and one that silently drops them hides an account that need only be re-enabled. userPermissionSets IS NOT USED, DELIBERATELY. It returns HTTP 400 on some environments and data on others within the same major version - measured on one tenant: working on two environments and failing on three. accessControls answers the same question and works everywhere, so the flaky endpoint is avoided rather than wrapped in a retry that would quietly return less from some environments than others. Runs as the signed-in user - see the note below. .PARAMETER Environment Limit to named environments. All of them by default. .PARAMETER SuperOnly Only assignments of SUPER - the permission set the product documents as "This role has all permissions." .PARAMETER ApplicationsOnly Only Entra application principals, not people. .PARAMETER IncludeDisabled Include assignments held by disabled users. Excluded by default, because a review of live access should not be padded with accounts that cannot sign in - they are still reachable with -IncludeDisabled, and the count of what was hidden is reported. .EXAMPLE Get-MsecBusinessCentralAccess -SuperOnly Everyone with unrestricted access to the finance system, in every environment. .EXAMPLE Get-MsecBusinessCentralAccess -SuperOnly | Where-Object { $_.AuthenticationEmail -notlike '*@contoso.com' } External parties holding unrestricted access - partners, consultants, technicians. .EXAMPLE Get-MsecBusinessCentralAccess -ApplicationsOnly | Select-Object Environment, UserName, RoleId, AppName What each service-to-service integration can actually do, which Entra does not show. .EXAMPLE Get-MsecBusinessCentralAccess | Group-Object Environment, RoleId | Sort-Object Count -Descending The shape of permission assignment across the estate. .OUTPUTS PSCustomObject per assignment, PSTypeName 'MsecBusinessCentralAccess'. .NOTES RUNS AS THE SIGNED-IN USER, NOT AS THE msec APP - see Get-MsecBusinessCentralEnvironment for why. Needs Connect-AzAccount, not Connect-Msec. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string[]] $Environment, [switch] $SuperOnly, [switch] $ApplicationsOnly, [switch] $IncludeDisabled ) $ctx = Get-MsecBusinessCentralToken $res = $ctx.Resource $headers = $ctx.Headers try { $environments = @((Invoke-RestMethod -Headers $headers -ErrorAction Stop -Method Get ` -Uri "$res/admin/v2.24/applications/businesscentral/environments").value) } catch { $status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { $null } if ($status -in 401, 403) { throw "Business Central admin API returned $status. This command runs as the signed-in user and needs a Business Central or Dynamics 365 administrator role. Original: $($_.Exception.Message)" } throw "Could not list Business Central environments: $($_.Exception.Message)" } if ($Environment) { $wanted = @($environments | Where-Object { $_.name -in $Environment }) $missing = @($Environment | Where-Object { $_ -notin @($environments.name) }) foreach ($m in $missing) { Write-Warning ("No environment named '{0}'. Present: {1}" -f $m, (@($environments.name) -join ', ')) } $environments = $wanted } $hiddenDisabled = 0 foreach ($e in $environments) { $name = [string] $e.name $companies = $null try { $companies = @((Invoke-RestMethod -Headers $headers -ErrorAction Stop -Method Get ` -Uri "$res/v2.0/$name/api/v2.0/companies").value) } catch { # Skipped, not silently empty: an environment that refused enumeration is a gap in # the review and the reader has to know which one. Write-Warning ("Could not list companies in environment '{0}', so its access is NOT included below: {1}" -f $name, $_.Exception.Message) continue } if (-not $companies.Count) { Write-Warning ("Environment '{0}' returned no companies, so no access could be read from it." -f $name) continue } # Both endpoints are routed through a company id but return environment-wide results, so # one company is enough. Iterating every company would multiply identical rows. $cid = $companies[0].id $users = @{} try { foreach ($u in @((Invoke-RestMethod -Headers $headers -ErrorAction Stop -Method Get ` -Uri "$res/v2.0/$name/api/microsoft/automation/v2.0/companies($cid)/usersPermissions").value)) { $users[[string]$u.userSecurityId] = $u } } catch { Write-Warning ("Could not read user detail in '{0}' - State, LicenseType and AuthenticationEmail will be blank on its rows: {1}" -f $name, $_.Exception.Message) } $controls = $null try { $controls = @((Invoke-RestMethod -Headers $headers -ErrorAction Stop -Method Get ` -Uri "$res/v2.0/$name/api/microsoft/automation/v2.0/companies($cid)/accessControls").value) } catch { Write-Warning ("Could not read access controls in environment '{0}', so its access is NOT included below: {1}" -f $name, $_.Exception.Message) continue } foreach ($c in $controls) { $sid = [string] $c.userSecurityId $user = $users[$sid] $state = if ($user) { [string] $user.state } else { $null } if (-not $IncludeDisabled -and $state -eq 'Disabled') { $hiddenDisabled++; continue } # A non-zero applicationId is an Entra application, not a person. $appId = [string] $user.applicationId $isApp = [bool]($appId -and $appId -ne '00000000-0000-0000-0000-000000000000') if ($ApplicationsOnly -and -not $isApp) { continue } if ($SuperOnly -and [string]$c.roleId -ne 'SUPER') { continue } # expiryDate of 0001-01-01 is the product's "no expiry", not a date in the past. # COMPARING AGAINST DateTime::MinValue DOES NOT CATCH IT: parsing '0001-01-01T00:00:00Z' # converts to local time, so in any timezone east of UTC the sentinel comes back as # 0001-01-01T00:54:00+00:54 - greater than MinValue, and the guard lets it through as # a real expiry in the year 1. The year is what actually distinguishes the two. $expiry = $null if ($user -and $user.expiryDate) { $parsed = [datetime]::MinValue if ([datetime]::TryParse([string]$user.expiryDate, [ref] $parsed) -and $parsed.Year -gt 1) { $expiry = $parsed } } [PSCustomObject]@{ PSTypeName = 'MsecBusinessCentralAccess' Environment = $name EnvironmentType = [string] $e.type UserName = [string] $c.userName FullName = [string] $c.fullName AuthenticationEmail = if ($user) { [string] $user.authenticationEmail } else { $null } State = $state LicenseType = [string] $c.userLicenseType # SUPER is documented as "This role has all permissions." RoleId = [string] $c.roleId RoleName = [string] $c.roleName # Empty means EVERY company, not none - see the description. Company = if ([string]$c.company) { [string] $c.company } else { '(all companies)' } Scope = [string] $c.scope IsApplication = $isApp ApplicationId = if ($isApp) { $appId } else { $null } AppName = [string] $c.appName ExpiryDate = $expiry UserSecurityId = $sid } } } if ($hiddenDisabled -gt 0) { Write-Warning ("{0} assignment(s) held by disabled users were excluded. They are still assignments - re-enabling the account restores them. Use -IncludeDisabled to see them." -f $hiddenDisabled) } } |