Docs Guides
Tenant audits
A tenant audit is a set of read-only health checks against one Microsoft 365 tenant: licensed accounts nobody uses, admins without strong MFA, mail forwarded outside the company, devices that stopped checking in, sign-in protection gaps. Each check reports structured findings with the evidence behind them, a sentence of business context, and a recommendation. Results are kept as history and export to CSV, JSON or Markdown.
Run an audit
- Open Tenants, pick the tenant, and choose the Audits tab (or Run a health check on its Overview).
- Keep Full tenant health check ticked, or untick it and choose categories: Identity hygiene, Licensing, Exchange / Mail, Endpoint / Intune, Tenant security.
- Set the thresholds the selected checks use. Inactive for defaults to 90 days (30, 60, 90, 180 or a custom 7-730); Device not synced for defaults to 30 days (14, 30, 60, 90 or a custom 7-365). Only thresholds the selection uses are shown.
- Choose Run audit. A full audit usually takes under a minute; Exchange checks start one PowerShell session, which adds a little time.
The result opens when the run finishes, and every run is saved. Use the Audit run picker to reopen an earlier one. A Viewer on the tenant can run, read and export audits; they read data and change nothing, which is the same reason a Viewer can plan Access Parity.
Reading the results
Every finding has one severity:
| Severity | Meaning |
|---|---|
| Fail | A clear risk to act on, for example a guest holding a privileged role or a mailbox forwarding outside the company. |
| Warn | Something that usually needs action or a documented reason, for example dormant licensed users. |
| Unknown | The data PCB could read does not prove either way, for example recent sign-in attempts with no recorded success. |
| Info | Inventory or context worth reviewing, such as the list of privileged role holders. |
| Pass | The check ran and found nothing. Every completed check reports at least a Pass, so a clean result is visible and exportable. |
Separately, every check has a run status:
| Status | Meaning |
|---|---|
| Completed | The check read what it needed. Its findings are above. |
| Unavailable | PCB could not run the check in this tenant: a missing Graph permission or admin role, a license the tenant does not have (for example Entra ID P1 for sign-in data), a product it does not use (Intune), Exchange Online not set up on this PCB, or no configuration snapshot to compare with. The reason and what is missing are shown. Unavailable says nothing about the tenant's health, so it is never counted as a pass. |
| Failed to run | The check was attempted and hit an unexpected error, such as throttling or a Microsoft outage. Run the audit again later. |
One check being unavailable or failing never stops the others. The summary rates the run as High-risk findings (any Fail), Attention needed (any Warn), Healthy, or Insufficient access (no check could run). There is deliberately no numeric score: severity counts are easier to defend than a made-up number.
The severity chips filter the view. Passing checks are hidden until you select Pass, and Not run shows the unavailable and failed checks. Each check lists the scope it evaluated and its caveats under Scope and notes. Tables show up to 100 rows per finding; the CSV and JSON exports always contain every row.
Check catalog
The first release ships 25 checks. Each one is listed with how it grades its findings.
Identity hygiene
| Check | What it looks at | Grading |
|---|---|---|
| Stale enabled accounts | Enabled member accounts, licensed or not, with no successful sign-in within the inactivity threshold. | Warn: inactive or never signed in. Unknown: attempts without recorded success. |
| Inactive admin accounts | Users with active directory role assignments who have not signed in within the threshold. | Fail: privileged role (Global Administrator, Security Administrator...). Warn: other roles. |
| Privileged role review | Every active holder of a privileged role, including users who hold it through a role-assignable group, and apps. | Fail: guest with a privileged role. Warn: more than four Global Administrators; disabled account still holding a role. Info: inventory; fewer than two Global Administrators; apps with privileged roles. |
| MFA registration coverage | Enabled member users with no MFA-capable method registered. | Fail: admins. Warn: other users. |
| Admin authentication strength | The strongest method each admin has registered. | Fail: no second factor. Warn: phone or email codes only. Info: app-based but not phishing-resistant. Pass: phishing-resistant (FIDO2, passkey, Windows Hello for Business). |
| Guest account review | Every guest account, plus invitations pending for more than 30 days. | Warn: stale invitations. Info: guest inventory. |
| Stale guest accounts | Enabled guests with no successful sign-in within the threshold. | Warn. |
Licensing
| Check | What it looks at | Grading |
|---|---|---|
| Dormant licensed users | Users holding licenses that may carry cost who have not signed in successfully within the threshold. | See below. |
| Disabled accounts with licenses | Sign-in-blocked accounts still holding licenses that may carry cost. | Warn. |
| Licensed shared mailboxes | Shared mailboxes (from Exchange Online) whose account holds a license (from Graph). | Warn: no archive and no litigation hold. Info: has an archive or hold, which needs a license. |
| Subscription seat utilization | Purchased versus assigned seats per subscription. | Fail: more assigned than active. Warn: seats in a warning or suspended state. Info: unassigned paid seats. |
Exchange / Mail
These checks need Exchange Online set up on this PCB instance. Without it they are reported as unavailable.
| Check | What it looks at | Grading |
|---|---|---|
| External mailbox forwarding | Mailbox forwarding settings (ForwardingSmtpAddress, ForwardingAddress) and the default outbound spam policy's automatic forwarding mode. | Fail: forwards outside the accepted domains. Warn: automatic external forwarding set to On. Info: internal forwards. |
| Mailbox delegation review | Full Access, Send As and Send on Behalf grants. | Info: delegations on user mailboxes and on shared/resource mailboxes, listed separately. |
| Shared mailbox sign-in | Shared mailboxes whose account can still sign in. | Warn. |
| Mailbox archive state | User mailboxes with no online archive, and mailboxes on litigation hold. | Info, with a link to the mailbox archive known fix. |
Endpoint / Intune
Tenants that do not use Intune report these checks as unavailable.
| Check | What it looks at | Grading |
|---|---|---|
| Stale managed devices | Devices whose last Intune check-in is older than the device threshold. | Warn. Unknown: no check-in time recorded. |
| Non-compliant devices | Intune compliance state. | Warn: non-compliant. Info: in grace period. Unknown: error or conflict. |
| Compliance policy coverage | Assigned compliance policies per platform in use, and the tenant setting for devices with no policy. | Fail: managed devices but no policies. Warn: a platform with no assigned policy; devices with no policy reported compliant. Info: unassigned policies. |
| Device encryption coverage | Devices reporting storage that is not encrypted. | Warn. Unknown: not reported. |
| Duplicate device records | Serial numbers on more than one device record, usually left over from re-enrollment. | Info (older records listed). |
Tenant security
| Check | What it looks at | Grading |
|---|---|---|
| Sign-in protection baseline | Security defaults, or enabled Conditional Access requiring MFA for all users on all apps and blocking legacy authentication. | Pass: security defaults on, or both protections in place. Fail: no enabled policy and security defaults off, or nothing requires MFA. Warn: MFA for only some users or apps; legacy authentication not blocked. Info: report-only policies. |
| Conditional Access exclusions | Every user, group, role and guest exclusion on enabled policies. | Warn: exclusions from all-user MFA or block policies. Info: other exclusions. |
| User consent and directory defaults | The authorization policy: user app consent, guest invitations, app registration, tenant creation. | Warn: users can consent to any app; anyone can invite guests. Info: low-risk consent; permissive defaults. |
| App consent review | Non-Microsoft apps holding high-impact delegated permissions (mail, files, directory write and similar). | Warn: consented by an individual user. Info: admin-consented for everyone. |
| Configuration drift since last snapshot | Re-captures the config snapshot sections live and diffs them against the tenant's latest snapshot. | Warn: a Conditional Access policy was removed. Info: other changes. Unavailable until a snapshot exists. |
Dormant licensed users in detail
This check finds license spend on accounts nobody uses. Each row shows the user, UPN, enabled state, licenses, last successful sign-in, last sign-in attempt, days inactive, the field the result is based on, the creation date, any admin roles and, when Exchange is set up, the mailbox type. Each dormant user has a Plan offboarding link.
What the sign-in data proves
Microsoft Entra records three different timestamps on each user, and PCB keeps them apart:
| Field | Records |
|---|---|
lastSuccessfulSignInDateTime | The last successful sign-in, interactive or not. Microsoft has recorded this only since December 2023. |
lastSignInDateTime | The last interactive sign-in attempt, which may have failed. |
lastNonInteractiveSignInDateTime | The last non-interactive attempt (an app or device refreshing a token), which may have failed. |
Each licensed user lands in exactly one group, decided in this order:
| Situation | Result | Why |
|---|---|---|
| A successful sign-in is recorded and is at least N days old | Warn, dormant | Days inactive is measured from the last success. |
| A successful sign-in within N days | Not listed | In use. |
| No success recorded; the latest attempt of any kind is at least N days old | Warn, dormant | Nothing at all has happened for N days, so measuring from the last attempt is conservative. |
| No success recorded, but an attempt within N days | Unknown | Attempts may have failed. That can mean a forgotten account or someone trying to get into it; PCB does not guess. |
| Nothing recorded, account older than N days | Warn, no recorded sign-in | It never signed in, or not since Microsoft started recording. The wording says exactly that. |
| Nothing recorded, account created within N days | Info, new account | Too new to call dormant, but a paid license is waiting on it. |
| Shared, room or equipment mailbox (Exchange set up) | Info, listed separately | These never sign in by design, so sign-in age says nothing about them. |
- Which licenses count. A user is "licensed" when they hold at least one
license that may carry cost. A short, conservative list of free and viral SKUs (for example
FLOW_FREE,POWER_BI_STANDARD,TEAMS_EXPLORATORY) is ignored. Anything else is treated as possibly paid, so the check can under-report savings but never inflate them. Graph exposes no prices, so the check reports licenses, not costs. - Group-based licensing shows as assigned. Removing it means changing the user's group membership.
- Sign-in data unavailable. Without Entra ID P1/P2, or without permission to read sign-in data, the check is Unavailable with the exact reason. It never falls back to treating everyone as dormant or everyone as active.
- Optional enrichment. Admin roles and mailbox types are added when readable. If they are not, the check still completes and says what it could not add.
Permissions and dependencies
Every check declares what it needs, and the catalog shows the list. PCB does not pre-check permissions, because Microsoft does not reliably expose them ahead of time. It reads the data and turns Microsoft's "access denied", "license required" and "not applicable to this tenant" answers into an Unavailable result that names what is missing. This is the same behavior as the person workspace and the rest of the app.
| Graph permission (delegated) | Used by |
|---|---|
User.Read.All (or User.ReadWrite.All / Directory.Read.All) | Every identity and licensing check. |
AuditLog.Read.All | Sign-in activity (dormant, stale and inactive-admin checks) and the authentication methods registration report (MFA checks). |
Organization.Read.All | Subscriptions and license names. |
RoleManagement.Read.Directory (or Directory.Read.All) | Directory role holders. |
Policy.Read.All | Conditional Access, security defaults, the authorization policy, drift. |
Directory.Read.All | Delegated permission grants (app consent) and names of excluded users and groups. |
DeviceManagementManagedDevices.Read.All | All Intune device checks. |
DeviceManagementConfiguration.Read.All | Compliance policies and the "no policy" tenant setting; drift. |
- Direct tenant sign-in: the Windows app's built-in registration already requests all of these. Graph also requires the signed-in admin's own roles. A Global Reader can read everything the Graph checks need; sign-in data additionally accepts Reports Reader or Security Reader.
- Partner Center / GDAP: the effective access is the intersection of your app registration's consented permissions and the roles in the customer's GDAP relationship. Add the optional audit permissions listed under app registration permissions, and include Global Reader (or narrower reader roles) in the relationship.
- Licenses: sign-in activity, the MFA registration report and Conditional Access need Microsoft Entra ID P1 or P2 in the customer tenant. Security defaults are read without a license.
- Exchange Online: PCB's app-only Exchange connection (certificate, PowerShell 7
and the ExchangeOnlineManagement module). The Exchange app needs a role that can read mailbox and
permission data, for example View-Only Organization Management. The audit uses
Get-cmdlets only.
Exports
Every run exports from the result header, or from
GET /api/tenants/{tenantId}/audits/{runId}/export?format=csv|json|markdown. File
names carry the tenant, the run time and the run id, for example
tenant-audit-contoso-ltd-20261007-1405-1a2b3c4d.csv.
CSV
One row per affected item (user, device, mailbox, policy...). A finding with no items is one row,
and a check that did not run is one row with Status set to Unavailable or Error and
the reason in Summary, so nothing is silently missing. The fixed columns are:
AuditRunId, AuditName, TenantId, TenantName, Timestamp, Category, CheckId, CheckName,
Status, Severity, FindingId, Finding, Summary, SubjectType, SubjectId, SubjectName, UPN,
Evidence, DaysInactive, LastSuccessfulSignIn, LicenseSku, BusinessImpact, Recommendation, Details
TenantIdis the Entra tenant id andTimestampis the run start in UTC ISO 8601, so files from many tenants combine cleanly in Excel or Power BI.Statusis the check's run status;Severityis the finding's.- Check-specific values without their own column go in
DetailsasLabel=valuepairs separated by;. - RFC 4180 quoting and CRLF line endings. Saved as UTF-8 with a byte-order mark, so Excel reads
accented names correctly (Python reads it with
encoding="utf-8-sig", and PowerShell'sImport-Csvhandles it as is). - Every value is a single line: line breaks and tabs inside values become spaces.
- Spreadsheet formula injection is neutralized. A text value starting with
=,+,-or@is prefixed with an apostrophe, so a hostile display name cannot run as a formula. Plain numbers, including negative ones, are left alone. - Ordering is deterministic (checks in catalog order, findings worst first, items by name), so two exports of different runs diff cleanly.
JSON
The complete stored report: run metadata, parameters, every check's status, reason, missing
requirements, findings, items with all their properties, and notes. Property names are camelCase
and enums are names. schemaVersion (currently 1) and each check's
checkVersion tell you which shape and grading produced the file.
Markdown
Ticket and customer notes: a summary block, then each check worst first with its findings, business impact, recommendation and up to 50 items per finding. Scope notes are folded into a collapsible block, and the file states that it is a read-only health check, not a compliance certification.
History and what is stored
Each run is stored as one insert-only record, in the Local Workbench's SQLite file or the server's PostgreSQL database. It holds the tenant, the operator, start and end times, the PCB version, the report schema version, the resolved parameters, the requested checks and the summary counts as columns, plus the full report as JSON. That answers which tenant, when, which checks, with which thresholds, which checks were unavailable and why, what was found, and which version produced it. Tokens and secrets are never stored. Deleting a tenant from PCB deletes its audit runs; there is no other automatic retention.
From finding to fix
Audits never change a tenant. Where PCB already has a reviewed way to act on a finding, the row links to it and the existing flow takes over: plan, human review, apply, verify, evidence.
- Dormant licensed users and disabled accounts with licenses link to Plan offboarding for that user, which produces a reviewable plan (block sign-in, remove licenses, and so on).
- Mailboxes without an archive link to the mailbox archive known fix, pre-filled with the mailbox.
- User names link to the person workspace.
Across tenants
The engine runs per tenant, and each run records an optional batch id, so a run across many
tenants is the same engine called once per tenant. The estate summary is available now:
GET /api/tenant-audits/estate returns the latest audit of every tenant you can see,
for example "42 tenants scanned: 31 healthy, 7 attention needed, 3 high risk, 1 insufficient
access", plus which tenants have never been audited. Starting one audit across every tenant from
the UI is planned.
API
| Endpoint | Purpose |
|---|---|
GET /api/tenant-audits/catalog | Every check with its metadata, categories, presets and parameters. |
POST /api/tenants/{tenantId}/audits | Run now. Body: {"categories": ["Identity"], "checkIds": [], "parameters": {"inactiveDays": 180}}; empty selection means everything. Returns the full report. |
GET /api/tenants/{tenantId}/audits | The latest 50 runs (summaries). |
GET /api/tenants/{tenantId}/audits/{runId} | One full report. |
GET /api/tenants/{tenantId}/audits/{runId}/export?format= | csv (default), json or markdown. |
GET /api/tenant-audits/estate | Latest run per visible tenant and the rollup. |
Tenant endpoints require Viewer on the tenant. An unknown check, category or parameter, or a threshold out of range, is rejected with a 400 that says why; PCB never silently clamps a threshold.
Adding a check
Checks are plug-ins: adding one is adding a class. There is no controller, UI or export change,
and the checks are discovered by type. A check implements ITenantAuditCheck in
src/PartnerCenterBridge.Core/TenantAudits/Checks/<Category>/:
public sealed class StaleGuestAccountsCheck(IAuditDirectoryData directory) : ITenantAuditCheck
{
public AuditCheckDescriptor Descriptor { get; } = new()
{
Id = "stale-guest-accounts", // stable forever: exports key on it
Name = "Stale guest accounts",
Category = AuditCategories.Identity,
Description = "Enabled guest accounts with no successful sign-in within the threshold.",
BusinessImpact = "Unused guest accounts keep access to shared data with no business reason.",
Recommendation = "Remove stale guests, or set up a guest access review.",
SeverityRules = ["Warn: ...", "Pass: ..."],
Requirements = [AuditRequirement.Graph("User.Read.All"), AuditRequirement.Graph("AuditLog.Read.All")],
Parameters = [AuditParameterKeys.InactiveDays]
};
public async Task<AuditCheckOutput> RunAsync(AuditCheckContext ctx)
{
var threshold = ctx.Parameters.Get(AuditParameterKeys.InactiveDays);
var users = await directory.GetUsersAsync(ctx); // read once per run, shared by all checks
users.RequireSignIn(); // Unavailable, with the reason, if not readable
// ... build AuditSubject rows with AuditUserRows / SignInEvaluator ...
return new AuditCheckOutput().Add(AuditFindingBuilder.Create(Descriptor, "stale", AuditSeverity.Warn, ...));
}
}
- Read through the data providers (
IAuditDirectoryData,IAuditDeviceData,IAuditSecurityData,IAuditMailboxData). They fetch each dataset once per tenant per run and translate "cannot read here" intoAuditUnavailableException. If you need new data, add a method to a provider. Its Graph implementation gets only a GET-only reader, so a check cannot write to a tenant. - Say only what the data proves. Put uncertain cases in an Unknown finding
instead of guessing. For sign-in questions, use
SignInEvaluator. - Keep text short and ASCII: one or two sentences of business impact. Non-ASCII in compiled strings has shipped as mojibake before, and a test enforces this.
- Emit a Pass when nothing is wrong. The engine adds one if you forget.
- Test it with the in-memory fakes in
tests/.../TenantAudits/TenantAuditTestSupport.cs. No HTTP is needed. Catalog tests check every check's id format, metadata and ASCII text automatically. - A check that needs PCB's own database (like configuration drift) lives in
src/PartnerCenterBridge.Api/TenantAudits/. It is discovered the same way.
Limitations
- Audits run while you wait, one tenant at a time. Scheduled and estate-wide runs are planned.
- Only active directory role assignments are read; PIM-eligible assignments that are not activated are not included.
- Inbox rules that forward mail are not read, only mailbox forwarding settings. Full Access delegation is read for up to 200 mailboxes or 90 seconds per run; the notes say when it was not read for every mailbox.
- Mailbox sizes are not read, so a shared mailbox over 50 GB (which does need a license) can appear as a likely unneeded license.
- Conditional Access grading considers users, apps, grant controls and client app types, not every condition (locations, platforms, risk).
- Application (app-only) permissions are not reviewed by the app consent check, only delegated consents.
- Registration is not enforcement: a user with MFA registered is only prompted when policy requires it.
- The check catalog is an assessment aid. It does not map to, or certify against, any compliance framework.