The toolkit is unsigned and uses delegated Microsoft Graph read permissions. Consent, your signed-in role and tenant policy still matter. It does not change users, licences or configuration. Keep the shared helper beside the scripts; use a fresh PowerShell session and a new output folder. A completed export includes COMPLETE.json.
Inspect the full source here before downloading. The included README covers installation, examples, permissions, known limits and Microsoft documentation. No tenant credentials or reports are sent to hostme.ie.
README.md
# hostme.ie — Microsoft 365 admin reporting toolkit
Version **0.2.0 preview**, reviewed 23 September 2026. Free, ungated PowerShell source.
Five reports for an authorised administrator. The scripts authenticate to Microsoft Graph and make GET requests only. They do not remove users, revoke access, change licences, send messages or change your tenant configuration. Authentication can still request administrator consent to the listed read scopes; review that separately under your organisation's policy.
## What is included
| Script | Output | Delegated Graph scopes |
|---|---|---|
| Export-M365LicenceReport.ps1 | Purchased-capacity fields and one row per assigned user/SKU; disabled accounts with licences flagged for review | User.Read.All, LicenseAssignment.Read.All |
| Export-M365GuestReview.ps1 | Guest identity, creation date, invitation state and account-enabled field | User.Read.All |
| Export-M365GroupMembers.ps1 | Direct user members per group and a group summary, including groups with no direct users | GroupMember.Read.All, User.ReadBasic.All |
| Export-M365GroupOwnerReview.ps1 | Returned user owners of cloud Microsoft 365 groups, with a review summary and explicit out-of-scope rows | GroupMember.Read.All, User.ReadBasic.All |
| Export-M365AppCredentialExpiry.ps1 | App-registration secret/key expiry metadata, plus all returned applications | Application.Read.All |
Keep `Hostme.GraphReports.psm1` beside the five scripts. It handles authentication, paging and CSV output. The `samples` folder contains **fictional** CSV examples; those files are not customer data.
## Requirements
- PowerShell 7, Microsoft public-cloud work/school tenant, and authorisation to review that tenant. Personal accounts and sovereign clouds are outside this release's scope.
- Microsoft.Graph.Authentication 2.x. Install from the official PowerShell Gallery, following your organisation's module policy. The scripts do not install anything automatically.
- Administrator consent where required, plus a signed-in role allowed by the endpoint. For the SKU report, Microsoft's documentation lists Directory Readers among supported roles. You do not need to use Global Administrator merely to run a report. Tenant restrictions and privileged-group controls may require a separate authorised review.
- A fresh PowerShell process with no existing Graph session, and a new output folder for each report. Tenant ID must be your actual tenant GUID, available in Microsoft Entra's overview.
## Run one report
Review the source before running it. These scripts are unsigned. Follow your organisation's code-signing/execution policy; do not disable protections to run a download.
```powershell
# Install the documented dependency if your policy permits:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery -MinimumVersion 2.0.0
# Open a fresh PowerShell 7 session:
pwsh -NoProfile
# Change into the extracted toolkit folder. Replace the tenant GUID.
./Export-M365LicenceReport.ps1 `
-TenantId 'YOUR-TENANT-GUID' `
-OutputDirectory './reports/licences-2026-09-23'
```
Choose `Export-M365GuestReview.ps1` or `Export-M365GroupMembers.ps1` in the same way, with its own new output directory. Sign in as the authorised account. The tenant and granted scopes are checked before data is requested. Authentication is process-scoped and this toolkit disconnects its own session afterwards.
Open the CSV files in Excel or import them with PowerShell. **A successful report has `COMPLETE.json`**, including the tenant, completion time and row counts. If it is absent, treat the folder as incomplete. A failed Graph page prevents any CSV export; a later disk-writing failure can leave an incomplete folder. Choose a fresh folder when retrying. Existing output is never overwritten.
## Two additional reports
```powershell
./Export-M365GroupOwnerReview.ps1 -TenantId 'YOUR-TENANT-GUID' -OutputDirectory './reports/owners-new'
./Export-M365AppCredentialExpiry.ps1 -TenantId 'YOUR-TENANT-GUID' -OutputDirectory './reports/expiry-new' -WarningDays 30
```
Use a new folder each time. `WarningDays` accepts 1–3650 and defaults to 30. The expiry report includes expired credentials, credentials due within the window and later expiries; the CSV is not restricted to the warning window. It exports identifiers and date metadata, never secret values, certificate bytes or tokens. Key credentials include a `KeyType` column; do not assume every key is a certificate.
## Interpret the reports correctly
- **Licence allocation is not usage.** A disabled account can legitimately retain a licence. The report does not check retention, mailbox state, sign-in activity, contract commitments, group-based assignment sources or prices. `EnabledUnits` and `ConsumedUnits` are Graph's reported SKU values, not a saving calculation. Unknown SKU IDs remain visible for review.
- **Guest age is not inactivity.** Invitation acceptance and account-enabled status do not prove recent access or continuing business need. This tool does not fetch sign-in logs or decide who should be removed.
- **Group membership is direct users only.** Nested groups, devices, service principals, owners and effective access are excluded. A zero count means no returned direct users, not necessarily an empty group. Hidden membership or incomplete user details causes the report to stop. This release does not request Member.Read.Hidden.
- **Group ownership is a user-owner review, not proof a group is ownerless.** Only cloud Microsoft 365 groups (`groupTypes` contains `Unified`, not currently synced) are assessed. Other groups remain in the summary as not assessed, with a blank owner count. Microsoft documents unavailable ownership for Exchange-created/distribution/synced groups and incomplete service-principal ownership in Graph v1.0. Zero means no user owner was returned; verify in the admin centre. One owner is a continuity review prompt, not an error. This report does not check whether owners are active or appropriate, or assign anyone access.
- **Credential expiry is not credential usage or validation.** The report reads app registrations, not enterprise-app/service-principal SAML signing certificates, federated credentials or managed identities. A credential-free app is not labelled broken. `DaysUntilExpiry` is whole 24-hour periods rounded down (including negatives); use `ExpiresUtc` for the exact deadline. Start dates are exported when present; a future expiry does not establish that a credential is active or used. Plan rotation with the app owner and verify dependencies yourself. The scripts do not rotate or delete credentials. Selecting key credentials is subject to Microsoft's per-tenant API throttling; no extra parallel requests are introduced.
- Graph data can lag recent changes. A report is a collection over time, not a transactionally consistent snapshot. Large tenants can take time and encounter throttling; the SDK handles its own request retries. If it ultimately fails, resolve the error and rerun into a new folder. Do not infer zero from an error.
- CSV text starting with a spreadsheet formula prefix is prefixed with an apostrophe. This is deliberate display protection; use the stable ID columns to reconcile records.
- Reports contain directory information and tenant IDs. Store them in an access-controlled location, set a retention period and do not upload them publicly. No telemetry or report upload is built into this toolkit.
## Verification and release status
This preview passed 46 offline fixture checks on PowerShell 7.6.6: parsing, multi-page retrieval, empty reports, wrong-tenant refusal, missing scopes, permission errors, failed later pages, pagination loops/external URLs, hidden membership, incomplete records, spreadsheet formula text, and preserving existing output/sessions. **It has not been validated against a live Microsoft 365 tenant.** Trial it in an authorised test tenant before adopting it in production. Offline fixtures cannot establish real tenant policies, roles, API behaviour or performance.
`SHA256SUMS.txt` lists source and sample file hashes. To inspect a downloaded file locally:
```powershell
Get-FileHash ./Hostme.GraphReports.psm1 -Algorithm SHA256
```
Hashes identify the release bytes; a checksum bundled with a file is not an independent security signature. Obtain the ZIP from https://hostme.ie/free-downloads/engineers/ and compare its separately displayed SHA-256 value.
## Sources and maintenance
- [Graph authentication](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands)
- [List users](https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0)
- [List subscribed SKUs](https://learn.microsoft.com/en-us/graph/api/subscribedsku-list?view=graph-rest-1.0)
- [List groups](https://learn.microsoft.com/en-us/graph/api/group-list?view=graph-rest-1.0)
- [List direct group members](https://learn.microsoft.com/en-us/graph/api/group-list-members?view=graph-rest-1.0)
- [List group owners and endpoint limitations](https://learn.microsoft.com/en-us/graph/api/group-list-owners?view=graph-rest-1.0)
- [List applications and permissions](https://learn.microsoft.com/en-us/graph/api/application-list?view=graph-rest-1.0)
- [Credential expiry metadata](https://learn.microsoft.com/en-us/graph/api/resources/passwordcredential?view=graph-rest-1.0)
- [Key credential metadata](https://learn.microsoft.com/en-us/graph/api/resources/keycredential?view=graph-rest-1.0)
- [Microsoft Graph PowerShell installation](https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation)
Report a reproducible issue through https://hostme.ie/contact/ with the script version, PowerShell/module versions and redacted error. Do not send credentials, tokens, live reports or personal directory data. This is a free community resource, without a support SLA or guaranteed compatibility with every tenant. For scoped business IT help, see https://hostme.ie/services/it-support/.
## Licence
Copyright 2026 hostme.ie. Permission is granted, free of charge, to use, copy, modify and distribute this toolkit, including commercially, provided this copyright and permission notice remain with it. The software is provided as is, without warranty; review it for your environment and retain responsibility for its use.
## Changelog
- 0.2.0: added group user-owner review and app-registration credential expiry reports, two output files each, fictional samples and 46 offline checks. Still a preview with no live-tenant validation. AI-assisted development; source reviewed and offline fixtures executed by the publishing agent, not independently audited.
- 0.1.0: initial preview with three read-only reports, explicit scopes, guarded pagination, safe CSV text handling and offline fixture validation.
Hostme.GraphReports.psm1
# Hostme Microsoft 365 reporting toolkit 0.2.0. Read-only Graph requests.
Set-StrictMode -Version Latest
function Connect-HostmeGraph {
param([guid]$TenantId, [string[]]$Scopes)
Import-Module Microsoft.Graph.Authentication -MinimumVersion 2.0.0 -ErrorAction Stop
if (Get-MgContext) {
throw 'Start a new pwsh -NoProfile session. This toolkit does not reuse or replace an existing Graph session.'
}
Connect-MgGraph -TenantId $TenantId.ToString() -Scopes $Scopes -ContextScope Process -Environment Global -NoWelcome -ErrorAction Stop | Out-Null
$context = Get-MgContext
if (!$context -or $context.TenantId -ne $TenantId.ToString() -or $context.AuthType -ne 'Delegated') {
throw 'The signed-in tenant or authentication type does not match this report. No report was created.'
}
foreach ($scope in $Scopes) {
if ($context.Scopes -notcontains $scope) { throw "Required read permission was not granted: $scope" }
}
}
function Get-HostmeGraphCollection {
param([Parameter(Mandatory)][string]$Uri)
$seen = [Collections.Generic.HashSet[string]]::new()
$items = [Collections.Generic.List[object]]::new()
while ($Uri) {
$url = [uri]$Uri
if ($url.Scheme -ne 'https' -or $url.Host -ne 'graph.microsoft.com' -or $url.Port -ne 443 -or $url.UserInfo -or !$url.AbsolutePath.StartsWith('/v1.0/')) {
throw 'Refused an unexpected Graph pagination URL.'
}
if (!$seen.Add($Uri)) { throw 'Graph returned a repeated pagination URL. No complete report is available.' }
$page = Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers @{ ConsistencyLevel = 'eventual' } -OutputType Hashtable -ErrorAction Stop
if (!$page.ContainsKey('value') -or $null -eq $page['value']) { throw 'Graph did not return a collection.' }
foreach ($item in $page['value']) { $items.Add($item) }
$Uri = if ($page.ContainsKey('@odata.nextLink')) { [string]$page['@odata.nextLink'] } else { '' }
}
return $items.ToArray()
}
function ConvertTo-HostmeCsvText {
param($Value)
if ($null -eq $Value) { return '' }
$text = [string]$Value
# Neutralise spreadsheet formula prefixes, including after leading whitespace.
if ($text -match '^\s*[=+@-]' -or $text -match '^[\t\r\n]') { return "'$text" }
return $text
}
function Export-HostmeCsv {
param([object[]]$Rows, [string[]]$Columns, [string]$Path)
$safe = @($Rows | ForEach-Object {
$row = [ordered]@{}
foreach ($column in $Columns) { $row[$column] = ConvertTo-HostmeCsvText $_.$column }
[pscustomobject]$row
})
if ($safe.Count) { $safe | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8BOM -ErrorAction Stop }
else {
$header = ($Columns | ForEach-Object { '"' + $_.Replace('"','""') + '"' }) -join ','
[IO.File]::WriteAllText($Path, $header + [Environment]::NewLine, [Text.UTF8Encoding]::new($true))
}
}
function Invoke-HostmeReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)][guid]$TenantId,
[Parameter(Mandatory)][ValidateSet('Licences','Guests','Groups','GroupOwners','AppCredentials')][string]$Report,
[Parameter(Mandatory)][string]$OutputDirectory,
[ValidateRange(1,3650)][int]$WarningDays = 30
)
$ErrorActionPreference = 'Stop'
$destination = [IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path -LiteralPath $destination) { throw 'Choose a NEW output directory; existing reports are never overwritten.' }
if ($TenantId -eq [guid]::Empty) { throw 'Supply the actual tenant GUID, not an empty GUID.' }
$scopes = switch ($Report) {
'Licences' { @('User.Read.All','LicenseAssignment.Read.All') }
'Guests' { @('User.Read.All') }
'Groups' { @('GroupMember.Read.All','User.ReadBasic.All') }
'GroupOwners' { @('GroupMember.Read.All','User.ReadBasic.All') }
'AppCredentials' { @('Application.Read.All') }
}
$connected = $false
try {
# Refuse an existing session before entering our own connection lifecycle.
Import-Module Microsoft.Graph.Authentication -MinimumVersion 2.0.0 -ErrorAction Stop
if (Get-MgContext) { throw 'Start a new pwsh -NoProfile session with no existing Graph connection.' }
$connected = $true
Connect-HostmeGraph -TenantId $TenantId -Scopes $scopes
$files = [ordered]@{}
$observedUtc = [datetimeoffset]::UtcNow
switch ($Report) {
'Licences' {
$skus = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber,consumedUnits,prepaidUnits,capabilityStatus')
$users = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,accountEnabled,assignedLicenses&$top=999')
$skuNames = @{}
$capacity = @($skus | ForEach-Object {
$skuNames[[string]$_['skuId']] = $_['skuPartNumber']
[pscustomobject]@{ SkuId=$_['skuId']; SkuPartNumber=$_['skuPartNumber']; CapabilityStatus=$_['capabilityStatus']; EnabledUnits=$_['prepaidUnits']['enabled']; ConsumedUnits=$_['consumedUnits'] }
})
$assignments = @($users | ForEach-Object {
$user = $_
foreach ($licence in $user['assignedLicenses']) {
$sku = [string]$licence['skuId']
[pscustomobject]@{
UserId=$user['id']; DisplayName=$user['displayName']; UserPrincipalName=$user['userPrincipalName']
AccountEnabled=$user['accountEnabled']; SkuId=$sku
SkuPartNumber=$(if ($skuNames.ContainsKey($sku)) {$skuNames[$sku]} else {'UNKNOWN - review SKU ID'})
ReviewReason=$(if ($null -eq $user['accountEnabled']) {'Account status unavailable - review'} elseif (!$user['accountEnabled']) {'Disabled account with licence - review before any change'} else {''})
}
}
})
$files['licence-capacity.csv'] = @{ Rows=$capacity; Columns=@('SkuId','SkuPartNumber','CapabilityStatus','EnabledUnits','ConsumedUnits') }
$files['user-licences.csv'] = @{ Rows=$assignments; Columns=@('UserId','DisplayName','UserPrincipalName','AccountEnabled','SkuId','SkuPartNumber','ReviewReason') }
}
'Guests' {
$users = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/users?$filter=userType%20eq%20%27Guest%27&$select=id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime,externalUserState,externalUserStateChangeDateTime&$top=999')
$rows = @($users | ForEach-Object {
[pscustomobject]@{ UserId=$_['id']; DisplayName=$_['displayName']; UserPrincipalName=$_['userPrincipalName']; Mail=$_['mail']; AccountEnabled=$_['accountEnabled']; CreatedUtc=$_['createdDateTime']; InvitationState=$_['externalUserState']; InvitationStateChangedUtc=$_['externalUserStateChangeDateTime']; ActivityAssessment='Not measured - account age is not inactivity' }
})
$files['guest-review.csv'] = @{ Rows=$rows; Columns=@('UserId','DisplayName','UserPrincipalName','Mail','AccountEnabled','CreatedUtc','InvitationState','InvitationStateChangedUtc','ActivityAssessment') }
}
'Groups' {
$groups = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/groups?$select=id,displayName,groupTypes,visibility&$top=999')
$rows = [Collections.Generic.List[object]]::new()
$summary = [Collections.Generic.List[object]]::new()
foreach ($group in $groups) {
if ($group['visibility'] -eq 'HiddenMembership') { throw "Hidden membership group $($group['id']) needs a separately authorised review; this report does not request Member.Read.Hidden." }
$id = ([guid]$group['id']).ToString()
$members = @(Get-HostmeGraphCollection ('https://graph.microsoft.com/v1.0/groups/' + $id + '/members/microsoft.graph.user?$select=id,displayName,userPrincipalName&$count=true&$top=999'))
foreach ($member in $members) {
if (!$member['id'] -or !$member['userPrincipalName']) { throw "Incomplete user details in group $id. Check permissions before trusting an export." }
$rows.Add([pscustomobject]@{ GroupId=$id; GroupName=$group['displayName']; UserId=$member['id']; DisplayName=$member['displayName']; UserPrincipalName=$member['userPrincipalName']; Membership='Direct user only' })
}
$summary.Add([pscustomobject]@{ GroupId=$id; GroupName=$group['displayName']; DirectUsers=$members.Count; Scope='Direct users only; excludes nested groups, devices and service principals' })
}
$files['direct-user-memberships.csv'] = @{ Rows=$rows.ToArray(); Columns=@('GroupId','GroupName','UserId','DisplayName','UserPrincipalName','Membership') }
$files['group-summary.csv'] = @{ Rows=$summary.ToArray(); Columns=@('GroupId','GroupName','DirectUsers','Scope') }
}
}
if ($Report -eq 'GroupOwners') {
$groups = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/groups?$select=id,displayName,groupTypes,onPremisesSyncEnabled&$top=999')
$owners = [Collections.Generic.List[object]]::new()
$summary = [Collections.Generic.List[object]]::new()
foreach ($group in $groups) {
if (!$group['id'] -or !$group.ContainsKey('groupTypes') -or !$group.ContainsKey('onPremisesSyncEnabled')) { throw 'Incomplete group classification; cannot establish ownership-report scope.' }
$id = ([guid]$group['id']).ToString()
if ($group['onPremisesSyncEnabled'] -eq $true -or $group['groupTypes'] -notcontains 'Unified') {
$summary.Add([pscustomobject]@{ GroupId=$id; GroupName=$group['displayName']; ReturnedUserOwners=''; Review='Not assessed - only cloud Microsoft 365 groups are in scope' })
continue
}
$members = @(Get-HostmeGraphCollection ('https://graph.microsoft.com/v1.0/groups/' + $id + '/owners/microsoft.graph.user?$select=id,displayName,userPrincipalName&$count=true&$top=999'))
foreach ($member in $members) {
if (!$member['id'] -or !$member['userPrincipalName']) { throw "Incomplete owner details in group $id. Check permissions before trusting an export." }
$owners.Add([pscustomobject]@{ GroupId=$id; GroupName=$group['displayName']; UserId=$member['id']; DisplayName=$member['displayName']; UserPrincipalName=$member['userPrincipalName'] })
}
$review = if (!$members.Count) { 'No user owner returned - verify ownership in the admin centre' } elseif ($members.Count -eq 1) { 'One user owner returned - review continuity cover' } else { 'Multiple user owners returned - verify current responsibility' }
$summary.Add([pscustomobject]@{ GroupId=$id; GroupName=$group['displayName']; ReturnedUserOwners=$members.Count; Review=$review })
}
$files['group-user-owners.csv'] = @{ Rows=$owners.ToArray(); Columns=@('GroupId','GroupName','UserId','DisplayName','UserPrincipalName') }
$files['group-owner-review.csv'] = @{ Rows=$summary.ToArray(); Columns=@('GroupId','GroupName','ReturnedUserOwners','Review') }
}
if ($Report -eq 'AppCredentials') {
$apps = @(Get-HostmeGraphCollection 'https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,passwordCredentials,keyCredentials&$top=100')
$rows = [Collections.Generic.List[object]]::new()
$summary = [Collections.Generic.List[object]]::new()
foreach ($app in $apps) {
if (!$app['id'] -or !$app['appId'] -or !$app.ContainsKey('passwordCredentials') -or !$app.ContainsKey('keyCredentials') -or $null -eq $app['passwordCredentials'] -or $null -eq $app['keyCredentials']) { throw 'Incomplete application credential metadata; cannot treat it as an empty credential list.' }
$count = 0
foreach ($field in @('passwordCredentials','keyCredentials')) {
foreach ($credential in $app[$field]) {
if (!$credential['keyId'] -or !$credential['endDateTime']) { throw "Incomplete credential metadata for application $($app['id'])." }
$end = [datetimeoffset]::Parse($credential['endDateTime'],[Globalization.CultureInfo]::InvariantCulture)
$remaining = ($end - $observedUtc).TotalDays
$state = if ($end -le $observedUtc) { 'Expired' } elseif ($end -le $observedUtc.AddDays($WarningDays)) { 'Due within warning window' } else { 'Beyond warning window' }
$start = if ($credential['startDateTime']) { ([datetimeoffset]::Parse($credential['startDateTime'],[Globalization.CultureInfo]::InvariantCulture)).ToUniversalTime().ToString('o') } else { '' }
$rows.Add([pscustomobject]@{ ApplicationObjectId=$app['id']; ApplicationId=$app['appId']; ApplicationName=$app['displayName']; CredentialKind=$(if ($field -eq 'passwordCredentials') {'Client secret'} else {'Key credential'}); KeyType=$(if ($field -eq 'keyCredentials') {$credential['type']} else {''}); CredentialId=$credential['keyId']; CredentialName=$credential['displayName']; StartUtc=$start; ExpiresUtc=$end.ToUniversalTime().ToString('o'); DaysUntilExpiry=[math]::Floor($remaining); ExpiryAssessment=$state; ObservedUtc=$observedUtc.ToString('o') })
$count++
}
}
$summary.Add([pscustomobject]@{ ApplicationObjectId=$app['id']; ApplicationId=$app['appId']; ApplicationName=$app['displayName']; ReturnedCredentials=$count; Scope='App-registration secrets/key credentials only; not enterprise-app SAML certificates, federation or managed identities' })
}
$files['app-credential-expiry.csv'] = @{ Rows=$rows.ToArray(); Columns=@('ApplicationObjectId','ApplicationId','ApplicationName','CredentialKind','KeyType','CredentialId','CredentialName','StartUtc','ExpiresUtc','DaysUntilExpiry','ExpiryAssessment','ObservedUtc') }
$files['application-summary.csv'] = @{ Rows=$summary.ToArray(); Columns=@('ApplicationObjectId','ApplicationId','ApplicationName','ReturnedCredentials','Scope') }
}
# Nothing is exported until every required Graph page has succeeded.
# COMPLETE.json is written last. A folder without it is incomplete.
New-Item -ItemType Directory -Path $destination -ErrorAction Stop | Out-Null
$counts = [ordered]@{}
foreach ($name in $files.Keys) {
$spec = $files[$name]
Export-HostmeCsv -Rows $spec.Rows -Columns $spec.Columns -Path (Join-Path $destination $name)
$counts[$name] = @($spec.Rows).Count
}
$manifest = [ordered]@{ ToolkitVersion='0.2.0'; Report=$Report; TenantId=$TenantId.ToString(); ObservedUtc=$observedUtc.ToString('o'); WarningDays=$(if ($Report -eq 'AppCredentials') {$WarningDays} else {$null}); CompletedUtc=[datetime]::UtcNow.ToString('o'); Rows=$counts; Limitations='Collection over time, subject to replication delays. No sign-in activity, savings or access-removal recommendation. Ownership covers returned user owners of cloud Microsoft 365 groups only. Credential expiry covers app registrations only and is not evidence of usage or validity. CSV text may be prefixed with an apostrophe for spreadsheet safety.' }
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $destination 'COMPLETE.json') -Encoding utf8 -ErrorAction Stop
Write-Output "Completed $Report report: $destination"
}
finally {
if ($connected) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
}
}
Export-ModuleMember -Function Invoke-HostmeReport