hostme.ie YOUR IT DEPARTMENT

Engineering guides and downloads

Practical tools and reference guides for engineers, developers and authorised IT administrators. Browse by task, inspect a sample and read the source.

Choose the admin job you need to do. Each tool includes source code, a sample output and clear permissions. No email signup.

Apps and automation · Guide

Choose the right sign-in for your script

When does PowerShell need an Entra app registration? Follow the setup steps and compare interactive access, certificates and managed identities before scheduling a job.

Glasses reflecting code on a computer screen in a programming workspace

Photo: Kevin Ku / Pexels

Guide · Reviewed 23 September 2026

Read the Entra and PowerShell guide →

Licences

Review licence allocations

Bring tenant capacity and user licence assignments into Excel. Flag disabled accounts with licences for review before making changes.

Sample output · fictional data
Account Enabled
leaver@example.invalid False
enabled@example.invalid True

PowerShell script · CSV output · Preview

Script details and permissions

Export-M365LicenceReport.ps1

Exports: licence-capacity.csv, user-licences.csv

Read scopes: User.Read.All, LicenseAssignment.Read.All

Allocation is not usage. A disabled account may legitimately retain a licence; this report does not calculate savings.

Sign-in and app registration explained

Read the source and instructions

Access and ownership

Prepare a guest-account review

Export external guest identities, invitation states and account status. Give access reviewers a practical starting list.

Sample output · fictional data
Invitation Enabled
Accepted True

PowerShell script · CSV output · Preview

Script details and permissions

Export-M365GuestReview.ps1

Exports: guest-review.csv

Read scopes: User.Read.All

Guest age is not inactivity. This report does not read sign-in logs or decide who should be removed.

Sign-in and app registration explained

Read the source and instructions

Access and ownership

Export direct group members

See direct user membership across your groups, with a separate group summary for reconciliation.

Sample output · fictional data
Group Direct users
Example group 1

PowerShell script · CSV output · Preview

Script details and permissions

Export-M365GroupMembers.ps1

Exports: direct-user-memberships.csv, group-summary.csv

Read scopes: GroupMember.Read.All, User.ReadBasic.All

Direct users only, not nested groups or effective access. Hidden membership and incomplete user details stop the report.

Sign-in and app registration explained

Read the source and instructions

Access and ownership · New

Find gaps in group ownership

Review cloud Microsoft 365 groups with zero, one or multiple returned user owners. Keep unsupported groups visible as unassessed.

Sample output · fictional data
Group User owners
Fictional cloud M365 group 2
Security group Not assessed

PowerShell script · CSV output · Preview

Script details and permissions

Export-M365GroupOwnerReview.ps1

Exports: group-user-owners.csv, group-owner-review.csv

Read scopes: GroupMember.Read.All, User.ReadBasic.All

Zero returned user owners is a reason to verify, not proof a group is ownerless. Service-principal ownership and owner activity are not assessed.

Sign-in and app registration explained

Read the source and instructions

Apps and automation · New

Review app credential expiry

List app-registration secret and key expiry dates. Separate expired credentials, those due soon and later deadlines before planning rotation.

Sample output · fictional data
Credential Expiry review
Old secret Expired
Renew soon Due within warning window

PowerShell script · CSV output · Preview

Script details and permissions

Export-M365AppCredentialExpiry.ps1

Exports: app-credential-expiry.csv, application-summary.csv

Read scopes: Application.Read.All

Metadata only; no secret values exported. Covers app registrations, not enterprise-app SAML certificates. Expiry does not establish usage or validity.

Sign-in and app registration explained

Read the source and instructions

Read before you run

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.
Export-M365AppCredentialExpiry.ps1
#requires -Version 7.0
[CmdletBinding()]
param(
    [Parameter(Mandatory)][guid]$TenantId,
    [Parameter(Mandatory)][string]$OutputDirectory,
    [ValidateRange(1,3650)][int]$WarningDays = 30
)
Import-Module (Join-Path $PSScriptRoot 'Hostme.GraphReports.psm1') -Force -ErrorAction Stop
Invoke-HostmeReport -TenantId $TenantId -Report AppCredentials -OutputDirectory $OutputDirectory -WarningDays $WarningDays
Export-M365GroupMembers.ps1
#requires -Version 7.0
[CmdletBinding()]
param([Parameter(Mandatory)][guid]$TenantId, [Parameter(Mandatory)][string]$OutputDirectory)
Import-Module (Join-Path $PSScriptRoot 'Hostme.GraphReports.psm1') -Force -ErrorAction Stop
Invoke-HostmeReport -TenantId $TenantId -Report Groups -OutputDirectory $OutputDirectory
Export-M365GroupOwnerReview.ps1
#requires -Version 7.0
[CmdletBinding()]
param(
    [Parameter(Mandatory)][guid]$TenantId,
    [Parameter(Mandatory)][string]$OutputDirectory
)
Import-Module (Join-Path $PSScriptRoot 'Hostme.GraphReports.psm1') -Force -ErrorAction Stop
Invoke-HostmeReport -TenantId $TenantId -Report GroupOwners -OutputDirectory $OutputDirectory
Export-M365GuestReview.ps1
#requires -Version 7.0
[CmdletBinding()]
param([Parameter(Mandatory)][guid]$TenantId, [Parameter(Mandatory)][string]$OutputDirectory)
Import-Module (Join-Path $PSScriptRoot 'Hostme.GraphReports.psm1') -Force -ErrorAction Stop
Invoke-HostmeReport -TenantId $TenantId -Report Guests -OutputDirectory $OutputDirectory
Export-M365LicenceReport.ps1
#requires -Version 7.0
[CmdletBinding()]
param([Parameter(Mandatory)][guid]$TenantId, [Parameter(Mandatory)][string]$OutputDirectory)
Import-Module (Join-Path $PSScriptRoot 'Hostme.GraphReports.psm1') -Force -ErrorAction Stop
Invoke-HostmeReport -TenantId $TenantId -Report Licences -OutputDirectory $OutputDirectory
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
Verify this download

SHA-256 for v0.2.0:

06be800f588104abd0ac26bcb94f96c3fb20017b977601226027352744f8632d

Compare with Get-FileHash. The ZIP includes per-file hashes. Checksums identify bytes; they are not a security signature.

Need help acting on the report?

For scoped Microsoft 365 support, access reviews or migrations, talk to hostme.ie on WhatsApp.

Found a script issue? Send the version and a redacted error. Do not send credentials or live directory exports. Free community resource; no support SLA. Independently produced by hostme.ie, with AI-assisted development. Reviewed 23 September 2026.