Auditing a Fabric Tenant on a Schedule with the Scanner and Admin APIs
Most Fabric governance still happens by an admin clicking through the admin portal and the OneLake catalog, which means it happens when someone remembers. Everything those screens show comes from REST endpoints a service principal can call on a schedule, so the useful question is not “what does the portal say today” but “what changed since last night, and who should hear about it”. The HTTP calls are routine; the design decision is which identity may read the whole tenant and how to guarantee it can never change it.
Detection is the half I cover here: collecting actual state, comparing it with what you meant, and raising findings. Provisioning from a manifest is in Fabric CI/CD Is Solved. Your Tenant Isn’t..
Two identities, two tenant settings
Fabric gates service principal access to admin APIs behind two separate tenant settings under Admin API settings, and I treat collapsing them as the first governance mistake to avoid.
Service principals can access read-only admin APIs is the broad one. A service principal in the allowed security group gets read-only access to all the information available through admin APIs, current and future, including user names, emails and detailed semantic model and report metadata. That is a lot of disclosure for one credential, so the group behind it should hold only inventory and scanning identities.
Service principals can access admin APIs used for updates is a separate switch with its own security group. It covers Fabric admin APIs that change state; the documented example is Restore Workspace.
My rule is two Entra security groups, two service principals, and no principal in both. The scanner identity can read everything and change nothing, so a leaked scanner credential is a disclosure incident rather than a modification incident. The remediation identity lives only in the update group, is used only by named remediation jobs, and its activity in the audit log should be rare enough that every event is worth a look.
An update-capable admin service principal can restore and manipulate workspaces tenant-wide. Keep it in a separate Entra group and a separate identity from the read-only scanner SPN, so a compromised inventory credential leaks disclosure, never modification.
Two constraints catch people out:
- An app that calls the read-only admin APIs as a service principal must not have any admin-consent-required Power BI permissions of type Application on its registration. The common trap is reusing a “Power BI integration” app that already has
Tenant.Read.Allgranted. - The developer settings are a different thing again. Service principals can call Fabric public APIs governs the non-admin Fabric APIs, and Service principals can create workspaces, connections, and deployment pipelines governs those three create operations. The scanner script below needs neither, because it calls only admin endpoints.
What the scanner APIs give you
The scanner APIs are four Power BI admin endpoints under the WorkspaceInfo group: GetModifiedWorkspaces, PostWorkspaceInfo, GetScanStatus and GetScanResult. They need no special licence and cover non-Premium workspaces. The flow is fixed:
- Call
workspaces/modifiedwithoutmodifiedSinceonce for a full inventory, then withmodifiedSinceset to the start of the previous run for incremental scans.modifiedSincemust be between 30 minutes and 30 days ago, so a job that misses a month needs a full rescan. UseexcludePersonalWorkspaces=trueunless you govern My Workspaces too. - Split the IDs into batches of at most 100 and call
workspaces/getInfofor each batch. It returns202 Acceptedand a scan ID. - Poll
workspaces/scanStatus/{scanId}every 30 to 60 seconds until it reportsSucceeded. - Read
workspaces/scanResult/{scanId}. Results stay available for 24 hours, so persist them promptly.
The query parameters on getInfo decide how deep the scan goes: lineage, datasourceDetails, datasetSchema, datasetExpressions and getArtifactUsers. The schema and expression flags return nothing unless a Fabric admin has also enabled Enhance admin APIs responses with detailed metadata (table, column and measure names) and Enhance admin APIs responses with DAX and mashup expressions (the second requires the first). Both apply to service principals only when the read-only admin API switch is on. Expect empty tables arrays the first time you run against a tenant where nobody flipped these.
Know the gaps before you build controls on top of the output. Semantic models that haven’t been refreshed or republished come back with name and lineage but no tables. Models over 1 GB in shared (non-Premium) workspaces return no subartifact metadata. Real-time datasets, models with object-level security, live connections to Analysis Services and Excel full-fidelity datasets explain themselves in a schemaRetrievalError field instead. A control that says “no PII columns found” over a model with no schema is a false negative, so I report “schema unavailable” as its own finding.
The rate limits are low: getInfo and scanResult allow 500 requests per hour (getInfo with at most 16 in flight), and modified only 30. Microsoft’s guidance is to wait for a scan to finish before starting the next getInfo, which the script does by running batches sequentially.
A scheduled scan in Python
This is a complete script for one run. It authenticates with DefaultAzureCredential (environment variables, workload identity in CI, or managed identity on Azure), scans every active shared workspace, and flags guests with workspace roles and semantic models that have no sensitivity label or expose columns whose names look like personal data.
Install with pip install requests azure-identity; for a service principal set AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET (or AZURE_FEDERATED_TOKEN_FILE in CI).
import time
import requests
from azure.identity import DefaultAzureCredential
BASE = "https://api.powerbi.com/v1.0/myorg/admin/workspaces"
SCOPE = "https://analysis.windows.net/powerbi/api/.default"
PII_HINTS = ("email", "phone", "mobile", "birth", "dob", "tfn", "medicare", "address")
def call(session, method, url, **kwargs):
for _ in range(5):
resp = session.request(method, url, timeout=60, **kwargs)
if resp.status_code == 429:
try:
wait = int(resp.headers.get("Retry-After", "60"))
except ValueError: # Retry-After can be an HTTP date
wait = 60
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"Still throttled after 5 attempts: {url}")
def scan_batch(session, workspace_ids):
params = {"datasetSchema": "true", "getArtifactUsers": "true"}
scan = call(session, "POST", f"{BASE}/getInfo", params=params, json={"workspaces": workspace_ids})
for _ in range(60): # give up after roughly 30 minutes
time.sleep(30)
status = call(session, "GET", f"{BASE}/scanStatus/{scan['id']}")["status"]
if status == "Succeeded":
return call(session, "GET", f"{BASE}/scanResult/{scan['id']}")
if status == "Failed":
raise RuntimeError(f"Scan {scan['id']} failed")
raise TimeoutError(f"Scan {scan['id']} still {status} after 30 minutes")
def findings(result):
for ws in result.get("workspaces", []):
for user in ws.get("users", []):
if user.get("userType") == "Guest":
yield ws.get("name"), f"guest with workspace role: {user.get('emailAddress') or user.get('identifier')}"
for ds in ws.get("datasets", []):
where = f"{ws.get('name')} / {ds.get('name')}"
if ds.get("schemaRetrievalError"):
yield where, f"schema unavailable: {ds['schemaRetrievalError']}"
if not (ds.get("sensitivityLabel") or {}).get("labelId"):
yield where, "no sensitivity label"
for table in ds.get("tables", []):
for field in table.get("columns", []) + table.get("measures", []):
name = field.get("name", "")
if any(h in name.lower() for h in PII_HINTS):
yield where, f"possible PII field {table.get('name')}.{name}"
def authorise(session, credential):
session.headers["Authorization"] = f"Bearer {credential.get_token(SCOPE).token}"
def main():
credential = DefaultAzureCredential()
session = requests.Session()
authorise(session, credential)
workspaces = call(session, "GET", f"{BASE}/modified", params={"excludePersonalWorkspaces": "true", "excludeInActiveWorkspaces": "true"})
ids = [w["id"] for w in workspaces]
for i in range(0, len(ids), 100):
authorise(session, credential) # tokens last about an hour; refresh per batch
for where, issue in findings(scan_batch(session, ids[i:i + 100])):
print(f"{where}: {issue}")
if __name__ == "__main__":
main()
It leaves out datasetExpressions on purpose: it never reads DAX or M, so it shouldn’t collect them. In production, write the raw scanResult JSON to a Lakehouse before interpreting it, and switch to modifiedSince after the first full run. Column-name matching is a cheap heuristic; it finds CustomerEmail and misses Col_17, which is exactly why the label check sits beside it.
Inventory from the Fabric admin APIs
The scanner’s result lists Fabric items in each workspace, but its deep metadata (tables, columns, measures) is semantic-model shaped. For capacity, domain and item ownership I use two admin endpoints on api.fabric.microsoft.com, both callable by a service principal in the read-only group:
- List Workspaces (
GET /v1/admin/workspaces) returns each workspace withcapacityIdanddomainId, filters bytype,state,capacityIdandname, pages up to 10,000 records with a continuation token, and allows 200 requests per hour. Like List Items, it carries a Preview note in the API reference as of July 2026, so pin your parsing to the fields you use. - List Items (
GET /v1/admin/items) returns every item withworkspaceId,capacityIdandcreatorPrincipal. It is also marked preview, so expect its shape to change.
For domains, List Domains and List Domain Workspaces let you cross-check a workspace’s domainId against the domain’s own assignment list; the release version of List Domains needs preview=false on every call, so pass it explicitly.
For guests, you already have the data: the workspace users in the scan result carry userType, which is what the script’s guest check filters on. GetGroupsAsAdmin with $expand=users is a slower fallback. The older Power BI admin surface also has endpoints the Fabric APIs don’t replace. GetUnusedArtifactsAsAdmin (still a preview API), for example, lists datasets, reports and dashboards nobody has used in 30 days, per workspace.
If you’d rather not write a client, the Fabric CLI api command calls these endpoints with the CLI’s credentials and an -A audience switch (fabric, powerbi, azure or storage). Responses are wrapped in text, so a -q JMESPath query starts there.
fab auth login -u <client-id> --federated-token <token> --tenant <tenant-id>
fab api "admin/workspaces" -P "type=Workspace,state=Active" -q "text.workspaces[].{id:id,capacity:capacityId,domain:domainId}"
fab api -A azure "subscriptions/<subscription-id>/providers/Microsoft.Fabric/capacities?api-version=2023-11-01"
The CLI’s auth docs require the Fabric APIs service principal switch (now named Service principals can call Fabric public APIs) for service principal login, so either add the scanner identity to that setting’s group too or run the CLI interactively as a Fabric admin. That group lets it call non-admin Fabric APIs, but it can only act where it holds a workspace role and still can’t create workspaces, so the read-only guarantee holds as long as you never give it workspace roles.
The ARM call needs an Azure role such as Reader on the subscription or capacity resources; the Fabric tenant setting doesn’t grant it. Keep that role read-only too.
Turning state into findings
Collection is the easy part; the controls are joins between what the APIs return and a manifest in Git that says what should exist. These are the ones I’d start with, because each has an obvious owner and a clear fix:
| Control | Signal | Who acts |
|---|---|---|
| Workspace not in manifest | List Workspaces ID missing from Git | Platform team registers or retires it |
| Wrong capacity | capacityId differs from manifest |
Capacity owner |
domainId differs from manifest |
Domain admin | |
Scan result has no sensitivityLabel.labelId |
Item owner applies a label | |
| Possible PII without a label | Column or measure names match patterns, no label | Data owner, then Purview review |
| Guest with a workspace role | Scan result users with userType Guest |
Workspace admin |
| Stale content | GetUnusedArtifactsAsAdmin result | Item owner archives or justifies |
The stale-content check costs one call per workspace and is capped at 200 requests per hour, so a 2,000-workspace tenant needs at least 10 hours per sweep. Run it as a rolling weekly sweep, not in the nightly job.
None of these needs the update identity. Most remediation is a person acting on a finding, and I’d keep it that way until a fix has proven boring and repeatable. Automating a fix with a tenant-wide update credential is a bigger risk than most of the drift it corrects.
For usage context, the admin monitoring workspace (preview) gives you reports out of the box, but it refreshes once a day and its refresh fails if the admin who installed it loses the role or relies on PIM without being active at refresh time. I treat it as a view, not a pipeline. Get Activity Events returns raw activity for the last 28 days, one UTC day per request, so pull it daily and keep your own history.
If you want the collection layer prebuilt, FUAM (Fabric Unified Admin Monitoring) in Microsoft’s fabric-toolbox repository uses pipelines and notebooks to land tenant settings, activities, workspaces, capacity metrics and scanner metadata into a Lakehouse. It’s an open-source accelerator, not a supported product, so own it like your own code, and it needs more than the scanner identity: its service principal must be in both the read-only admin API group and the Fabric public APIs group, and its capacity-metrics notebooks run as a Fabric administrator who owns them, so treat its identity as a separate, broader one.
Where I’d draw the line
Run governance detection on a schedule with a read-only identity, and keep anything that writes to the tenant on a separate identity, in a separate group, behind change control. Start with the scanner and List Workspaces, because they answer “what exists and who owns it” with almost no setup. Don’t build controls on scan fields until the detailed-metadata enhance setting is on and you’ve handled models that return no schema, or you’ll report a clean tenant that you simply can’t see.
This approach isn’t worth it for a tenant with a handful of workspaces and one admin who knows all of them; the portal is fine there. It pays off once workspaces are created faster than anyone can review them by hand.