Fabric Notebook Patterns

The Fabric Estate Audit: What Central BI Should Be Able to Answer Without a Meeting

A read-only notebook that inventories every workspace, item, domain, and admin in the tenant, then asserts the governance rules you claim to have.

Past a few dozen workspaces, the Fabric portal is a browsing tool, not a management tool. This is the audit I run instead. Every call is a GET, so it creates nothing and fixes nothing, and it’s safe to schedule. The post shows the first half; this is the whole thing, including the per-workspace role checks that find the workspaces exactly one human can administer.

The client

Two helpers carry the whole notebook: one that survives throttling, and one that actually finishes a list.

# nb_estate_audit - central BI's read-only inventory of the Fabric estate.
# Creates nothing, fixes nothing. The asserts at the bottom are the deliverable.
import collections, json, time, urllib.error, urllib.parse, urllib.request
import notebookutils

API = "https://api.fabric.microsoft.com/v1"
TOKEN = notebookutils.credentials.getToken("https://api.fabric.microsoft.com")

def get(path, tries=6):
    """GET that honors Retry-After. The admin endpoints throttle, and they mean it."""
    for attempt in range(tries):
        req = urllib.request.Request(API + path, headers={"Authorization": f"Bearer {TOKEN}"})
        try:
            with urllib.request.urlopen(req) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code == 429 and attempt < tries - 1:
                time.sleep(int(e.headers.get("Retry-After") or 2 ** attempt))
                continue
            raise

def page(path, key):
    """Admin list endpoints are paginated. An unpaginated call does not error, it lies."""
    out, token = [], None
    while True:
        sep = "&" if "?" in path else "?"
        suffix = f"{sep}continuationToken={urllib.parse.quote(token)}" if token else ""
        body = get(path + suffix)
        out += body.get(key, [])
        token = body.get("continuationToken")
        if not token:
            return out

Outside a Fabric notebook, swap the notebookutils line for a service principal client-credentials token against scope https://api.fabric.microsoft.com/.default. Nothing else changes.

The inventory

workspaces = page("/admin/workspaces?type=Workspace", "workspaces")
domains = {d["id"]: d["displayName"] for d in get("/admin/domains")["domains"]}
items = page("/admin/items", "itemEntities")

print(f"workspaces {len(workspaces)} | items {len(items)} | domains {len(domains)}")
print(collections.Counter(i["type"] for i in items).most_common())

The type counter is worth reading every single run. It’s the fastest answer to “what kind of shop are we actually,” and the number that matters is semantic models against reports.

Who can administer what

This is the half the portal will genuinely never give you. One call per workspace, so pace it and let Retry-After do its job.

no_group_admin, bus_factor_one, guests = [], [], []

for w in workspaces:
    access = get(f"/admin/workspaces/{w['id']}/users")["accessDetails"]
    admins = [a["principal"] for a in access
              if a["workspaceAccessDetails"]["workspaceRole"] == "Admin"]

    # An individual as sole admin is a person-shaped single point of failure.
    if not any(p["type"] == "Group" for p in admins):
        no_group_admin.append(w["name"])
    if len(admins) == 1 and admins[0]["type"] == "User":
        bus_factor_one.append(f"{w['name']} ({admins[0]['displayName']})")

    # Anyone outside the tenant holding a write role is a finding, always.
    guests += [f"{w['name']}: {a['principal'].get('displayName')}" for a in access
               if "#EXT#" in (a["principal"].get("userDetails", {})
                              .get("userPrincipalName") or "")
               and a["workspaceAccessDetails"]["workspaceRole"] != "Viewer"]

Validation

The asserts are the point. Everything above is just collection.

# ---- Validation ----
kind = collections.Counter(i["type"] for i in items)
per_ws = collections.Counter(i["workspaceId"] for i in items)

undomained = [w["name"] for w in workspaces if not w.get("domainId")]
empty = [w["name"] for w in workspaces if per_ws[w["id"]] == 0]
stale = [i for i in items if i.get("lastUpdatedDate", "")[:4] < "2025"]

print(f"undomained {len(undomained)} | empty {len(empty)} | stale {len(stale)}")
print(f"no group admin {len(no_group_admin)} | bus factor 1 {len(bus_factor_one)}")
print(f"semantic models {kind['SemanticModel']} vs reports {kind['Report']}")

assert not undomained, f"{len(undomained)} workspaces carry no domain: {undomained[:5]}"
assert not empty, f"empty workspaces, retire them or fill them: {empty}"
assert not guests, f"external principals with write access: {guests}"
assert not bus_factor_one, f"workspaces with a single human admin: {bus_factor_one[:5]}"
assert kind["SemanticModel"] <= kind["Report"] * 0.5, \
    f"model-per-report sprawl: {kind['SemanticModel']} models for {kind['Report']} reports"
assert len(stale) <= len(items) * 0.25, \
    f"{len(stale)} of {len(items)} items untouched since 2025, nobody is retiring anything"

Run against my own tenant, that block reports 40 workspaces, 816 items, 3 domains, 39 workspaces with no domain, 30 with no group admin, 18 administered by exactly one human, 529 items untouched since 2025, and 267 semantic models against 273 reports. Six of the seven asserts fail. That is the correct outcome for a first run and the reason to run it.

Adapting it