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.

Read-only, and not a compliance audit. An audit reads tenant configuration through Microsoft Graph and Exchange Online and changes nothing. It is a technician's health check and assessment, not a certification against any compliance framework. Results reflect what Microsoft's APIs reported at the time of the run.

Run an audit

  1. Open Tenants, pick the tenant, and choose the Audits tab (or Run a health check on its Overview).
  2. Keep Full tenant health check ticked, or untick it and choose categories: Identity hygiene, Licensing, Exchange / Mail, Endpoint / Intune, Tenant security.
  3. 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.
  4. 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:

SeverityMeaning
FailA clear risk to act on, for example a guest holding a privileged role or a mailbox forwarding outside the company.
WarnSomething that usually needs action or a documented reason, for example dormant licensed users.
UnknownThe data PCB could read does not prove either way, for example recent sign-in attempts with no recorded success.
InfoInventory or context worth reviewing, such as the list of privileged role holders.
PassThe 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:

StatusMeaning
CompletedThe check read what it needed. Its findings are above.
UnavailablePCB 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 runThe 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

CheckWhat it looks atGrading
Stale enabled accountsEnabled 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 accountsUsers 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 reviewEvery 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 coverageEnabled member users with no MFA-capable method registered.Fail: admins. Warn: other users.
Admin authentication strengthThe 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 reviewEvery guest account, plus invitations pending for more than 30 days.Warn: stale invitations. Info: guest inventory.
Stale guest accountsEnabled guests with no successful sign-in within the threshold.Warn.

Licensing

CheckWhat it looks atGrading
Dormant licensed usersUsers holding licenses that may carry cost who have not signed in successfully within the threshold.See below.
Disabled accounts with licensesSign-in-blocked accounts still holding licenses that may carry cost.Warn.
Licensed shared mailboxesShared 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 utilizationPurchased 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.

CheckWhat it looks atGrading
External mailbox forwardingMailbox 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 reviewFull Access, Send As and Send on Behalf grants.Info: delegations on user mailboxes and on shared/resource mailboxes, listed separately.
Shared mailbox sign-inShared mailboxes whose account can still sign in.Warn.
Mailbox archive stateUser 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.

CheckWhat it looks atGrading
Stale managed devicesDevices whose last Intune check-in is older than the device threshold.Warn. Unknown: no check-in time recorded.
Non-compliant devicesIntune compliance state.Warn: non-compliant. Info: in grace period. Unknown: error or conflict.
Compliance policy coverageAssigned 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 coverageDevices reporting storage that is not encrypted.Warn. Unknown: not reported.
Duplicate device recordsSerial numbers on more than one device record, usually left over from re-enrollment.Info (older records listed).

Tenant security

CheckWhat it looks atGrading
Sign-in protection baselineSecurity 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 exclusionsEvery 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 defaultsThe 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 reviewNon-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 snapshotRe-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:

FieldRecords
lastSuccessfulSignInDateTimeThe last successful sign-in, interactive or not. Microsoft has recorded this only since December 2023.
lastSignInDateTimeThe last interactive sign-in attempt, which may have failed.
lastNonInteractiveSignInDateTimeThe 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:

SituationResultWhy
A successful sign-in is recorded and is at least N days oldWarn, dormantDays inactive is measured from the last success.
A successful sign-in within N daysNot listedIn use.
No success recorded; the latest attempt of any kind is at least N days oldWarn, dormantNothing at all has happened for N days, so measuring from the last attempt is conservative.
No success recorded, but an attempt within N daysUnknownAttempts 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 daysWarn, no recorded sign-inIt never signed in, or not since Microsoft started recording. The wording says exactly that.
Nothing recorded, account created within N daysInfo, new accountToo new to call dormant, but a paid license is waiting on it.
Shared, room or equipment mailbox (Exchange set up)Info, listed separatelyThese never sign in by design, so sign-in age says nothing about them.
What this does not prove. Sign-in activity shows sign-in to Microsoft Entra, not use of Microsoft 365. Mail can still arrive in a dormant mailbox, and a service account that authenticates only with app credentials never signs in as a user. Review the list before you remove licenses; the recommendation says "review" for a reason.
  • 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.AllSign-in activity (dormant, stale and inactive-admin checks) and the authentication methods registration report (MFA checks).
Organization.Read.AllSubscriptions and license names.
RoleManagement.Read.Directory (or Directory.Read.All)Directory role holders.
Policy.Read.AllConditional Access, security defaults, the authorization policy, drift.
Directory.Read.AllDelegated permission grants (app consent) and names of excluded users and groups.
DeviceManagementManagedDevices.Read.AllAll Intune device checks.
DeviceManagementConfiguration.Read.AllCompliance 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
  • TenantId is the Entra tenant id and Timestamp is the run start in UTC ISO 8601, so files from many tenants combine cleanly in Excel or Power BI.
  • Status is the check's run status; Severity is the finding's.
  • Check-specific values without their own column go in Details as Label=value pairs 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's Import-Csv handles 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

EndpointPurpose
GET /api/tenant-audits/catalogEvery check with its metadata, categories, presets and parameters.
POST /api/tenants/{tenantId}/auditsRun now. Body: {"categories": ["Identity"], "checkIds": [], "parameters": {"inactiveDays": 180}}; empty selection means everything. Returns the full report.
GET /api/tenants/{tenantId}/auditsThe 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/estateLatest 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" into AuditUnavailableException. 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.