Content Governance Risk — A Read-Only Audit Solution
Peabody Energy's Microsoft 365 estate spans 5 business units, 12+ mine operations across two continents, and a complex web of SharePoint sites, Teams channels, and OneDrive accounts. This Power BI solution gives Executive and IT leadership a real-time, read-only view of governance gaps, oversharing risk, and compliance exposure — without modifying a single document or permission.
The Five Risks We Are Closing
Anonymous & External Oversharing
Anonymous links on commercial contracts; never-expiring shares to unknown domains; zombie grants to inactive guests.
MNPI Exposure
Material Non-Public Information — earnings, M&A, forecast — stored without sensitivity labels or shared externally.
OneDrive Shadow Content
Sensitive files in personal OneDrive bypassing governed libraries; org-wide sharing of commercial documents.
Compliance & Retention Gaps
Safety records and permits without retention labels, violating MSHA / OSMRE / QLD DES record-keeping obligations.
Content Sprawl & Ungoverned Sites
Orphaned sites with no owner, libraries missing enterprise content types, base classification columns absent.
Custom Permission Drift
Item-level broken inheritance with non-standard ACLs and shadow-admin behaviour across high-value libraries.
Solution at a Glance
What It Does
Read-only audit and reporting layer. Five fact tables, six interactive report pages, one curated executive dashboard. No content or permissions are modified.
How It Works
SharePoint REST + Microsoft Graph APIs, called via Power Query (M) using a certificate-based Azure AD app registration. Loaded into a star-schema Power BI semantic model with DAX risk scoring.
Who Sees What
Four RLS roles with object-level masking: Executive (tenant-wide aggregates), IT Admin (full detail), Legal & Compliance, and BU Owner (filtered to their BU).
Implementation Footprint
Licensing & Capability Matrix
| Capability | Minimum License | Recommended | Status |
|---|---|---|---|
| Power BI report authoring | Power BI Pro (per developer) | Pro | In place |
| Executive consumption | Power BI Pro / PPU | PPU or Fabric | Confirm |
| Sensitivity labels on dataset | Microsoft Purview (E5) | E5 tenant-wide | Confirm |
| Row-Level Security enforcement | Power BI Pro / Premium | Premium | In place |
| Object-Level Security (column masking) | Premium / Fabric Capacity | Fabric Capacity | Confirm |
| Incremental refresh (4-hour permission deltas) | Premium / Fabric | Fabric Capacity | Confirm |
| SharePoint REST + Graph API access | Microsoft 365 E3/E5 (tenant) | E5 | In place |
Implementation Phase Timeline
Solution Overview & Architecture
Read-only audit and reporting layer over the Peabody M365 tenant. Five fact tables, six report pages, four RLS roles, and zero modifications to source content.
1.2 Solution Components
| Layer | Technology | Role |
|---|---|---|
| Data Collection | SharePoint REST API | Site, list, library, user, item permission inventory |
| Data Collection | Microsoft Graph API | Sharing links, OneDrive items, Teams drives, guest identities |
| Transformation | Power Query (M) | API calls, pagination, token refresh, data shaping |
| Semantic Model | Power BI Dataset | Star schema, DAX measures, RLS, calculated columns |
| Report | Power BI Reports | 6 interactive report pages (IT detail + exec summary) |
| Distribution | Power BI App | Curated executive view, sensitivity-labeled dataset |
| Alert | Power BI Alerts | Critical threshold triggers (anonymous links, zero owners) |
| Refresh | Power BI Gateway / Fabric | Scheduled nightly full + 4-hour delta for permissions |
1.3 Data Flow Architecture
1.4 Scope
In Scope
- All SharePoint Online sites in the Peabody tenant
- Teams-connected SharePoint document libraries (all channels)
- OneDrive for Business — priority roles first (Commercial, Finance, Legal), all users in baseline pass
- External sharing grants and anonymous sharing links
- Item-level broken permission inheritance
- Site-level governance health (owners, labels, hub association)
- Library-level classification health (CTs, columns, retention)
Out of Scope
- SharePoint on-premises (legacy farms — separate workstream)
- Power Automate remediation flows (Action Item #20)
- SAP / Maximo integration (Action Item #8)
- Email or Teams message content scanning
- Writing or modifying any SharePoint content or permissions
1.5 Risk Tier Definitions
Direct legal/regulatory/financial exposure. Anonymous link on commercial contract; MNPI without label.
Significant compliance / security risk. Old external share, unlabeled site, safety library no retention.
Governance gap weakening classification & discoverability. Library missing enterprise CT, no second owner.
Track and improve in next governance cycle. Subsite sprawl, stale libraries, naming inconsistencies.
Prerequisites & Environment Setup
License confirmation, workstation requirements, workspace creation, SharePoint tenant settings, and the approved external domain reference list.
2.1 Required Licenses & Subscriptions
| Requirement | Minimum License / Plan |
|---|---|
| Power BI authoring | Power BI Pro (per developer) |
| Power BI publishing | Power BI Pro or Premium Per User (PPU) |
| Executive dashboard access | Power BI Pro or PPU (per consumer) |
| SharePoint API access | Microsoft 365 E3/E5 (tenant-level) |
| Microsoft Graph API access | Microsoft 365 E3/E5 (tenant-level) |
| Sensitivity labels on dataset | Microsoft Purview (M365 E5 or add-on) |
| Row-Level Security enforcement | Power BI Pro or Premium |
| Scheduled refresh | Power BI Pro + On-Prem Gateway or Fabric Capacity (recommended) |
| Incremental refresh | Power BI Premium or Fabric Capacity |
2.2 Developer Workstation Requirements
| Software | Version | Purpose |
|---|---|---|
| Power BI Desktop | March 2026+ | Report and model authoring |
| Power BI Gateway | Latest | On-prem / SPO refresh |
| Visual Studio Code | Latest | M query / DAX authoring |
| Azure CLI | Latest | App registration, cert mgmt |
| PowerShell 7+ | 7.4+ | Cert generation, API tests |
| Postman (optional) | Latest | REST/Graph endpoint testing |
| Git client | Latest | Version control for .pbix |
2.3 Power BI Workspace Setup
Create the workspace
Name: Peabody-GovernanceAudit · License: PPU or Fabric Capacity · Access: IT Admin (Admin), Developer team (Member)
Apply sensitivity label
Label: Confidential. Dataset contains user permission data and external sharing records — not for broad distribution.
Enable workspace settings
Large dataset storage format: ON · XMLA read/write endpoint: ON (advanced model ops) · Incremental refresh: ON (Premium / Fabric only)
2.4 SharePoint Tenant Settings (Admin Required)
| Setting | Required State |
|---|---|
SharePoint app-only access (/_layouts/15/appinv.aspx) | Enabled |
| API access (Graph app permissions) | Approved in SharePoint Admin |
| External sharing at tenant level | Confirm current setting (do not change — audit reads only) |
| Site collection app permissions | Allow app-only policy (tenant) |
To enable tenant-level app-only in SharePoint Online, a SharePoint admin must run:
Set-SPOTenant -DisableCustomAppAuthentication $false
2.5 Approved External Domain Reference List
Used by the semantic model to classify external sharing grants as approved-partner vs. unknown-external. Stored as static CSV table DimApprovedDomains joined to FactExternalShares.
| Domain | Partner Type | Notes |
|---|---|---|
aurizon.com.au | Rail carrier (AUS) | QLD/NSW operations |
pacificnational.com.au | Rail carrier (AUS) | NSW operations |
dbct.com.au | Port terminal (QLD) | DBCT |
bnsf.com | Rail carrier (U.S.) | PRB operations |
up.com | Rail carrier (U.S.) | PRB operations |
haypoint.com.au | Port terminal (QLD) | Hay Point terminal |
portwaratah.com.au | Port terminal (NSW) | Newcastle export |
| [Customer domains] | Steel/power customers | Add from contract register before deploy |
Azure AD App Registration & Authentication
The single most critical setup step. All API calls authenticate using one app registration with certificate credentials. Do NOT use client secrets in production.
Authentication Flow
3.1 App Registration — Step by Step
URL: https://portal.azure.com
Path: Azure Active Directory → App Registrations → New Registration
- Name:
Peabody-GovernanceAudit-API - Supported types: Accounts in this organizational directory only (single tenant)
- Redirect URI: Leave blank (app-only flow, no user redirect)
- Application (client) ID — used in all API calls
- Directory (tenant) ID — used in token requests
- Object ID — for reference
Microsoft Graph — Application Permissions
Sites.Read.All— read all SharePoint site collectionsFiles.Read.All— read all files and OneDrive contentUser.Read.All— read all user profiles (guest detection)
SharePoint — Application Permissions
Sites.Read.All— SharePoint REST API app-only read access
On the developer workstation, run in PowerShell 7+:
$cert = New-SelfSignedCertificate ` -Subject "CN=Peabody-GovernanceAudit" ` -CertStoreLocation "Cert:\CurrentUser\My" ` -KeyExportPolicy Exportable ` -KeySpec Signature ` -KeyLength 2048 ` -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddYears(2) $certPath = "C:\Peabody\Certs\GovernanceAudit.cer" Export-Certificate -Cert $cert -FilePath $certPath # Upload .cer (public) to App Registration → Certificates & Secrets # Then export PFX (private key) for Azure Key Vault: $pfxPath = "C:\Peabody\Certs\GovernanceAudit.pfx" $pwd = ConvertTo-SecureString -String "[strong-password]" -Force -AsPlainText Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $pwd # Record thumbprint for Power Query token requests: $cert.Thumbprint
3.2 Token Acquisition Pattern for Power Query
Power Query does not natively support certificate-based OAuth. Three production-grade patterns:
Deploy a lightweight Azure Function (HTTP trigger) that:
- Loads the certificate from Azure Key Vault
- Requests a bearer token from Azure AD
- Returns the token to Power Query as JSON
Power Query calls the Azure Function URL with a managed identity or Function key. The token is injected into all API requests.
Why preferred: Decouples certificate from .pbix file; centralised renewal; server-side retry-after handling; no secrets ever land in Power BI.
Create a temporary client secret in the app registration for initial development and testing of Power Query queries only.
If the tenant permits delegated SharePoint REST access via a service account, the Power BI Gateway can use a stored credential. Less preferred than app-only certificate auth — wider blast radius and harder to audit.
3.3 Token Request Parameters
| Parameter | Value |
|---|---|
| Grant type | client_credentials |
| Token endpoint | https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token |
| SharePoint scope | https://{tenant}.sharepoint.com/.default |
| Graph scope | https://graph.microsoft.com/.default |
| Token lifetime | 3600 seconds (1 hour) |
Power BI Data Sources: SharePoint REST API
Four Power Query (M) modules — Site Discovery, Library Profiler, User & Permission Auditor, Item-Level Broken Inheritance Scan — each parameterised on SiteUrl and called iteratively across all sites.
4.1 Base REST Pattern
application/json;odata=nometadata to the Accept header. Removes OData envelope noise and returns clean JSON — essential for Power Query parsing performance at scale.let token = // bearer from Azure Function proxy, headers = [ #"Authorization" = "Bearer " & token, #"Accept" = "application/json;odata=nometadata" ], url = siteUrl & "/_api/web/...", response = Json.Document(Web.Contents(url, [Headers = headers])) in response
4.2 Module 1 — Site Discovery
Pull identity and health properties for each site. Feeds FactSiteInventory and DimSite.
| Response Field | Semantic Model Column |
|---|---|
web/Title | SiteTitle |
web/Url | SiteUrl |
web/Created | SiteCreatedDate |
web/LastItemModifiedDate | SiteLastModifiedDate |
web/MembersCanShare | MembersCanShare |
site/Owner/Email | PrimaryOwnerEmail |
site/StorageUsed | StorageUsedMB |
web/AssociatedOwnerGroup/Users | OwnerCount (calculated) |
| [Hub association via Graph] | HubSiteId, IsHubAssociated |
| [Sensitivity label via Graph] | SensitivityLabelId |
| [Teams connection via Graph] | IsTeamsConnected, M365GroupId |
REST Endpoints
GET {siteUrl}/_api/web?$select=Title,Url,Created,LastItemModifiedDate,Description,MembersCanShare GET {siteUrl}/_api/site?$select=Owner,StorageUsed,StorageUsageCurrent GET {siteUrl}/_api/web/associatedownergroup/users?$select=Email,LoginName,Title
4.3 Pagination Handler (Mandatory)
@odata.nextLink or you will silently receive incomplete data.let GetAllPages = (url as text, token as text) => let GetPage = (pageUrl as text, accumulated as list) => let response = Json.Document(Web.Contents(pageUrl, [ Headers = [ Authorization = "Bearer " & token, Accept = "application/json;odata=nometadata" ] ])), items = response[value], nextLink = try response[#"odata.nextLink"] otherwise null, combined = accumulated & items, result = if nextLink = null then combined else @GetPage(nextLink, combined) in result in GetPage(url, {}) in GetAllPages
4.4 Module 2 — Library Profiler
For each site, enumerate non-hidden lists/libraries and assess classification health. Feeds FactLibraryClassification.
GET {siteUrl}/_api/web/lists
?$filter=Hidden eq false
&$select=Title,BaseTemplate,ItemCount,ContentTypesEnabled,
HasUniqueRoleAssignments,LastItemModifiedDate,Id
&$expand=ContentTypes($select=Name),
Fields($filter=Hidden eq false;$select=Title)
Base Column Presence Check (7 columns)
HasCol_BusinessUnit = "Business Unit" in Fields[Title] HasCol_Mine = "Mine" in Fields[Title] HasCol_Geography = "Geography" in Fields[Title] HasCol_DocumentStatus = "Document Status" in Fields[Title] HasCol_Confidentiality = "Confidentiality" in Fields[Title] HasCol_RetentionLabel = "Retention Label" in Fields[Title] HasCol_OwnerApprover = "Owner" in Fields[Title] BaseColumnScore = // sum of 7 flags above (0–7) BaseColumnGapCount = 7 - BaseColumnScore
Enterprise Content Type Check
HasEnterpriseContentType = true if any ContentTypeName is NOT IN the OOTB set (Document, Folder, Item, Event, Announcement, Task, Link).
4.5 Module 3 — User & Permission Auditor
Pulls all users + permission assignments per site; identifies guests and broken inheritance at site level.
| Endpoint | Fields Extracted |
|---|---|
/_api/web/siteusers | Email, LoginName, Title, IsSiteAdmin, UserId, UserType |
/_api/web/sitegroups (expanded) | Group Title, Group Type, User Email per group |
/_api/web/roleassignments?$expand=Member,RoleDefinitionBindings | Member/LoginName, PrincipalType, RoleDefinitionBindings/Name |
Guest Detection
IsExternalUser = Text.Contains(LoginName, "#EXT#") or UserType = "Guest"
Custom Permission Level Detection
IsCustomPermissionLevel = RoleDefinitionName not in {"Full Control","Design","Edit","Contribute", "Read","View Only","Limited Access", "Approve","Manage Hierarchy"}
4.6 Module 4 — Item-Level Broken Inheritance Scan (Pass 2)
LibraryHasBrokenInheritance = true from Module 2. Passing all libraries through this query will exceed throttle limits and create enormous, mostly empty result sets.GET {siteUrl}/_api/web/lists/getById('{listId}')/items
?$filter=HasUniqueRoleAssignments eq true
&$select=Id,FileLeafRef,FileRef,Author/Email,
Editor/Email,Modified,HasUniqueRoleAssignments
&$expand=RoleAssignments/Member/Email,
RoleAssignments/RoleDefinitionBindings,
Author,Editor
Keyword Signal Matching (applied at this step)
KeywordGroup = if any of [contract, agreement, renewal, pricing, PLV, HCC, PCI, nomination, charter, reopener] in FileName then "Commercial" else if any of [earnings, EBITDA, forecast, budget, guidance, acquisition, Anglo, Moranbah, bid, LOI, NDA] then "Financial-MNPI" else if any of [reserve, resource, geological, seam, bore, JORC] then "Geological" else if any of [incident, MSHA, injury, fatality, LTI, permit, reclamation, bond, compliance, violation] then "Safety-Regulatory" else if any of [laycan, vessel, demurrage, disruption, outage, Black Warrior, lock, BNSF, Aurizon] in FileName then "Logistics" else "General" KeywordHitFlag = KeywordGroup <> "General"
Power BI Data Sources: Microsoft Graph API
Same Power Query pattern as REST but different token (scope https://graph.microsoft.com/.default) and base URL https://graph.microsoft.com/v1.0/. Three purposes: sharing-link resolution, OneDrive enumeration, site enrichment (hub / Teams / sensitivity).
5.2 Site Enrichment Query
GET /v1.0/sites?search=*&$select=id,displayName,webUrl,sharepointIds GET /v1.0/sites/{siteId}?$select=id,displayName,webUrl,sensitivity,sharepointIds GET /v1.0/sites/{siteId}/termStore // Hub association: response includes isHubSite, hubSiteId properties // isHubSite = false AND hubSiteId = null → ungoverned standalone // Teams connection: GET /v1.0/groups?$filter=resourceProvisioningOptions/any(x:x eq 'Team') &$select=id,displayName,resourceProvisioningOptions,sharepointIds
5.3 Teams Drive Enumeration
One drive per General + one per private/shared channel. Each Teams channel creates a separate document library backed by its own drive — enumerate all drives so no channel library is skipped in Pass 3.
GET /v1.0/sites/{siteId}/drives
?$select=id,name,driveType,createdBy,lastModifiedDateTime
5.4 Pass 3 — Sharing Link Resolution
For each item flagged in Pass 2 + all high-priority OneDrive items, retrieve the full permission object including link scope. Feeds FactExternalShares.
GET /v1.0/drives/{driveId}/items/{itemId}/permissions
?$select=id,roles,link,grantedTo,grantedToIdentities,
inheritedFrom,hasPassword,expirationDateTime
| Field | Extract / Detection |
|---|---|
link/scope | "anonymous" | "organization" | "users" | null |
link/type | "view" | "edit" | "review" |
link/expirationDateTime | null = never expires (flag) |
grantedTo/user/email | Recipient — extract domain, join DimApprovedDomains |
inheritedFrom | null = unique permission on this item |
roles | ["read"] | ["write"] | ["owner"] |
IsAnonymousLink = link/scope = "anonymous" IsOrgWideLink = link/scope = "organization" IsNeverExpires = link/expirationDateTime = null and link <> null IsApprovedDomain = recipientDomain in DimApprovedDomains IsExternalUnknown = IsExternalUser and not IsApprovedDomain
5.5 OneDrive Shadow Content Scan
For priority-role users, enumerate OneDrive items with external/org-wide sharing matching sensitive keyword patterns. Feeds FactOneDriveShadowContent.
GET /v1.0/users?$filter=accountEnabled eq true
&$select=id,displayName,mail,department,jobTitle,userTypeFilter to Department IN (Commercial, Marketing, Finance, Investor Relations, Legal, Compliance, Capital Projects, HSE).
GET /v1.0/users/{userId}/drive?$select=id,name,owner,lastModifiedDateTime,quota// BASELINE (first run): GET /v1.0/drives/{driveId}/root/search(q='') ?$select=id,name,size,webUrl,lastModifiedDateTime,file,parentReference &$top=1000 // INCREMENTAL — store delta token per drive: GET /v1.0/drives/{driveId}/root/delta?token={savedDeltaToken}
Only pull permissions for items where KeywordHitFlag = true OR file/size > 0.
GET /v1.0/drives/{driveId}/items/{itemId}/permissionsOneDriveRiskTier = if IsAnonymousShare or (IsExternalShare and not IsApprovedDomain) then "Critical" else if IsExternalShare and IsApprovedDomain then "High" else if IsOrgWideShare and KeywordHitFlag then "High" else if IsOrgWideShare then "Medium" else "Low"
5.6 Throttling — Mandatory Retry Pattern
let CallWithRetry = (url as text, token as text, maxRetries as number) => let Attempt = (n as number) => let resp = try Web.Contents(url, [ Headers = [Authorization = "Bearer " & token], ManualStatusHandling = {429, 503} ]), status = Value.Metadata(resp)[Response.Status], result = if status = 429 and n < maxRetries then @Attempt(n + 1) else Json.Document(resp) in result in Attempt(0) in CallWithRetry
Retry-After server-side before returning the response.Semantic Model Design
Standard star schema — five fact tables and eight dimension tables. All relationships one-to-many from dim → fact. No many-to-many (creates ambiguous filter contexts in DAX).
6.1 Star Schema Overview
6.2 Fact Table Schemas
| Column | Type | Source | Notes |
|---|---|---|---|
| SiteKey | Int | Surrogate | PK |
| SiteId | Text | Graph | GUID |
| SiteUrl, SiteTitle | Text | REST /_api/web | |
| HubSiteId, IsHubAssociated | Text/Bool | Graph | null = ungoverned |
| IsTeamsConnected, M365GroupId | Bool/Text | Graph | |
| SensitivityLabelId | Text | Graph | FK → DimSensLabel |
| OwnerCount, HasTwoOwners | Int/Bool | REST /sitegroups | Gap check |
| ExternalUserCount, HasExternalUsers | Int/Bool | REST /siteusers | Flag |
| StorageUsedMB | Dec | REST /_api/site | |
| SiteCreatedDate, SiteLastModifiedDate | Date | REST /_api/web | |
| DaysSinceModified, IsStale | Int/Bool | Derived | |
| BUKey | Int | Lookup on SiteTitle | FK → DimBU |
| AuditRunDate | Date | Loaded at refresh |
| Column | Type | Source | Notes |
|---|---|---|---|
| LibraryKey, SiteKey, ListId, LibraryTitle | Int/Int/Text/Text | Surrogate / FK / REST | PK / FK → DimSite |
| TemplateType, ItemCount | Int | REST lists | 101 = DocLib |
| ContentTypesEnabled, HasEnterpriseContentType | Bool | REST / Derived | Gap check |
| LibraryHasBrokenInherit | Bool | REST lists | Gap check |
| BaseColumnScore, BaseColumnGapCount | Int | Derived | 0–7 |
| HasRetentionLabel | Bool | REST / Purview | Gap check |
| LibraryLastModifiedDate | Date | REST lists | |
| GovernanceGapCount | Int | Sum of gap flags | Composite score |
| LibraryRiskTierKey | Int | Derived | FK → DimRiskTier |
| Column | Type | Source |
|---|---|---|
| PermissionKey, LibraryKey, SiteKey | Int | Surrogate / FKs |
| ItemId, FileName, FilePath | Text | REST items |
| CreatedByUserKey, LastModifiedByUserKey | Int | FK → DimUser |
| LastModifiedDate | Date | REST items |
| PermissionedUserEmail, PermissionLevel | Text | REST roleassignments |
| IsExternalUser, IsCustomPermLevel | Bool | Derived |
| KeywordGroup, KeywordHitFlag | Text/Bool | Derived |
| RiskTierKey | Int | Derived → DimRiskTier |
| Column | Type | Source |
|---|---|---|
| ShadowKey, UserKey, DriveId, ItemId | Int/Int/Text/Text | Surrogate / FK / Graph |
| FileName, FilePath, FileSizeKB | Text/Text/Dec | Graph |
| IsAnonymousShare, IsOrgWideShare, IsExternalShare | Bool | Graph permissions |
| IsApprovedDomain | Bool | Join DimApprDomains |
| ExternalRecipientEmail, SharingLinkUrl | Text | Graph permissions |
| LinkExpiryDate, IsNeverExpires, IsZombieLink | Date/Bool/Bool | Graph / Derived |
| KeywordGroup, KeywordHitFlag, RiskTierKey | Text/Bool/Int | Derived |
| Column | Type | Source |
|---|---|---|
| ShareKey, SiteKey, LibraryKey, SharedByUserKey | Int | Surrogate / FKs |
| FileName, FilePath | Text | Graph / REST |
| SharingType, LinkScope, LinkType, SharingLinkUrl | Text | Derived / Graph |
| LinkCreatedDate, LinkExpiryDate, IsNeverExpires | Date/Date/Bool | Graph / Derived |
| IsPasswordProtected | Bool | Graph |
| RecipientEmail, RecipientDomain | Text | Graph / Derived |
| IsApprovedDomain, IsExternalUnknown, IsZombieLink | Bool | Derived |
| KeywordGroup, KeywordHitFlag, DaysLinkActive, RiskTierKey | Text/Bool/Int/Int | Derived |
6.3 Dimension Tables (Reference)
DimBusinessUnit
| BUKey | BU Name | Short Code | Strategic Posture |
|---|---|---|---|
| 1 | Seaborne Metallurgical | SEA-MET | Growth |
| 2 | Seaborne Thermal | SEA-THR | Stable Export |
| 3 | Powder River Basin | PRB | Cash Generation |
| 4 | Other U.S. Thermal | US-THR | Cash Generation |
| 5 | Corporate & Other | CORP | Support |
| 99 | Unknown / Unclassified | UNK | — |
DimMine
| Mine | BU | Geography | Type |
|---|---|---|---|
| Centurion | 1 | QLD, AUS | Underground |
| Coppabella | 1 | QLD, AUS | Surface |
| Moorvale | 1 | QLD, AUS | Surface |
| Metropolitan | 1 | NSW, AUS | Underground |
| Shoal Creek | 1 | AL, USA | Underground |
| Middlemount | 5 | QLD, AUS | Surface (JV) |
| Wilpinjong | 2 | NSW, AUS | Surface |
| Wambo | 2 | NSW, AUS | Mixed |
| NARM, Caballo, Rawhide | 3 | WY, USA | Surface |
| Illinois Basin | 4 | IL, USA | Mixed |
DimSensitivityLabel
| Label | Weight | Color |
|---|---|---|
| Public | 1 | Green |
| Internal | 2 | Blue |
| Confidential | 3 | Yellow |
| Restricted (MNPI) | 4 | Red |
| Unlabeled | 5 | Dark Red |
DimRiskTier
| Tier | Weight | SLA Days | Color |
|---|---|---|---|
| Critical | 4 | 0 | #CC0000 |
| High | 3 | 30 | #FF6600 |
| Medium | 2 | 90 | #FFCC00 |
| Low | 1 | 180 | #339933 |
6.4 Key DAX Measures
All measures live in a dedicated measure table (no columns). Prefix with [_] to sort to top of field list.
Governance KPI Measures
[_ Total Sites] = COUNTROWS(FactSiteInventory) [_ Sites Without Sensitivity Label] = CALCULATE(COUNTROWS(FactSiteInventory), DimSensitivityLabel[LabelName] = "Unlabeled") [_ % Sites Unlabeled] = DIVIDE([_ Sites Without Sensitivity Label], [_ Total Sites], 0) [_ Sites Without Two Owners] = CALCULATE(COUNTROWS(FactSiteInventory), FactSiteInventory[HasTwoOwners] = FALSE) [_ Libraries Without Enterprise CT] = CALCULATE(COUNTROWS(FactLibraryClassification), FactLibraryClassification[HasEnterpriseContentType] = FALSE) [_ Libraries With Broken Inheritance] = CALCULATE(COUNTROWS(FactLibraryClassification), FactLibraryClassification[LibraryHasBrokenInherit] = TRUE)
Oversharing Measures
[_ Anonymous Links Active] = CALCULATE(COUNTROWS(FactExternalShares), FactExternalShares[LinkScope] = "anonymous") [_ Unknown External Shares] = CALCULATE(COUNTROWS(FactExternalShares), FactExternalShares[IsExternalUnknown] = TRUE) [_ Zombie Links] = CALCULATE(COUNTROWS(FactExternalShares), FactExternalShares[IsZombieLink] = TRUE) [_ Never-Expiring Links] = CALCULATE(COUNTROWS(FactExternalShares), FactExternalShares[IsNeverExpires] = TRUE, FactExternalShares[LinkScope] <> "users") [_ MNPI Files Shared Externally] = CALCULATE(COUNTROWS(FactExternalShares), FactExternalShares[KeywordGroup] = "Financial-MNPI") [_ OneDrive Critical Risk Files] = CALCULATE(COUNTROWS(FactOneDriveShadowContent), DimRiskTier[RiskTier] = "Critical")
Composite Governance Risk Score
[_ Governance Risk Score] = VAR AnonymousWeight = [_ Anonymous Links Active] * 10 VAR MNPIWeight = [_ MNPI Files Shared Externally] * 8 VAR UnknownExtWeight = [_ Unknown External Shares] * 6 VAR ZombieWeight = [_ Zombie Links] * 4 VAR NoLabelWeight = [_ Sites Without Sensitivity Label]* 5 VAR NoOwnerWeight = [_ Sites Without Two Owners] * 3 VAR NoCTWeight = [_ Libraries Without Enterprise CT]* 2 RETURN AnonymousWeight + MNPIWeight + UnknownExtWeight + ZombieWeight + NoLabelWeight + NoOwnerWeight + NoCTWeight
Report Pages Design (6 Pages)
Six purpose-built pages with distinct audiences. Pages 1 and 3 are surfaced to executives via the Power BI App; Pages 1–6 are accessible to IT roles.
Page 1 — Executive Governance Summary
CIO · CFO · CLO · CISO · CEOPage 2 — Site Health Matrix
IT Leadership · SP Admins · CoEPage 3 — External & Anonymous Sharing
CISO · Legal · IT AdminPage 4 — OneDrive Shadow Content
IT Admin · Compliance · HR (limited)Page 5 — Broken Inheritance Drill
IT Admin · SP AdminsPage 6 — Remediation Tracker
IT Operations · CoEExecutive Dashboard & Power BI App
A pinned-tile dashboard surfaced through a published Power BI App — the only artefact most executives ever see. Optimised for at-a-glance daily review on desktop or Power BI mobile.
8.1 Dashboard Tile Layout
8.2 Power BI App Configuration
| Setting | Value |
|---|---|
| App Name | Peabody Governance Audit |
| Description | Quarterly governance health, oversharing exposure, remediation tracking |
| Logo | Peabody Energy navy/gold (Brand Hub asset · 256×256 PNG) |
| Theme Color | Navy #1a2e5a primary · Gold #e8920a accent |
| Audience: Executives | Page 1 + Page 3 only · CEO, CFO, CIO, CLO, CISO, BU Presidents |
| Audience: IT Leadership | All pages · Director IT, Manager Modern Workplace |
| Audience: SP Admins | Pages 2, 5, 6 · Operational team |
| Audience: Compliance / Legal | Pages 3, 4 · Restricted to non-PII view (RLS) |
8.3 Subscription & Alert Configuration
| Alert | Threshold | Recipients | Frequency |
|---|---|---|---|
| Critical findings spike | +5 from prior run | CIO, CISO, IT Director | Real-time email |
| New anonymous link detected | Any new anonymous link | CISO, IT Admin team | Real-time email + Teams card |
| MNPI external sharing | Any new MNPI keyword external share | CFO, CLO, CISO | Real-time email (high priority) |
| Risk score breach | Score > 1,000 (action threshold) | Executive Steering Committee | Same-day email |
| Page 1 weekly digest | — | Executive distribution | Monday 07:00 CT (PDF) |
#governance-alerts channel for IT operational visibility.Row-Level & Object-Level Security
The audit dataset itself contains sensitive metadata (file names, user identities, sharing exposure). RLS and OLS apply least privilege so each audience sees only what their role requires.
9.1 Role & Access Matrix
| Role | Page 1 Exec | Page 2 Sites | Page 3 External | Page 4 OneDrive | Page 5 Broken | Page 6 Remediation | User Identities |
|---|---|---|---|---|---|---|---|
| Executive (CEO/CFO/CIO/CLO) | YES | NO | PARTIAL | NO | NO | SUMMARY | MASKED |
| BU President | OWN BU | OWN BU | OWN BU | NO | OWN BU | OWN BU | DEPT ONLY |
| CISO / IT Security | YES | YES | YES | YES | YES | YES | FULL |
| SP Admin / IT Operations | YES | YES | YES | YES | YES | YES | FULL |
9.2 RLS Filter — DAX
// On DimBusinessUnit table [BUName] = LOOKUPVALUE( DimUserBUMapping[BUName], DimUserBUMapping[UserPrincipalName], USERPRINCIPALNAME() )
// On DimUser table — mask individual identities for execs [UserPrincipalName] <> USERPRINCIPALNAME() && FALSE() // blocks user-level rows; aggregations roll up safely
Single (dim → fact) — bidirectional filters break RLS in unpredictable ways.9.3 Object-Level Security (Column Masking)
Apply via Tabular Editor (free tool, OLS not supported in Power BI Desktop GUI). Hide columns from named roles entirely so they cannot be referenced in DAX, exports, or Q&A.
| Table.Column | Hidden From Role | Reason |
|---|---|---|
FactOneDriveShadowContent[FileName] | Executive | File names may reveal MNPI |
FactOneDriveShadowContent[FilePath] | Executive | Path discloses project codenames |
DimUser[Email] & [DisplayName] | Executive | Avoid exec → individual punitive action; aggregate views only |
FactExternalShares[RecipientEmail] | BU President (other BUs) | Cross-BU exposure not in BU scope |
FactItemPermissions[FileName] | Executive | Names disclose deal codenames |
Refresh Strategy & Performance
Three-tier refresh: full quarterly baseline + weekly incremental + daily delta on highest-risk subset. Total cycle stays under 6 hours with throttle-aware patterns.
10.1 Refresh Policy
| Tier | Scope | Schedule | Mechanism | Approx Duration |
|---|---|---|---|---|
| Quarterly Full | All facts & dims · all sites · all libraries · all items | 1st Mon of Q · 22:00 CT | Manual trigger (CoE) | 4–6 hours |
| Weekly Incremental | Modified items only via Graph delta tokens | Mon 03:00 CT | Scheduled refresh | 45–90 min |
| Daily Delta (High-Risk) | Sites flagged Critical or High · external/anon links · MNPI keywords | Daily 06:00 CT | Scheduled refresh (separate dataflow) | 10–20 min |
| Real-time Alerts | New anonymous links · MNPI external shares | Push (Graph subscriptions) | Power Automate flow | < 5 min latency |
10.2 Incremental Refresh Configuration
let Source = SharePointAuditTable, FilteredByDate = Table.SelectRows(Source, each [LastModifiedDate] >= RangeStart and [LastModifiedDate] < RangeEnd) in FilteredByDate
| Setting | Value |
|---|---|
| Store data starting | 5 years before refresh date |
| Incrementally refresh | 30 days before refresh date |
| Detect data changes | Use LastModifiedDate column |
| Refresh complete days only | Yes |
10.3 Graph Delta Token Pattern
GET /drives/{driveId}/root/delta with no token. Returns all items + a delta token.driveId, value: deltaToken, lastSync.GET /drives/{driveId}/root/delta?token={savedDeltaToken} — returns only changed items.@odata.deltaLink after successful processing.10.4 On-Premises Data Gateway
Required because the Azure Function token proxy lives in Peabody's Azure tenant and must be reachable from Power BI Service via secure channel.
| Component | Configuration |
|---|---|
| Gateway type | Standard mode (cluster of 2 for HA) |
| Service account | Domain account with logon-as-service · KeyVault read scope |
| Data source | Web data source pointing to Azure Function URL |
| Authentication | Anonymous (Function key passed in URL · key rotated quarterly) |
| Privacy level | Organizational |
Deployment Pipeline
Three-stage Power BI deployment pipeline (Dev → Test → Prod) with parameter rules so the same .pbix promotes cleanly without re-pointing data sources manually.
11.1 Pre-Deployment Checklist
- Model file size < 1 GB (current ~120 MB target)
- All measures documented with description (Tabular Editor → Properties → Description)
- No unused columns visible to end users (Hide from Client Tools)
- All RLS roles tested via "View As Roles"
- OLS roles validated via Tabular Editor → "Test as Role"
- Refresh succeeds end-to-end on Test workspace gateway
- Performance Analyzer: no visual > 3,000 ms render time
- DAX queries reviewed by CoE — no
FILTER(ALL(table))in measures (perf killer) - Theme JSON file matches Peabody brand tokens
11.2 Deployment Steps
SiteUrlBase, FunctionAppUrl, TenantId swap to Test values.11.3 Source Control & Commit Conventions
Power BI Project (.pbip) format committed to Azure DevOps Git repo. One commit per logical change. Conventional Commits style.
feat(model): add FactOneDriveShadowContent table fix(rls): correct BU President filter cross-direction perf(query): use delta token for OneDrive enumeration docs(measure): add description for [_ Governance Risk Score] chore(theme): update gold accent to brand-compliant #e8920a
Implementation Master Checklist
Authoritative sequence for the developer team — 73 independently actionable tasks across 9 phases. Tasks marked PARALLEL may be executed concurrently with the prior task. Progress is saved to your browser's local storage.